Coverage for src/qutip_sampler/samplers.py: 100%

1"""

2 Quantum annealing sampler using QuTiP sesolve.

3

4 Maps a dimod BinaryQuadraticModel onto a transverse-field Ising model,

5 evolves the ground state of the transverse-field Hamiltonian to the Ising

6 Hamiltonian via a linear annealing schedule, then samples bitstrings from

7 the final quantum state probability distribution.

8"""

9

10 from __future__ import annotations

11

12 from typing import Any

13

14 import numpy as np

15 import dimod

16 import qutip

17

18 __all__ = ["QuTipSampler"]

19

20

21 def _operator_on_qubit(op: qutip.Qobj, qubit_idx: int, n_qubits: int) -> qutip.Qobj:

22 """Embed a single-qubit operator into the N-qubit Hilbert space.

23

24 Builds: I x ... x op x ... x I where op sits at position qubit_idx.

25 """

26 ops = [qutip.identity(2)] * n_qubits

27 ops[qubit_idx] = op

28 return qutip.tensor(ops)

29

30

31 def _build_ising_hamiltonian(

32 h: dict[Any, float],

33 J: dict[tuple[Any, Any], float],

34 variables: list[Any],

35) -> qutip.Qobj:

36 """Build H_ising = sum_i h[i]*sigma_z^(i) + sum_(i,j) J[i,j]*sigma_z^(i)*sigma_z^(j)."""

37 n = len(variables)

38 var_to_idx = {v: i for i, v in enumerate(variables)}

39

40 H = qutip.qzero([2] * n)

41

42 for var, bias in h.items():

43 if bias == 0.0:

44 continue

45 H += bias * _operator_on_qubit(qutip.sigmaz(), var_to_idx[var], n)

46

47 for (var_i, var_j), coupling in J.items():

48 if coupling == 0.0:

49 continue

50 ops = [qutip.identity(2)] * n

51 ops[var_to_idx[var_i]] = qutip.sigmaz()

52 ops[var_to_idx[var_j]] = qutip.sigmaz()

53 H += coupling * qutip.tensor(ops)

54

55 return H

56

57

58 def _build_transverse_hamiltonian(n_qubits: int) -> qutip.Qobj:

59 """Build H_transverse = -sum_i sigma_x^(i).

60

61 The negative sign ensures |+>^N is the ground state.

62 """

63 H = qutip.qzero([2] * n_qubits)

64 for i in range(n_qubits):

65 H -= _operator_on_qubit(qutip.sigmax(), i, n_qubits)

66 return H

67

68

69 def _initial_state(n_qubits: int) -> qutip.Qobj:

70 """Ground state of H_transverse: tensor product of |+> = (|0>+|1>)/sqrt(2)."""

71 plus = (qutip.basis(2, 0) + qutip.basis(2, 1)).unit()

72 return qutip.tensor([plus] * n_qubits)

73

74

75 def _sample_from_state(

76 final_state: qutip.Qobj,

77 variables: list[Any],

78 num_reads: int,

79 rng: np.random.Generator,

80) -> list[dict[Any, int]]:

81 """Draw bitstring samples from the final quantum state.

82

83 Basis index i -> binary string -> qubit 0 is MSB.

84 |0> (bit '0') -> sigma_z = +1 -> spin +1.

85 |1> (bit '1') -> sigma_z = -1 -> spin -1.

86 """

87 n = len(variables)

88 amplitudes = final_state.full().flatten()

89 probs = np.abs(amplitudes) ** 2

90 probs /= probs.sum()

91

92 indices = rng.choice(2**n, size=num_reads, p=probs)

93

94 return [

95 {var: (+1 if bits[i] == "0" else -1) for i, var in enumerate(variables)}

96 for idx in indices

97 for bits in [format(idx, f"0{n}b")]

98 ]

99

100

101 def _anneal(

102 h: dict[Any, float],

103 J: dict[tuple[Any, Any], float],

104 variables: list[Any],

105 T: float,

106 steps: int,

107) -> qutip.Qobj:

108 """Run the quantum annealing schedule and return the final state."""

109 n = len(variables)

110 H_ising = _build_ising_hamiltonian(h, J, variables)

111 H_transverse = _build_transverse_hamiltonian(n)

112 psi0 = _initial_state(n)

113 tlist = np.linspace(0.0, T, steps + 1)

114 H_td = [

115 [H_transverse, lambda t, _=None: 1.0 - t / T],

116 [H_ising, lambda t, _=None: t / T],

117 ]

118 return qutip.sesolve(H_td, psi0, tlist, e_ops=[]).states[-1]

119

120

121 class QuTipSampler(dimod.Sampler):

122 """dimod Sampler that simulates quantum annealing via QuTiP sesolve.

123

124 Takes a dimod.BinaryQuadraticModel (via sample/sample_ising/sample_qubo),

125 maps it to a transverse-field Ising model, evolves the state with a linear

126 annealing schedule using sesolve, and returns a dimod.SampleSet.

127

128 Parameters

129 ----------

130 anneal_time : float

131 Total annealing time T (default 10.0).

132 n_steps : int

133 Number of time steps for sesolve (default 200).

134

135 Example

136 -------

137 >>> import dimod

138 >>> from qutip_sampler import QuTipSampler

139 >>> bqm = dimod.BinaryQuadraticModel({'a': -1, 'b': -1}, {'ab': 0.5}, 0.0, 'SPIN')

140 >>> result = QuTipSampler().sample(bqm, num_reads=100, seed=42)

141 >>> print(result.first.sample)

142 """

143

144 def __init__(self, anneal_time: float = 10.0, n_steps: int = 200) -> None:

145 self._anneal_time = anneal_time

146 self._n_steps = n_steps

147 self._parameters: dict[str, list] = {

148 "num_reads": [],

149 "seed": [],

150 "anneal_time": [],

151 "n_steps": [],

152 }

153 self._properties: dict[str, Any] = {

154 "description": "Quantum annealing sampler backed by QuTiP sesolve",

155 "annealing_schedule": "linear",

156 }

157

158 @property

159 def parameters(self) -> dict[str, list]:

160 return self._parameters

161

162 @property

163 def properties(self) -> dict[str, Any]:

164 return self._properties

165

166 def _resolve(

167 self, anneal_time: float | None, n_steps: int | None

168 ) -> tuple[float, int]:

169 """Return effective (T, steps), falling back to instance defaults."""

170 return (

171 anneal_time if anneal_time is not None else self._anneal_time,

172 n_steps if n_steps is not None else self._n_steps,

173 )

174

175 def sample_ising(

176 self,

177 h: dict[Any, float],

178 J: dict[tuple[Any, Any], float],

179 *,

180 num_reads: int = 100,

181 seed: int | None = None,

182 anneal_time: float | None = None,

183 n_steps: int | None = None,

184 **kwargs,

185 ) -> dimod.SampleSet:

186 """Sample from an Ising problem via quantum annealing simulation.

187

188 Parameters

189 ----------

190 h : dict

191 Linear biases {variable: bias}.

192 J : dict

193 Quadratic couplings {(var_i, var_j): coupling}.

194 num_reads : int

195 Number of bitstring samples to draw (default 100).

196 seed : int or None

197 RNG seed for reproducible sampling.

198 anneal_time : float or None

199 Override the instance anneal_time.

200 n_steps : int or None

201 Override the instance n_steps.

202

203 Returns

204 -------

205 dimod.SampleSet

206 Samples with SPIN vartype and corresponding Ising energies.

207 """

208 all_vars: set[Any] = set(h.keys())

209 for vi, vj in J.keys():

210 all_vars.update([vi, vj])

211 variables = sorted(all_vars)

212

213 if not variables:

214 return dimod.SampleSet.from_samples([], dimod.SPIN, energy=[])

215

216 T, steps = self._resolve(anneal_time, n_steps)

217 final_state = _anneal(h, J, variables, T, steps)

218

219 rng = np.random.default_rng(seed)

220 raw_samples = _sample_from_state(final_state, variables, num_reads, rng)

221 energies = [dimod.ising_energy(s, h, J) for s in raw_samples]

222

223 return dimod.SampleSet.from_samples(raw_samples, vartype=dimod.SPIN, energy=energies)

224

225 def sample_qubo(

226 self,

227 Q: dict[tuple[Any, Any], float],

228 *,

229 num_reads: int = 100,

230 seed: int | None = None,

231 anneal_time: float | None = None,

232 n_steps: int | None = None,

233 **kwargs,

234 ) -> dimod.SampleSet:

235 """Sample from a QUBO problem via quantum annealing simulation.

236

237 Parameters

238 ----------

239 Q : dict

240 QUBO coefficients {(var_i, var_j): value}. Diagonal entries

241 (var, var) are linear biases; off-diagonal are quadratic.

242 num_reads : int

243 Number of bitstring samples to draw (default 100).

244 seed : int or None

245 RNG seed for reproducible sampling.

246 anneal_time : float or None

247 Override the instance anneal_time.

248 n_steps : int or None

249 Override the instance n_steps.

250

251 Returns

252 -------

253 dimod.SampleSet

254 Samples with BINARY vartype and corresponding QUBO energies.

255 """

256 bqm = dimod.BinaryQuadraticModel.from_qubo(Q)

257 return self.sample(bqm, num_reads=num_reads, seed=seed, anneal_time=anneal_time, n_steps=n_steps, **kwargs)

258

259 def sample(

260 self,

261 bqm: dimod.BinaryQuadraticModel,

262 *,

263 num_reads: int = 100,

264 seed: int | None = None,

265 anneal_time: float | None = None,

266 n_steps: int | None = None,

267 **kwargs,

268 ) -> dimod.SampleSet:

269 """Sample from a BinaryQuadraticModel via quantum annealing simulation.

270

271 Parameters

272 ----------

273 bqm : dimod.BinaryQuadraticModel

274 The problem to sample. Supports both SPIN and BINARY vartypes.

275 num_reads : int

276 Number of bitstring samples to draw (default 100).

277 seed : int or None

278 RNG seed for reproducible sampling.

279 anneal_time : float or None

280 Override the instance anneal_time.

281 n_steps : int or None

282 Override the instance n_steps.

283

284 Returns

285 -------

286 dimod.SampleSet

287 Samples with the same vartype as the input BQM.

288 """

289 h, J, offset = bqm.to_ising()

290 sampleset = self.sample_ising(

291 h, J,

292 num_reads=num_reads,

293 seed=seed,

294 anneal_time=anneal_time,

295 n_steps=n_steps,

296 **kwargs,

297 )

298 return sampleset.change_vartype(bqm.vartype, energy_offset=offset)