qmlkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. qmlkit/__init__.py +495 -0
  2. qmlkit/_aliases.py +135 -0
  3. qmlkit/algorithms/__init__.py +82 -0
  4. qmlkit/algorithms/adapt.py +297 -0
  5. qmlkit/algorithms/autoencoder.py +206 -0
  6. qmlkit/algorithms/chemistry.py +222 -0
  7. qmlkit/algorithms/clustering.py +149 -0
  8. qmlkit/algorithms/hamiltonians.py +143 -0
  9. qmlkit/algorithms/molecule.py +442 -0
  10. qmlkit/algorithms/qaoa.py +208 -0
  11. qmlkit/algorithms/rl.py +198 -0
  12. qmlkit/algorithms/vqe.py +198 -0
  13. qmlkit/ansatz/__init__.py +68 -0
  14. qmlkit/ansatz/blocks.py +348 -0
  15. qmlkit/ansatz/library.py +570 -0
  16. qmlkit/ansatz/reupload.py +168 -0
  17. qmlkit/baselines.py +604 -0
  18. qmlkit/budget.py +234 -0
  19. qmlkit/core/__init__.py +1 -0
  20. qmlkit/core/backends/__init__.py +22 -0
  21. qmlkit/core/backends/_sampling.py +43 -0
  22. qmlkit/core/backends/base.py +256 -0
  23. qmlkit/core/backends/cirq_backend.py +110 -0
  24. qmlkit/core/backends/cirq_density_backend.py +71 -0
  25. qmlkit/core/backends/noisy.py +86 -0
  26. qmlkit/core/backends/numpy_backend.py +276 -0
  27. qmlkit/core/backends/qiskit_aer_backend.py +79 -0
  28. qmlkit/core/backends/qiskit_backend.py +104 -0
  29. qmlkit/core/backends/registry.py +210 -0
  30. qmlkit/core/backends/spinqit_backend.py +233 -0
  31. qmlkit/core/backends/torch_backend.py +185 -0
  32. qmlkit/core/builder.py +189 -0
  33. qmlkit/core/execute.py +193 -0
  34. qmlkit/core/gates.py +243 -0
  35. qmlkit/core/ir.py +320 -0
  36. qmlkit/core/observables.py +269 -0
  37. qmlkit/datasets.py +178 -0
  38. qmlkit/diagnostics.py +719 -0
  39. qmlkit/draw.py +177 -0
  40. qmlkit/encoding/__init__.py +63 -0
  41. qmlkit/encoding/amplitude.py +178 -0
  42. qmlkit/encoding/angle.py +61 -0
  43. qmlkit/encoding/feature_maps.py +353 -0
  44. qmlkit/encoding/hamiltonian.py +206 -0
  45. qmlkit/encoding/pipeline.py +198 -0
  46. qmlkit/encoding/scaling.py +139 -0
  47. qmlkit/evaluate.py +686 -0
  48. qmlkit/fourier.py +124 -0
  49. qmlkit/generative.py +406 -0
  50. qmlkit/gradients/__init__.py +61 -0
  51. qmlkit/gradients/adjoint.py +138 -0
  52. qmlkit/gradients/batch.py +275 -0
  53. qmlkit/gradients/dispatch.py +247 -0
  54. qmlkit/gradients/hadamard.py +108 -0
  55. qmlkit/gradients/parameter_shift.py +142 -0
  56. qmlkit/gradients/rules.py +151 -0
  57. qmlkit/gradients/spsa.py +134 -0
  58. qmlkit/imbalance.py +335 -0
  59. qmlkit/info.py +153 -0
  60. qmlkit/interop.py +778 -0
  61. qmlkit/kernels/__init__.py +69 -0
  62. qmlkit/kernels/estimators.py +206 -0
  63. qmlkit/kernels/matrix.py +439 -0
  64. qmlkit/kernels/models.py +315 -0
  65. qmlkit/metrics.py +394 -0
  66. qmlkit/nn/__init__.py +18 -0
  67. qmlkit/nn/advanced.py +254 -0
  68. qmlkit/nn/layer.py +343 -0
  69. qmlkit/nn/losses.py +124 -0
  70. qmlkit/nn/models.py +245 -0
  71. qmlkit/optim.py +306 -0
  72. qmlkit/provenance.py +271 -0
  73. qmlkit/py.typed +0 -0
  74. qmlkit/search.py +561 -0
  75. qmlkit/shadows.py +117 -0
  76. qmlkit/utils/__init__.py +19 -0
  77. qmlkit/utils/errors.py +130 -0
  78. qmlkit/utils/shots.py +55 -0
  79. qmlkit-0.1.0.dist-info/METADATA +745 -0
  80. qmlkit-0.1.0.dist-info/RECORD +83 -0
  81. qmlkit-0.1.0.dist-info/WHEEL +4 -0
  82. qmlkit-0.1.0.dist-info/licenses/LICENSE +202 -0
  83. qmlkit-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,442 @@
1
+ r"""Molecular Hamiltonians for any molecule, by two routes.
2
+
3
+ **Route one — bring your own integrals.** This is the general one, and the one to
4
+ reach for past a couple of light atoms. Anything that can produce one- and
5
+ two-electron integrals in a molecular-orbital basis — PySCF, OpenFermion, Psi4 — hands
6
+ them to :func:`from_integrals` and gets a qubit Hamiltonian back::
7
+
8
+ from pyscf import gto, scf, ao2mo
9
+ import numpy as np
10
+
11
+ mol = gto.M(atom="Li 0 0 0; H 0 0 1.6", basis="sto-3g")
12
+ mf = scf.RHF(mol).run()
13
+ c = mf.mo_coeff
14
+ h1 = c.T @ mf.get_hcore() @ c
15
+ h2 = ao2mo.restore(1, ao2mo.kernel(mol, c), c.shape[1])
16
+
17
+ hamiltonian, info = from_integrals(h1, h2, n_electrons=mol.nelectron,
18
+ nuclear_repulsion=mol.energy_nuc())
19
+
20
+ That decoupling is deliberate. A quantum ML library should not also be a quantum
21
+ chemistry package, and pretending otherwise would mean shipping a worse version of
22
+ software that already exists.
23
+
24
+ **Route two — the built-in SCF.** For molecules built only from *s*-orbital atoms
25
+ (hydrogen and helium in STO-3G) the integrals are computed here, with a real
26
+ restricted Hartree–Fock loop rather than a symmetry shortcut. That covers the systems
27
+ VQE is usually benchmarked on — H\ :sub:`2`, H\ :sub:`3`\ :sup:`+`, H\ :sub:`4` chains
28
+ and rings, HeH\ :sup:`+` — at arbitrary geometry::
29
+
30
+ from qmlkit.algorithms import Molecule, molecular_hamiltonian
31
+
32
+ h4 = Molecule([("H", (0, 0, 0)), ("H", (0, 0, 0.9)),
33
+ ("H", (0, 0, 1.8)), ("H", (0, 0, 2.7))])
34
+ hamiltonian, info = molecular_hamiltonian(h4)
35
+
36
+ Anything with *p* orbitals needs route one. That boundary is stated rather than
37
+ papered over.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import itertools
43
+ import math
44
+ from dataclasses import dataclass, field
45
+ from typing import Any
46
+
47
+ import numpy as np
48
+ import numpy.typing as npt
49
+
50
+ from qmlkit.core.observables import PauliString, PauliSum
51
+
52
+ __all__ = [
53
+ "Molecule",
54
+ "MolecularInfo",
55
+ "molecular_hamiltonian",
56
+ "from_integrals",
57
+ "hydrogen_chain",
58
+ "hydrogen_ring",
59
+ "SUPPORTED_ELEMENTS",
60
+ "BOHR_PER_ANGSTROM",
61
+ ]
62
+
63
+ BOHR_PER_ANGSTROM = 1.0 / 0.529177210903
64
+
65
+ #: STO-3G 1s contractions. Only elements whose occupied shells are pure *s*, because
66
+ #: p-orbital integrals need a different (and much longer) set of recursions.
67
+ SUPPORTED_ELEMENTS: dict[str, dict[str, Any]] = {
68
+ "H": {"z": 1, "electrons": 1, "alpha": (3.42525091, 0.62391373, 0.16885540)},
69
+ "He": {"z": 2, "electrons": 2, "alpha": (6.36242139, 1.15892300, 0.31364979)},
70
+ }
71
+ _CONTRACTION = np.array([0.15432897, 0.53532814, 0.44463454])
72
+
73
+ _I2 = np.eye(2)
74
+ _Zmat = np.diag([1.0, -1.0])
75
+ _SIGMA_MINUS = np.array([[0.0, 1.0], [0.0, 0.0]])
76
+ _PAULI: dict[str, npt.NDArray[Any]] = {
77
+ "I": _I2,
78
+ "X": np.array([[0.0, 1.0], [1.0, 0.0]]),
79
+ "Y": np.array([[0.0, -1j], [1j, 0.0]]),
80
+ "Z": _Zmat,
81
+ }
82
+
83
+
84
+ @dataclass
85
+ class Molecule:
86
+ """Atoms and where they are. Positions in angstrom."""
87
+
88
+ atoms: list[tuple[str, tuple[float, float, float]]]
89
+ charge: int = 0
90
+
91
+ def __post_init__(self) -> None:
92
+ unknown = sorted({s for s, _ in self.atoms} - set(SUPPORTED_ELEMENTS))
93
+ if unknown:
94
+ raise ValueError(
95
+ f"the built-in SCF handles only s-orbital elements "
96
+ f"{sorted(SUPPORTED_ELEMENTS)}, not {unknown}. Compute the integrals "
97
+ "with PySCF or OpenFermion and pass them to from_integrals() instead."
98
+ )
99
+
100
+ @property
101
+ def n_electrons(self) -> int:
102
+ total = sum(SUPPORTED_ELEMENTS[s]["electrons"] for s, _ in self.atoms)
103
+ return int(total - self.charge)
104
+
105
+ @property
106
+ def n_orbitals(self) -> int:
107
+ """One 1s function per atom, in this basis."""
108
+ return len(self.atoms)
109
+
110
+ def __repr__(self) -> str:
111
+ formula = "".join(s for s, _ in self.atoms)
112
+ return f"Molecule({formula}, charge={self.charge}, {self.n_electrons} electrons)"
113
+
114
+
115
+ @dataclass
116
+ class MolecularInfo:
117
+ """Everything that went into the Hamiltonian, so the result can be audited."""
118
+
119
+ n_qubits: int
120
+ n_electrons: int
121
+ n_orbitals: int
122
+ n_terms: int
123
+ nuclear_repulsion: float
124
+ hartree_fock_energy: float | None = None
125
+ hartree_fock_occupation: list[int] = field(default_factory=list)
126
+ active_space: tuple[int, ...] | None = None
127
+
128
+ def __repr__(self) -> str:
129
+ return (
130
+ f"MolecularInfo(qubits={self.n_qubits}, electrons={self.n_electrons}, "
131
+ f"orbitals={self.n_orbitals}, terms={self.n_terms})"
132
+ )
133
+
134
+
135
+ def hydrogen_chain(n: int, spacing: float = 0.74) -> Molecule:
136
+ """``n`` hydrogens in a line — the standard scaling benchmark for VQE."""
137
+ return Molecule([("H", (0.0, 0.0, i * spacing)) for i in range(n)])
138
+
139
+
140
+ def hydrogen_ring(n: int, radius: float = 1.0) -> Molecule:
141
+ """``n`` hydrogens on a circle; frustrated, and harder than the chain."""
142
+ return Molecule(
143
+ [
144
+ ("H", (radius * np.cos(2 * np.pi * i / n), radius * np.sin(2 * np.pi * i / n), 0.0))
145
+ for i in range(n)
146
+ ]
147
+ )
148
+
149
+
150
+ # --------------------------------------------------------------------------- #
151
+ # integrals over contracted s-type Gaussians
152
+ # --------------------------------------------------------------------------- #
153
+ def _boys(t: float) -> float:
154
+ """Boys function F0, from the standard library rather than SciPy.
155
+
156
+ `math.erf` has been there since Python 3.2, and using it keeps qmlkit's only
157
+ runtime dependency NumPy -- which is a promise the README makes and which CI
158
+ checks by installing nothing else.
159
+ """
160
+ if t < 1e-12:
161
+ return 1.0
162
+ return float(np.sqrt(np.pi / (4 * t)) * math.erf(np.sqrt(t)))
163
+
164
+
165
+ def _basis(molecule: Molecule) -> list[tuple[npt.NDArray[Any], npt.NDArray[Any], npt.NDArray[Any]]]:
166
+ """``(centre, exponents, coefficients)`` per basis function."""
167
+ out = []
168
+ for symbol, position in molecule.atoms:
169
+ alpha = np.array(SUPPORTED_ELEMENTS[symbol]["alpha"])
170
+ coeff = _CONTRACTION * (2 * alpha / np.pi) ** 0.75
171
+ out.append((np.array(position) * BOHR_PER_ANGSTROM, alpha, coeff))
172
+ return out
173
+
174
+
175
+ def _ao_integrals(molecule: Molecule) -> tuple[npt.NDArray[Any], ...]:
176
+ basis = _basis(molecule)
177
+ n = len(basis)
178
+ charges = [
179
+ (SUPPORTED_ELEMENTS[s]["z"], np.array(p) * BOHR_PER_ANGSTROM) for s, p in molecule.atoms
180
+ ]
181
+
182
+ overlap = np.zeros((n, n))
183
+ core = np.zeros((n, n))
184
+ for i, j in itertools.product(range(n), repeat=2):
185
+ ci, ai, di = basis[i]
186
+ cj, aj, dj = basis[j]
187
+ d2 = float((ci - cj) @ (ci - cj))
188
+ for a, da in zip(ai, di, strict=True):
189
+ for b, db in zip(aj, dj, strict=True):
190
+ p = a + b
191
+ mu = a * b / p
192
+ gauss = np.exp(-mu * d2)
193
+ s = (np.pi / p) ** 1.5 * gauss
194
+ overlap[i, j] += da * db * s
195
+ core[i, j] += da * db * mu * (3 - 2 * mu * d2) * s # kinetic
196
+ centre = (a * ci + b * cj) / p
197
+ for z, nucleus in charges:
198
+ pc = centre - nucleus
199
+ core[i, j] -= da * db * 2 * np.pi / p * z * gauss * _boys(p * float(pc @ pc))
200
+
201
+ eri = np.zeros((n, n, n, n))
202
+ for i, j, k, m in itertools.product(range(n), repeat=4):
203
+ ci, ai, di = basis[i]
204
+ cj, aj, dj = basis[j]
205
+ ck, ak, dk = basis[k]
206
+ cm, am, dm = basis[m]
207
+ dij = float((ci - cj) @ (ci - cj))
208
+ dkm = float((ck - cm) @ (ck - cm))
209
+ total = 0.0
210
+ for a, da in zip(ai, di, strict=True):
211
+ for b, db in zip(aj, dj, strict=True):
212
+ p = a + b
213
+ cp = (a * ci + b * cj) / p
214
+ kab = np.exp(-a * b / p * dij)
215
+ for c, dc in zip(ak, dk, strict=True):
216
+ for d, dd in zip(am, dm, strict=True):
217
+ q = c + d
218
+ cq = (c * ck + d * cm) / q
219
+ kcd = np.exp(-c * d / q * dkm)
220
+ pq = cp - cq
221
+ total += (
222
+ da
223
+ * db
224
+ * dc
225
+ * dd
226
+ * 2
227
+ * np.pi**2.5
228
+ / (p * q * np.sqrt(p + q))
229
+ * kab
230
+ * kcd
231
+ * _boys(p * q / (p + q) * float(pq @ pq))
232
+ )
233
+ eri[i, j, k, m] = total
234
+
235
+ repulsion = 0.0
236
+ for (z1, r1), (z2, r2) in itertools.combinations(charges, 2):
237
+ repulsion += z1 * z2 / float(np.linalg.norm(r1 - r2))
238
+ return overlap, core, eri, np.array(repulsion)
239
+
240
+
241
+ def _rhf(
242
+ overlap: npt.NDArray[Any],
243
+ core: npt.NDArray[Any],
244
+ eri: npt.NDArray[Any],
245
+ n_electrons: int,
246
+ max_iterations: int = 100,
247
+ tol: float = 1e-10,
248
+ ) -> tuple[npt.NDArray[Any], float]:
249
+ """Restricted Hartree-Fock. Returns MO coefficients and the electronic energy.
250
+
251
+ Symmetric orthogonalisation, then the usual diagonalise-build-repeat loop. No
252
+ symmetry shortcut, so this works for any geometry rather than only symmetric ones.
253
+ """
254
+ if n_electrons % 2:
255
+ raise ValueError(f"restricted Hartree-Fock needs an even electron count, got {n_electrons}")
256
+ n_occupied = n_electrons // 2
257
+ values, vectors = np.linalg.eigh(overlap)
258
+ x = vectors @ np.diag(values**-0.5) @ vectors.T # S^{-1/2}
259
+
260
+ density = np.zeros_like(core)
261
+ energy = 0.0
262
+ for _ in range(max_iterations):
263
+ coulomb = np.einsum("ls,mnls->mn", density, eri, optimize=True)
264
+ exchange = np.einsum("ls,mlns->mn", density, eri, optimize=True)
265
+ fock = core + coulomb - 0.5 * exchange
266
+ _, c_prime = np.linalg.eigh(x.T @ fock @ x)
267
+ coefficients = x @ c_prime
268
+ occupied = coefficients[:, :n_occupied]
269
+ new_density = 2 * occupied @ occupied.T
270
+ new_energy = 0.5 * float(np.sum(new_density * (core + fock)))
271
+ if abs(new_energy - energy) < tol and np.abs(new_density - density).max() < tol:
272
+ density, energy = new_density, new_energy
273
+ break
274
+ density, energy = new_density, new_energy
275
+ return coefficients, energy
276
+
277
+
278
+ # --------------------------------------------------------------------------- #
279
+ # second quantisation and the Jordan-Wigner map
280
+ # --------------------------------------------------------------------------- #
281
+ def _kron(*matrices: npt.NDArray[Any]) -> npt.NDArray[Any]:
282
+ out = np.eye(1, dtype=complex)
283
+ for m in matrices:
284
+ out = np.kron(out, m)
285
+ return out
286
+
287
+
288
+ def _annihilator(orbital: int, n_spin_orbitals: int) -> npt.NDArray[Any]:
289
+ return _kron(*([_Zmat] * orbital + [_SIGMA_MINUS] + [_I2] * (n_spin_orbitals - orbital - 1)))
290
+
291
+
292
+ #: (m00, m01, m10, m11) -> (c_I, c_X, c_Y, c_Z), i.e. Tr(P M)/2 for each Pauli
293
+ _TO_PAULI = 0.5 * np.array(
294
+ [
295
+ [1, 0, 0, 1], # I
296
+ [0, 1, 1, 0], # X
297
+ [0, 1j, -1j, 0], # Y
298
+ [1, 0, 0, -1], # Z
299
+ ],
300
+ dtype=complex,
301
+ )
302
+
303
+
304
+ def _decompose(matrix: npt.NDArray[Any], n_qubits: int, tol: float) -> list[PauliString]:
305
+ r"""``c_P = Tr(P H) / 2^n`` for every Pauli string at once.
306
+
307
+ Done term by term this is ``Tr(P @ H)`` over ``4^n`` strings, each a
308
+ ``2^n x 2^n`` matrix product — ``O(16^n)``, which is about a minute for a
309
+ four-atom molecule and hopeless past that.
310
+
311
+ The same numbers come out of ``n`` applications of one 4x4 change of basis: pair
312
+ each row index with its column index, and rotate that pair from the
313
+ matrix-element basis into the Pauli basis. That is ``O(n * 4^n)``, and it takes
314
+ milliseconds where the direct form took minutes.
315
+ """
316
+ tensor = np.asarray(matrix, dtype=complex).reshape((2,) * (2 * n_qubits))
317
+ # interleave row/column indices so each qubit's (r, c) pair sits on one axis
318
+ order = [i for q in range(n_qubits) for i in (q, q + n_qubits)]
319
+ tensor = tensor.transpose(order).reshape((4,) * n_qubits)
320
+ for axis in range(n_qubits):
321
+ tensor = np.moveaxis(np.tensordot(_TO_PAULI, tensor, axes=([1], [axis])), 0, axis)
322
+
323
+ letters = "IXYZ"
324
+ terms: list[PauliString] = []
325
+ for index, value in enumerate(tensor.reshape(-1)):
326
+ coefficient = float(np.real(value))
327
+ if abs(coefficient) <= tol:
328
+ continue
329
+ digits = np.base_repr(index, base=4).rjust(n_qubits, "0")
330
+ terms.append(
331
+ PauliString(
332
+ tuple((q, letters[int(d)]) for q, d in enumerate(digits) if d != "0"), coefficient
333
+ )
334
+ )
335
+ return terms
336
+
337
+
338
+ def from_integrals(
339
+ one_body: npt.NDArray[Any],
340
+ two_body: npt.NDArray[Any],
341
+ n_electrons: int,
342
+ nuclear_repulsion: float = 0.0,
343
+ active_space: tuple[int, ...] | None = None,
344
+ tol: float = 1e-10,
345
+ ) -> tuple[PauliSum, MolecularInfo]:
346
+ r"""A qubit Hamiltonian from molecular-orbital integrals.
347
+
348
+ Parameters
349
+ ----------
350
+ one_body
351
+ ``h[p, q]``, the one-electron integrals in the MO basis.
352
+ two_body
353
+ ``g[p, q, r, s]`` in **chemist notation** ``(pq|rs)``, which is what PySCF's
354
+ ``ao2mo.restore(1, ...)`` returns.
355
+ active_space
356
+ Spatial orbitals to keep. Everything else is dropped, which is the usual way
357
+ to fit a molecule onto a machine you actually have — ``2 * len(active_space)``
358
+ qubits instead of ``2 * n_orbitals``.
359
+
360
+ Notes
361
+ -----
362
+ This is the general entry point: it never asks where the integrals came from.
363
+ """
364
+ h = np.asarray(one_body, dtype=float)
365
+ g = np.asarray(two_body, dtype=float)
366
+ if h.ndim != 2 or h.shape[0] != h.shape[1]:
367
+ raise ValueError(f"one_body must be square, got shape {h.shape}")
368
+ if g.shape != (h.shape[0],) * 4:
369
+ raise ValueError(f"two_body must have shape {(h.shape[0],) * 4}, got {g.shape}")
370
+
371
+ if active_space is not None:
372
+ keep = list(active_space)
373
+ h = h[np.ix_(keep, keep)]
374
+ g = g[np.ix_(keep, keep, keep, keep)]
375
+
376
+ n_spatial = h.shape[0]
377
+ n_spin = 2 * n_spatial
378
+ if n_spin > 12:
379
+ raise ValueError(
380
+ f"{n_spin} qubits means a {2**n_spin}-dimensional matrix. Use active_space "
381
+ "to pick the orbitals that matter."
382
+ )
383
+
384
+ a = [_annihilator(p, n_spin) for p in range(n_spin)]
385
+ adag = [x.conj().T for x in a]
386
+ spin = [p % 2 for p in range(n_spin)]
387
+ spatial = [p // 2 for p in range(n_spin)]
388
+
389
+ matrix = np.zeros((2**n_spin, 2**n_spin), dtype=complex)
390
+ for p, q in itertools.product(range(n_spin), repeat=2):
391
+ if spin[p] == spin[q]:
392
+ matrix += h[spatial[p], spatial[q]] * (adag[p] @ a[q])
393
+ for p, q, r, s in itertools.product(range(n_spin), repeat=4):
394
+ if spin[p] == spin[q] and spin[r] == spin[s]:
395
+ matrix += (
396
+ 0.5
397
+ * g[spatial[p], spatial[q], spatial[r], spatial[s]]
398
+ * (adag[p] @ adag[r] @ a[s] @ a[q])
399
+ )
400
+ matrix += float(nuclear_repulsion) * np.eye(2**n_spin)
401
+
402
+ terms = _decompose(matrix, n_spin, tol)
403
+ occupation = [1 if i < n_electrons else 0 for i in range(n_spin)]
404
+ info = MolecularInfo(
405
+ n_qubits=n_spin,
406
+ n_electrons=n_electrons,
407
+ n_orbitals=n_spatial,
408
+ n_terms=len(terms),
409
+ nuclear_repulsion=float(nuclear_repulsion),
410
+ hartree_fock_occupation=occupation,
411
+ active_space=active_space,
412
+ )
413
+ return PauliSum(tuple(terms)), info
414
+
415
+
416
+ def molecular_hamiltonian(
417
+ molecule: Molecule,
418
+ active_space: tuple[int, ...] | None = None,
419
+ tol: float = 1e-10,
420
+ ) -> tuple[PauliSum, MolecularInfo]:
421
+ """Compute the integrals here, then hand them to :func:`from_integrals`.
422
+
423
+ Restricted to s-orbital elements — see the module docstring for why, and for the
424
+ route to take when that is not enough.
425
+ """
426
+ overlap, core, eri, repulsion = _ao_integrals(molecule)
427
+ coefficients, electronic = _rhf(overlap, core, eri, molecule.n_electrons)
428
+ h = coefficients.T @ core @ coefficients
429
+ g = np.einsum(
430
+ "pi,qj,rk,sl,pqrs->ijkl",
431
+ coefficients,
432
+ coefficients,
433
+ coefficients,
434
+ coefficients,
435
+ eri,
436
+ optimize=True,
437
+ )
438
+ hamiltonian, info = from_integrals(
439
+ h, g, molecule.n_electrons, float(repulsion), active_space=active_space, tol=tol
440
+ )
441
+ info.hartree_fock_energy = float(electronic + repulsion)
442
+ return hamiltonian, info
@@ -0,0 +1,208 @@
1
+ """QAOA as a solver, not just an ansatz.
2
+
3
+ The ansatz has been in the zoo since Phase 3. What was missing is the part that
4
+ makes it an *algorithm*: turn a combinatorial problem into a cost Hamiltonian,
5
+ optimise the angles, then sample the state and read off a bitstring you can act on.
6
+
7
+ from qmlkit.algorithms import QAOA
8
+
9
+ edges = [(0, 1), (1, 2), (2, 3), (3, 0)]
10
+ result = QAOA(edges, p=2).run(seed=0)
11
+ print(result.bitstring, result.cut_value)
12
+
13
+ The cost Hamiltonian is an argument, so anything expressible as a Pauli sum — MaxCut,
14
+ Max-2-SAT, a weighted graph, a portfolio constraint — is the same call.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import warnings
20
+ from collections.abc import Sequence
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+ import numpy.typing as npt
26
+ from numpy.typing import ArrayLike
27
+
28
+ from qmlkit.algorithms.hamiltonians import exact_ground_energy, max_cut_hamiltonian
29
+ from qmlkit.algorithms.vqe import OPTIMIZERS, Optimizer
30
+ from qmlkit.ansatz.library import Ansatz, qaoa_ansatz
31
+ from qmlkit.core.execute import BackendLike, expectation, probabilities
32
+ from qmlkit.core.observables import Observable, observable_support
33
+ from qmlkit.optim import supports_rotosolve
34
+
35
+ __all__ = ["QAOA", "QAOAResult"]
36
+
37
+
38
+ @dataclass
39
+ class QAOAResult:
40
+ """The angles, and — more usefully — the bitstring they point at."""
41
+
42
+ energy: float
43
+ theta: npt.NDArray[Any]
44
+ bitstring: str
45
+ probability: float
46
+ history: list[float] = field(default_factory=list)
47
+ exact: float | None = None
48
+ top: list[tuple[str, float]] = field(default_factory=list)
49
+
50
+ @property
51
+ def cut_value(self) -> float:
52
+ """For a MaxCut cost, the number of edges the returned bitstring cuts."""
53
+ return -self.energy
54
+
55
+ @property
56
+ def approximation_ratio(self) -> float | None:
57
+ """Energy reached over the best possible, when the exact answer is known."""
58
+ if self.exact is None or self.exact == 0:
59
+ return None
60
+ return float(self.energy / self.exact)
61
+
62
+ def __repr__(self) -> str:
63
+ ratio = (
64
+ "" if self.approximation_ratio is None else f", ratio={self.approximation_ratio:.4f}"
65
+ )
66
+ return (
67
+ f"QAOAResult(bitstring={self.bitstring!r}, energy={self.energy:.6f}"
68
+ f", p={self.probability:.4f}{ratio})"
69
+ )
70
+
71
+
72
+ class QAOA:
73
+ """Optimise QAOA angles, then sample a solution out of the state.
74
+
75
+ Parameters
76
+ ----------
77
+ problem
78
+ Either an edge list (treated as MaxCut) or any cost ``Observable``.
79
+ p
80
+ Rounds. Two angles per round regardless of problem size — which is the
81
+ whole appeal, and also why more rounds is the only way to improve.
82
+ mixer, ansatz
83
+ The structure. ``mixer`` is passed to :func:`qaoa_ansatz`; pass ``ansatz``
84
+ directly to use a warm-started or otherwise non-standard construction.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ problem: Observable | Sequence[tuple[int, int]],
90
+ p: int = 1,
91
+ n_qubits: int | None = None,
92
+ mixer: str = "x",
93
+ ansatz: Ansatz | None = None,
94
+ optimizer: str | Optimizer = "gradient-descent",
95
+ backend: BackendLike = None,
96
+ shots: int | None = None,
97
+ ) -> None:
98
+ if isinstance(problem, (list, tuple)) and not problem:
99
+ raise ValueError(
100
+ "QAOA needs a problem: an edge list for MaxCut, or a cost observable. "
101
+ "An empty edge list defines nothing to optimise."
102
+ )
103
+ if isinstance(problem, (list, tuple)) and isinstance(problem[0], tuple):
104
+ edges = [(int(a), int(b)) for a, b in problem]
105
+ width = n_qubits or max(max(e) for e in edges) + 1
106
+ self.cost: Observable = max_cut_hamiltonian(edges)
107
+ self.edges: list[tuple[int, int]] | None = edges
108
+ else:
109
+ self.cost = problem # type: ignore[assignment]
110
+ support = observable_support(self.cost)
111
+ width = n_qubits or (max(support) + 1 if support else 1)
112
+ self.edges = None
113
+
114
+ self.n_qubits = width
115
+ self.p = p
116
+ self.ansatz = ansatz or qaoa_ansatz(width, edges=self.edges, p=p, mixer=mixer)
117
+ self.optimizer = optimizer
118
+ self.backend = backend
119
+ self.shots = shots
120
+ self._spec = self.ansatz.build()
121
+ self.n_evaluations = 0
122
+
123
+ def energy(self, theta: Sequence[float]) -> float:
124
+ self.n_evaluations += 1
125
+ return float(
126
+ expectation(
127
+ self._spec,
128
+ self.cost,
129
+ theta=np.asarray(theta, dtype=float),
130
+ shots=self.shots,
131
+ backend=self.backend,
132
+ )
133
+ )
134
+
135
+ def gradient_of_energy(self, theta: Sequence[float]) -> npt.NDArray[Any]:
136
+ from qmlkit.gradients.dispatch import grad
137
+
138
+ return grad(
139
+ self._spec,
140
+ np.asarray(theta, dtype=float),
141
+ self.cost,
142
+ backend=self.backend,
143
+ shots=self.shots,
144
+ )
145
+
146
+ def distribution(self, theta: ArrayLike) -> npt.NDArray[Any]:
147
+ """Outcome probabilities of the optimised state."""
148
+ return probabilities(self._spec.bind(np.asarray(theta, dtype=float)), backend=self.backend)
149
+
150
+ def run(
151
+ self,
152
+ theta0: Sequence[float] | None = None,
153
+ seed: int | None = None,
154
+ n_top: int = 5,
155
+ compare_exact: bool | None = None,
156
+ **optimizer_kwargs: Any,
157
+ ) -> QAOAResult:
158
+ start = (
159
+ np.asarray(theta0, dtype=float)
160
+ if theta0 is not None
161
+ else self.ansatz.init("uniform", seed=seed)
162
+ )
163
+ fn = OPTIMIZERS[self.optimizer] if isinstance(self.optimizer, str) else self.optimizer
164
+ if fn is OPTIMIZERS["spsa"]:
165
+ optimizer_kwargs.setdefault("seed", seed)
166
+ if fn is OPTIMIZERS["gradient-descent"]:
167
+ optimizer_kwargs.setdefault("grad", self.gradient_of_energy)
168
+ if fn is OPTIMIZERS["rotosolve"] and not supports_rotosolve(self._spec):
169
+ warnings.warn(
170
+ "Rotosolve assumes each angle drives a single Pauli rotation, but this "
171
+ "circuit shares an angle across gates that do not compose - QAOA's cost "
172
+ "angle drives one rz per edge. Rotosolve will converge immediately on "
173
+ "the wrong point. Use 'gradient-descent' or 'spsa'.",
174
+ UserWarning,
175
+ stacklevel=2,
176
+ )
177
+ self.n_evaluations = 0
178
+ theta, history = fn(self.energy, start, **optimizer_kwargs)
179
+
180
+ # The angles are a means; the bitstring is the answer. Read the most likely
181
+ # outcomes off the optimised state rather than reporting only an energy.
182
+ probs = self.distribution(theta)
183
+ order = np.argsort(probs)[::-1]
184
+ top = [(format(int(i), f"0{self.n_qubits}b"), float(probs[i])) for i in order[:n_top]]
185
+ best = top[0]
186
+
187
+ if compare_exact is None:
188
+ compare_exact = self.n_qubits <= 12
189
+ exact = exact_ground_energy(self.cost, self.n_qubits) if compare_exact else None
190
+
191
+ return QAOAResult(
192
+ energy=float(history[-1]),
193
+ theta=theta,
194
+ bitstring=best[0],
195
+ probability=best[1],
196
+ history=list(history),
197
+ exact=exact,
198
+ top=top,
199
+ )
200
+
201
+ def cut_size(self, bitstring: str) -> int:
202
+ """Edges cut by an assignment — the classical objective, computed classically."""
203
+ if self.edges is None:
204
+ raise ValueError("cut_size only applies when the problem was given as edges")
205
+ return sum(1 for a, b in self.edges if bitstring[a] != bitstring[b])
206
+
207
+ def __repr__(self) -> str:
208
+ return f"QAOA(n_qubits={self.n_qubits}, p={self.p}, P={self.ansatz.n_weights})"