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.
- qmlkit/__init__.py +495 -0
- qmlkit/_aliases.py +135 -0
- qmlkit/algorithms/__init__.py +82 -0
- qmlkit/algorithms/adapt.py +297 -0
- qmlkit/algorithms/autoencoder.py +206 -0
- qmlkit/algorithms/chemistry.py +222 -0
- qmlkit/algorithms/clustering.py +149 -0
- qmlkit/algorithms/hamiltonians.py +143 -0
- qmlkit/algorithms/molecule.py +442 -0
- qmlkit/algorithms/qaoa.py +208 -0
- qmlkit/algorithms/rl.py +198 -0
- qmlkit/algorithms/vqe.py +198 -0
- qmlkit/ansatz/__init__.py +68 -0
- qmlkit/ansatz/blocks.py +348 -0
- qmlkit/ansatz/library.py +570 -0
- qmlkit/ansatz/reupload.py +168 -0
- qmlkit/baselines.py +604 -0
- qmlkit/budget.py +234 -0
- qmlkit/core/__init__.py +1 -0
- qmlkit/core/backends/__init__.py +22 -0
- qmlkit/core/backends/_sampling.py +43 -0
- qmlkit/core/backends/base.py +256 -0
- qmlkit/core/backends/cirq_backend.py +110 -0
- qmlkit/core/backends/cirq_density_backend.py +71 -0
- qmlkit/core/backends/noisy.py +86 -0
- qmlkit/core/backends/numpy_backend.py +276 -0
- qmlkit/core/backends/qiskit_aer_backend.py +79 -0
- qmlkit/core/backends/qiskit_backend.py +104 -0
- qmlkit/core/backends/registry.py +210 -0
- qmlkit/core/backends/spinqit_backend.py +233 -0
- qmlkit/core/backends/torch_backend.py +185 -0
- qmlkit/core/builder.py +189 -0
- qmlkit/core/execute.py +193 -0
- qmlkit/core/gates.py +243 -0
- qmlkit/core/ir.py +320 -0
- qmlkit/core/observables.py +269 -0
- qmlkit/datasets.py +178 -0
- qmlkit/diagnostics.py +719 -0
- qmlkit/draw.py +177 -0
- qmlkit/encoding/__init__.py +63 -0
- qmlkit/encoding/amplitude.py +178 -0
- qmlkit/encoding/angle.py +61 -0
- qmlkit/encoding/feature_maps.py +353 -0
- qmlkit/encoding/hamiltonian.py +206 -0
- qmlkit/encoding/pipeline.py +198 -0
- qmlkit/encoding/scaling.py +139 -0
- qmlkit/evaluate.py +686 -0
- qmlkit/fourier.py +124 -0
- qmlkit/generative.py +406 -0
- qmlkit/gradients/__init__.py +61 -0
- qmlkit/gradients/adjoint.py +138 -0
- qmlkit/gradients/batch.py +275 -0
- qmlkit/gradients/dispatch.py +247 -0
- qmlkit/gradients/hadamard.py +108 -0
- qmlkit/gradients/parameter_shift.py +142 -0
- qmlkit/gradients/rules.py +151 -0
- qmlkit/gradients/spsa.py +134 -0
- qmlkit/imbalance.py +335 -0
- qmlkit/info.py +153 -0
- qmlkit/interop.py +778 -0
- qmlkit/kernels/__init__.py +69 -0
- qmlkit/kernels/estimators.py +206 -0
- qmlkit/kernels/matrix.py +439 -0
- qmlkit/kernels/models.py +315 -0
- qmlkit/metrics.py +394 -0
- qmlkit/nn/__init__.py +18 -0
- qmlkit/nn/advanced.py +254 -0
- qmlkit/nn/layer.py +343 -0
- qmlkit/nn/losses.py +124 -0
- qmlkit/nn/models.py +245 -0
- qmlkit/optim.py +306 -0
- qmlkit/provenance.py +271 -0
- qmlkit/py.typed +0 -0
- qmlkit/search.py +561 -0
- qmlkit/shadows.py +117 -0
- qmlkit/utils/__init__.py +19 -0
- qmlkit/utils/errors.py +130 -0
- qmlkit/utils/shots.py +55 -0
- qmlkit-0.1.0.dist-info/METADATA +745 -0
- qmlkit-0.1.0.dist-info/RECORD +83 -0
- qmlkit-0.1.0.dist-info/WHEEL +4 -0
- qmlkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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})"
|