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,222 @@
|
|
|
1
|
+
r"""Molecular Hamiltonians, computed here rather than quoted.
|
|
2
|
+
|
|
3
|
+
VQE's canonical demonstration is the ground-state energy of H\ :sub:`2`, and most
|
|
4
|
+
tutorials get the Hamiltonian by importing coefficients from a chemistry package or
|
|
5
|
+
copying a table out of a paper. This module computes it: STO-3G integrals over
|
|
6
|
+
Gaussian primitives, symmetry-adapted molecular orbitals, second quantisation, and a
|
|
7
|
+
Jordan–Wigner map to four qubits.
|
|
8
|
+
|
|
9
|
+
That matters for a library whose whole argument is that you should be able to see the
|
|
10
|
+
cost of what you run. It is also checkable — the curve below reproduces the published
|
|
11
|
+
FCI/STO-3G result to five decimals:
|
|
12
|
+
|
|
13
|
+
>>> from qmlkit.algorithms.chemistry import h2_hamiltonian
|
|
14
|
+
>>> from qmlkit.algorithms import exact_ground_energy
|
|
15
|
+
>>> h, info = h2_hamiltonian(0.735)
|
|
16
|
+
>>> round(exact_ground_energy(h, 4), 5)
|
|
17
|
+
-1.13731
|
|
18
|
+
|
|
19
|
+
Minimal basis only, and two centres only. Anything larger wants PySCF or
|
|
20
|
+
OpenFermion, and the point here is transparency rather than coverage.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import itertools
|
|
26
|
+
import math
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
import numpy.typing as npt
|
|
31
|
+
|
|
32
|
+
from qmlkit.core.observables import PauliString, PauliSum
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"h2_hamiltonian",
|
|
36
|
+
"h2_curve",
|
|
37
|
+
"BOHR_PER_ANGSTROM",
|
|
38
|
+
"HARTREE_TO_KCAL",
|
|
39
|
+
"CHEMICAL_ACCURACY",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
#: STO-3G contraction for the hydrogen 1s orbital
|
|
43
|
+
_ALPHA = np.array([3.42525091, 0.62391373, 0.16885540])
|
|
44
|
+
_COEFF = np.array([0.15432897, 0.53532814, 0.44463454])
|
|
45
|
+
_D = _COEFF * (2 * _ALPHA / np.pi) ** 0.75 # fold in the primitive normalisation
|
|
46
|
+
|
|
47
|
+
BOHR_PER_ANGSTROM = 1.0 / 0.529177210903
|
|
48
|
+
HARTREE_TO_KCAL = 627.509474
|
|
49
|
+
#: 1 kcal/mol in hartree — the accuracy chemistry actually cares about
|
|
50
|
+
CHEMICAL_ACCURACY = 1.0 / HARTREE_TO_KCAL
|
|
51
|
+
|
|
52
|
+
_I2 = np.eye(2)
|
|
53
|
+
_Z = np.diag([1.0, -1.0])
|
|
54
|
+
_SIGMA_MINUS = np.array([[0.0, 1.0], [0.0, 0.0]])
|
|
55
|
+
_PAULI: dict[str, npt.NDArray[Any]] = {
|
|
56
|
+
"I": _I2,
|
|
57
|
+
"X": np.array([[0.0, 1.0], [1.0, 0.0]]),
|
|
58
|
+
"Y": np.array([[0.0, -1j], [1j, 0.0]]),
|
|
59
|
+
"Z": _Z,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _boys(t: float) -> float:
|
|
64
|
+
"""Boys function :math:`F_0`, which is all s-type integrals need.
|
|
65
|
+
|
|
66
|
+
`math.erf` rather than SciPy's: qmlkit depends on NumPy alone, and reaching for
|
|
67
|
+
SciPy here would have added a runtime dependency for one scalar special function.
|
|
68
|
+
"""
|
|
69
|
+
value = float(t)
|
|
70
|
+
if value < 1e-12:
|
|
71
|
+
return 1.0
|
|
72
|
+
return float(np.sqrt(np.pi / (4 * value)) * math.erf(np.sqrt(value)))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _kron(*matrices: npt.NDArray[Any]) -> npt.NDArray[Any]:
|
|
76
|
+
out = np.eye(1, dtype=complex)
|
|
77
|
+
for m in matrices:
|
|
78
|
+
out = np.kron(out, m)
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ao_integrals(r_bohr: float) -> tuple[npt.NDArray[Any], ...]:
|
|
83
|
+
"""Overlap, kinetic, nuclear attraction and two-electron integrals."""
|
|
84
|
+
centres = [np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, r_bohr])]
|
|
85
|
+
n = 2
|
|
86
|
+
overlap = np.zeros((n, n))
|
|
87
|
+
kinetic = np.zeros((n, n))
|
|
88
|
+
nuclear = np.zeros((n, n))
|
|
89
|
+
for i, j in itertools.product(range(n), repeat=2):
|
|
90
|
+
diff = centres[i] - centres[j]
|
|
91
|
+
d2 = float(diff @ diff)
|
|
92
|
+
for a, da in zip(_ALPHA, _D, strict=True):
|
|
93
|
+
for b, db in zip(_ALPHA, _D, strict=True):
|
|
94
|
+
p = a + b
|
|
95
|
+
mu = a * b / p
|
|
96
|
+
gaussian = np.exp(-mu * d2)
|
|
97
|
+
s = (np.pi / p) ** 1.5 * gaussian
|
|
98
|
+
overlap[i, j] += da * db * s
|
|
99
|
+
kinetic[i, j] += da * db * mu * (3 - 2 * mu * d2) * s
|
|
100
|
+
centre = (a * centres[i] + b * centres[j]) / p
|
|
101
|
+
for nucleus in centres: # both hydrogens carry Z = 1
|
|
102
|
+
pc = centre - nucleus
|
|
103
|
+
nuclear[i, j] -= (
|
|
104
|
+
da * db * 2 * np.pi / p * gaussian * float(_boys(p * float(pc @ pc)))
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
eri = np.zeros((n, n, n, n))
|
|
108
|
+
for i, j, k, m in itertools.product(range(n), repeat=4):
|
|
109
|
+
total = 0.0
|
|
110
|
+
dij = centres[i] - centres[j]
|
|
111
|
+
dkm = centres[k] - centres[m]
|
|
112
|
+
for a, da in zip(_ALPHA, _D, strict=True):
|
|
113
|
+
for b, db in zip(_ALPHA, _D, strict=True):
|
|
114
|
+
p = a + b
|
|
115
|
+
centre_p = (a * centres[i] + b * centres[j]) / p
|
|
116
|
+
k_ab = np.exp(-a * b / p * float(dij @ dij))
|
|
117
|
+
for c, dc in zip(_ALPHA, _D, strict=True):
|
|
118
|
+
for d, dd in zip(_ALPHA, _D, strict=True):
|
|
119
|
+
q = c + d
|
|
120
|
+
centre_q = (c * centres[k] + d * centres[m]) / q
|
|
121
|
+
k_cd = np.exp(-c * d / q * float(dkm @ dkm))
|
|
122
|
+
pq = centre_p - centre_q
|
|
123
|
+
total += (
|
|
124
|
+
da
|
|
125
|
+
* db
|
|
126
|
+
* dc
|
|
127
|
+
* dd
|
|
128
|
+
* 2
|
|
129
|
+
* np.pi**2.5
|
|
130
|
+
/ (p * q * np.sqrt(p + q))
|
|
131
|
+
* k_ab
|
|
132
|
+
* k_cd
|
|
133
|
+
* float(_boys(p * q / (p + q) * float(pq @ pq)))
|
|
134
|
+
)
|
|
135
|
+
eri[i, j, k, m] = total
|
|
136
|
+
return overlap, kinetic, nuclear, eri
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _annihilator(orbital: int, n_spin_orbitals: int) -> npt.NDArray[Any]:
|
|
140
|
+
"""Jordan-Wigner: a Z string for the fermionic sign, then a lowering operator."""
|
|
141
|
+
factors = [_Z] * orbital + [_SIGMA_MINUS] + [_I2] * (n_spin_orbitals - orbital - 1)
|
|
142
|
+
return _kron(*factors)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _matrix(bond_length_angstrom: float) -> tuple[npt.NDArray[Any], float]:
|
|
146
|
+
"""The 16x16 Hamiltonian in the occupation-number basis, plus nuclear repulsion."""
|
|
147
|
+
r = bond_length_angstrom * BOHR_PER_ANGSTROM
|
|
148
|
+
overlap, kinetic, nuclear, eri = _ao_integrals(r)
|
|
149
|
+
core = kinetic + nuclear
|
|
150
|
+
|
|
151
|
+
# For a symmetric two-centre minimal basis the molecular orbitals are fixed by
|
|
152
|
+
# symmetry, so no SCF iteration is needed: sigma_g and sigma_u, normalised.
|
|
153
|
+
s = overlap[0, 1]
|
|
154
|
+
coefficients = np.array(
|
|
155
|
+
[
|
|
156
|
+
[1 / np.sqrt(2 * (1 + s)), 1 / np.sqrt(2 * (1 - s))],
|
|
157
|
+
[1 / np.sqrt(2 * (1 + s)), -1 / np.sqrt(2 * (1 - s))],
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
h = coefficients.T @ core @ coefficients
|
|
161
|
+
g = np.einsum(
|
|
162
|
+
"pi,qj,rk,sl,pqrs->ijkl",
|
|
163
|
+
coefficients,
|
|
164
|
+
coefficients,
|
|
165
|
+
coefficients,
|
|
166
|
+
coefficients,
|
|
167
|
+
eri,
|
|
168
|
+
optimize=True,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
n = 4 # two spatial orbitals, two spins
|
|
172
|
+
a = [_annihilator(p, n) for p in range(n)]
|
|
173
|
+
adag = [x.conj().T for x in a]
|
|
174
|
+
spin = (0, 1, 0, 1)
|
|
175
|
+
spatial = (0, 0, 1, 1)
|
|
176
|
+
|
|
177
|
+
matrix = np.zeros((2**n, 2**n), dtype=complex)
|
|
178
|
+
for p, q in itertools.product(range(n), repeat=2):
|
|
179
|
+
if spin[p] == spin[q]:
|
|
180
|
+
matrix += h[spatial[p], spatial[q]] * (adag[p] @ a[q])
|
|
181
|
+
for p, q, r_, s_ in itertools.product(range(n), repeat=4):
|
|
182
|
+
if spin[p] == spin[q] and spin[r_] == spin[s_]:
|
|
183
|
+
matrix += (
|
|
184
|
+
0.5
|
|
185
|
+
* g[spatial[p], spatial[q], spatial[r_], spatial[s_]]
|
|
186
|
+
* (adag[p] @ adag[r_] @ a[s_] @ a[q])
|
|
187
|
+
)
|
|
188
|
+
repulsion = 1.0 / r
|
|
189
|
+
return matrix + repulsion * np.eye(2**n), repulsion
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def h2_hamiltonian(
|
|
193
|
+
bond_length: float = 0.735, tol: float = 1e-10
|
|
194
|
+
) -> tuple[PauliSum, dict[str, Any]]:
|
|
195
|
+
"""The H2 qubit Hamiltonian at a given bond length in angstrom.
|
|
196
|
+
|
|
197
|
+
Returns the observable and a dictionary of what went into it. The Pauli
|
|
198
|
+
coefficients come from projecting the dense matrix, ``c_P = Tr(P H) / 2^n``,
|
|
199
|
+
which needs no symbolic algebra and is trivially checkable in the other
|
|
200
|
+
direction with :func:`~qmlkit.algorithms.hamiltonian_matrix`.
|
|
201
|
+
"""
|
|
202
|
+
matrix, repulsion = _matrix(bond_length)
|
|
203
|
+
terms: list[PauliString] = []
|
|
204
|
+
for letters in itertools.product("IXYZ", repeat=4):
|
|
205
|
+
operator = _kron(*[_PAULI[c] for c in letters])
|
|
206
|
+
coefficient = float(np.real(np.trace(operator @ matrix)) / 16)
|
|
207
|
+
if abs(coefficient) > tol:
|
|
208
|
+
paulis = tuple((q, c) for q, c in enumerate(letters) if c != "I")
|
|
209
|
+
terms.append(PauliString(paulis, coefficient))
|
|
210
|
+
info = {
|
|
211
|
+
"bond_length": bond_length,
|
|
212
|
+
"n_qubits": 4,
|
|
213
|
+
"n_terms": len(terms),
|
|
214
|
+
"nuclear_repulsion": repulsion,
|
|
215
|
+
"hartree_fock_occupation": [1, 1, 0, 0], # both electrons in sigma_g
|
|
216
|
+
}
|
|
217
|
+
return PauliSum(tuple(terms)), info
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def h2_curve(bond_lengths: npt.NDArray[Any] | list[float]) -> list[tuple[float, PauliSum]]:
|
|
221
|
+
"""``(bond_length, hamiltonian)`` pairs — the dissociation curve as input data."""
|
|
222
|
+
return [(float(r), h2_hamiltonian(float(r))[0]) for r in bond_lengths]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""q-means — Lloyd's algorithm with a quantum distance.
|
|
2
|
+
|
|
3
|
+
The unsupervised gap. k-means is entirely defined by one operation, "how far apart
|
|
4
|
+
are these two points", so replacing that with a quantum kernel distance is the whole
|
|
5
|
+
algorithm:
|
|
6
|
+
|
|
7
|
+
.. math:: d(x, x')^2 = 2\\bigl(1 - k(x, x')\\bigr)
|
|
8
|
+
|
|
9
|
+
for a normalised kernel. Everything else — assign, recentre, repeat — is Lloyd's, and
|
|
10
|
+
is deliberately unchanged so that any difference in the result is attributable to the
|
|
11
|
+
distance and nothing else.
|
|
12
|
+
|
|
13
|
+
The feature map is the argument, exactly as in :class:`~qmlkit.QSVC`: a clustering
|
|
14
|
+
method built on a kernel *is* its embedding.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import numpy.typing as npt
|
|
24
|
+
|
|
25
|
+
from qmlkit.core.execute import BackendLike
|
|
26
|
+
from qmlkit.encoding.feature_maps import FeatureMap
|
|
27
|
+
from qmlkit.kernels.matrix import QuantumKernel
|
|
28
|
+
|
|
29
|
+
__all__ = ["QMeans", "QMeansResult"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class QMeansResult:
|
|
34
|
+
labels: npt.NDArray[Any]
|
|
35
|
+
centroids: npt.NDArray[Any]
|
|
36
|
+
inertia: float
|
|
37
|
+
n_iterations: int
|
|
38
|
+
history: list[float] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str:
|
|
41
|
+
return (
|
|
42
|
+
f"QMeansResult(k={len(self.centroids)}, inertia={self.inertia:.6f}, "
|
|
43
|
+
f"iterations={self.n_iterations})"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class QMeans:
|
|
48
|
+
"""k-means where the distance comes from a quantum kernel.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
n_clusters
|
|
53
|
+
``k``.
|
|
54
|
+
feature_map
|
|
55
|
+
The embedding the distance is measured in. This is the only quantum part,
|
|
56
|
+
and swapping it is the entire experiment.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
n_clusters: int = 2,
|
|
62
|
+
feature_map: FeatureMap | None = None,
|
|
63
|
+
max_iterations: int = 50,
|
|
64
|
+
tol: float = 1e-6,
|
|
65
|
+
shots: int | None = None,
|
|
66
|
+
backend: BackendLike = None,
|
|
67
|
+
seed: int | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
if n_clusters < 1:
|
|
70
|
+
raise ValueError("n_clusters must be at least 1")
|
|
71
|
+
self.n_clusters = n_clusters
|
|
72
|
+
self.feature_map = feature_map
|
|
73
|
+
self.max_iterations = max_iterations
|
|
74
|
+
self.tol = tol
|
|
75
|
+
self.shots = shots
|
|
76
|
+
self.backend = backend
|
|
77
|
+
self.seed = seed
|
|
78
|
+
self.centroids_: npt.NDArray[Any] | None = None
|
|
79
|
+
self.labels_: npt.NDArray[Any] | None = None
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------- distance --
|
|
82
|
+
def _kernel(self, n_features: int) -> QuantumKernel:
|
|
83
|
+
from qmlkit.encoding.feature_maps import AngleFeatureMap
|
|
84
|
+
|
|
85
|
+
fmap = self.feature_map or AngleFeatureMap(n_features, entangle=n_features > 1)
|
|
86
|
+
return QuantumKernel(fmap, shots=self.shots, backend=self.backend, seed=self.seed)
|
|
87
|
+
|
|
88
|
+
def distances(self, X: npt.NDArray[Any], centroids: npt.NDArray[Any]) -> npt.NDArray[Any]:
|
|
89
|
+
r"""``(n_samples, k)`` of :math:`2(1 - k(x, c))`.
|
|
90
|
+
|
|
91
|
+
A kernel with unit diagonal induces a genuine squared distance this way, so
|
|
92
|
+
the assignment step below is the ordinary one — no special-casing.
|
|
93
|
+
"""
|
|
94
|
+
kernel = self._kernel(X.shape[1])
|
|
95
|
+
gram = kernel(np.asarray(X, dtype=float), np.asarray(centroids, dtype=float))
|
|
96
|
+
return 2.0 * (1.0 - gram)
|
|
97
|
+
|
|
98
|
+
# -------------------------------------------------------------------- fit --
|
|
99
|
+
def fit(self, X: npt.NDArray[Any], seed: int | None = None) -> QMeansResult:
|
|
100
|
+
data = np.asarray(X, dtype=float)
|
|
101
|
+
if len(data) < self.n_clusters:
|
|
102
|
+
raise ValueError(f"cannot form {self.n_clusters} clusters from {len(data)} samples")
|
|
103
|
+
rng = np.random.default_rng(self.seed if seed is None else seed)
|
|
104
|
+
# k-means++ style start: distinct rows, so two centroids cannot collide
|
|
105
|
+
chosen = rng.choice(len(data), size=self.n_clusters, replace=False)
|
|
106
|
+
centroids = data[chosen].copy()
|
|
107
|
+
|
|
108
|
+
history: list[float] = []
|
|
109
|
+
labels = np.zeros(len(data), dtype=int)
|
|
110
|
+
iteration = 0
|
|
111
|
+
for iteration in range(self.max_iterations): # noqa: B007 - used after the loop
|
|
112
|
+
d = self.distances(data, centroids)
|
|
113
|
+
labels = np.argmin(d, axis=1)
|
|
114
|
+
inertia = float(d[np.arange(len(data)), labels].sum())
|
|
115
|
+
history.append(inertia)
|
|
116
|
+
|
|
117
|
+
moved = centroids.copy()
|
|
118
|
+
for k in range(self.n_clusters):
|
|
119
|
+
members = data[labels == k]
|
|
120
|
+
if len(members):
|
|
121
|
+
moved[k] = members.mean(axis=0)
|
|
122
|
+
# An empty cluster keeps its centroid rather than drifting or
|
|
123
|
+
# collapsing onto another; duplicated points make that reachable.
|
|
124
|
+
shift = float(np.abs(moved - centroids).max())
|
|
125
|
+
centroids = moved
|
|
126
|
+
if shift < self.tol:
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
self.centroids_ = centroids
|
|
130
|
+
self.labels_ = labels
|
|
131
|
+
return QMeansResult(
|
|
132
|
+
labels=labels,
|
|
133
|
+
centroids=centroids,
|
|
134
|
+
inertia=history[-1],
|
|
135
|
+
n_iterations=iteration + 1,
|
|
136
|
+
history=history,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def predict(self, X: npt.NDArray[Any]) -> npt.NDArray[Any]:
|
|
140
|
+
if self.centroids_ is None:
|
|
141
|
+
raise ValueError("QMeans must be fitted before predicting")
|
|
142
|
+
return np.argmin(self.distances(np.asarray(X, dtype=float), self.centroids_), axis=1)
|
|
143
|
+
|
|
144
|
+
def fit_predict(self, X: npt.NDArray[Any], seed: int | None = None) -> npt.NDArray[Any]:
|
|
145
|
+
return self.fit(X, seed=seed).labels
|
|
146
|
+
|
|
147
|
+
def __repr__(self) -> str:
|
|
148
|
+
name = type(self.feature_map).__name__ if self.feature_map else "AngleFeatureMap"
|
|
149
|
+
return f"QMeans(n_clusters={self.n_clusters}, feature_map={name})"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
r"""Hamiltonians to hand to VQE, and an exact answer to check it against.
|
|
2
|
+
|
|
3
|
+
A Hamiltonian here is just a :class:`~qmlkit.core.observables.PauliSum` — the same
|
|
4
|
+
type an expectation value takes — so nothing new has to learn about it. These are
|
|
5
|
+
constructors, not a new class hierarchy.
|
|
6
|
+
|
|
7
|
+
:func:`exact_ground_energy` diagonalises the dense matrix. That is exponential and
|
|
8
|
+
useless past ~14 qubits, which is exactly the point: it is the oracle a variational
|
|
9
|
+
result gets *checked* against on small systems, not a method to compete with.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterable, Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import numpy.typing as npt
|
|
19
|
+
|
|
20
|
+
from qmlkit.core.builder import entangler_pairs
|
|
21
|
+
from qmlkit.core.observables import Observable, PauliString, PauliSum, as_sum
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"pauli_hamiltonian",
|
|
25
|
+
"ising_hamiltonian",
|
|
26
|
+
"heisenberg_hamiltonian",
|
|
27
|
+
"max_cut_hamiltonian",
|
|
28
|
+
"hamiltonian_matrix",
|
|
29
|
+
"exact_ground_energy",
|
|
30
|
+
"exact_ground_state",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
_PAULI: dict[str, npt.NDArray[Any]] = {
|
|
34
|
+
"I": np.eye(2, dtype=complex),
|
|
35
|
+
"X": np.array([[0, 1], [1, 0]], dtype=complex),
|
|
36
|
+
"Y": np.array([[0, -1j], [1j, 0]], dtype=complex),
|
|
37
|
+
"Z": np.array([[1, 0], [0, -1]], dtype=complex),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def pauli_hamiltonian(terms: Iterable[tuple[str, Sequence[int], float]]) -> PauliSum:
|
|
42
|
+
"""Build from ``(paulis, qubits, coefficient)`` triples.
|
|
43
|
+
|
|
44
|
+
>>> pauli_hamiltonian([("ZZ", (0, 1), 1.0), ("X", (0,), -0.5)])
|
|
45
|
+
Z0 Z1 + -0.5*X0
|
|
46
|
+
"""
|
|
47
|
+
out: list[PauliString] = []
|
|
48
|
+
for letters, qubits, coeff in terms:
|
|
49
|
+
# A constant term is the identity: no letters and no qubits. Allowed, because
|
|
50
|
+
# dropping it would silently shift every energy the Hamiltonian reports.
|
|
51
|
+
if not letters and not tuple(qubits):
|
|
52
|
+
out.append(PauliString((), float(coeff)))
|
|
53
|
+
continue
|
|
54
|
+
if len(letters) != len(qubits):
|
|
55
|
+
raise ValueError(f"{letters!r} needs {len(letters)} qubits, got {tuple(qubits)}")
|
|
56
|
+
paulis = tuple(
|
|
57
|
+
(int(q), p.upper()) for q, p in zip(qubits, letters, strict=True) if p.upper() != "I"
|
|
58
|
+
)
|
|
59
|
+
out.append(PauliString(tuple(sorted(paulis)), float(coeff)))
|
|
60
|
+
return PauliSum(tuple(out))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ising_hamiltonian(
|
|
64
|
+
n_qubits: int,
|
|
65
|
+
j: float = 1.0,
|
|
66
|
+
h: float = 1.0,
|
|
67
|
+
edges: Sequence[tuple[int, int]] | None = None,
|
|
68
|
+
pattern: str = "chain",
|
|
69
|
+
) -> PauliSum:
|
|
70
|
+
r"""Transverse-field Ising model, :math:`H = J\sum Z_iZ_j + h\sum X_i`.
|
|
71
|
+
|
|
72
|
+
The standard first test for any variational eigensolver: it is exactly solvable,
|
|
73
|
+
frustration-free at ``h=0``, and its ground state becomes genuinely entangled as
|
|
74
|
+
``h`` grows, so a working VQE has to do real work.
|
|
75
|
+
"""
|
|
76
|
+
graph = list(edges) if edges is not None else list(entangler_pairs(n_qubits, pattern))
|
|
77
|
+
terms: list[tuple[str, tuple[int, ...], float]] = [("ZZ", (a, b), j) for a, b in graph]
|
|
78
|
+
terms += [("X", (q,), h) for q in range(n_qubits)]
|
|
79
|
+
return pauli_hamiltonian(terms)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def heisenberg_hamiltonian(
|
|
83
|
+
n_qubits: int,
|
|
84
|
+
jx: float = 1.0,
|
|
85
|
+
jy: float = 1.0,
|
|
86
|
+
jz: float = 1.0,
|
|
87
|
+
h: float = 0.0,
|
|
88
|
+
edges: Sequence[tuple[int, int]] | None = None,
|
|
89
|
+
pattern: str = "chain",
|
|
90
|
+
) -> PauliSum:
|
|
91
|
+
r"""Heisenberg model, :math:`\sum J_\alpha \sigma^\alpha_i\sigma^\alpha_j + h\sum Z_i`."""
|
|
92
|
+
graph = list(edges) if edges is not None else list(entangler_pairs(n_qubits, pattern))
|
|
93
|
+
terms: list[tuple[str, Sequence[int], float]] = []
|
|
94
|
+
for a, b in graph:
|
|
95
|
+
for letter, coupling in (("XX", jx), ("YY", jy), ("ZZ", jz)):
|
|
96
|
+
if coupling:
|
|
97
|
+
terms.append((letter, (a, b), coupling))
|
|
98
|
+
terms += [("Z", (q,), h) for q in range(n_qubits) if h]
|
|
99
|
+
return pauli_hamiltonian(terms)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def max_cut_hamiltonian(edges: Sequence[tuple[int, int]], n_qubits: int | None = None) -> PauliSum:
|
|
103
|
+
r"""MaxCut cost, :math:`\tfrac12\sum_{(i,j)\in E}(Z_iZ_j - 1)`.
|
|
104
|
+
|
|
105
|
+
Minimising this maximises the cut, and its ground-state energy is
|
|
106
|
+
``-(number of edges cut)``. The constant is kept rather than dropped so the
|
|
107
|
+
energy VQE or QAOA reports *is* the negated cut size, with nothing to add back.
|
|
108
|
+
"""
|
|
109
|
+
if not edges:
|
|
110
|
+
raise ValueError("MaxCut needs at least one edge")
|
|
111
|
+
_ = n_qubits # width comes from the edges themselves; kept for a symmetric API
|
|
112
|
+
terms: list[tuple[str, Sequence[int], float]] = [("ZZ", (a, b), 0.5) for a, b in edges]
|
|
113
|
+
terms.append(("", (), -0.5 * len(edges)))
|
|
114
|
+
return pauli_hamiltonian(terms)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def hamiltonian_matrix(obs: Observable, n_qubits: int) -> npt.NDArray[Any]:
|
|
118
|
+
"""Dense ``2**n x 2**n`` matrix. Exponential — for checking, not for running."""
|
|
119
|
+
if n_qubits > 14:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"a dense matrix for {n_qubits} qubits needs {4**n_qubits * 16 / 1e9:.0f} GB; "
|
|
122
|
+
"this function exists to verify small cases, not to solve large ones"
|
|
123
|
+
)
|
|
124
|
+
dim = 2**n_qubits
|
|
125
|
+
total = np.zeros((dim, dim), dtype=complex)
|
|
126
|
+
for term in as_sum(obs).terms:
|
|
127
|
+
letters = dict(term.paulis)
|
|
128
|
+
matrix = np.eye(1, dtype=complex)
|
|
129
|
+
for qubit in range(n_qubits):
|
|
130
|
+
matrix = np.kron(matrix, _PAULI[letters.get(qubit, "I")])
|
|
131
|
+
total += complex(term.coeff) * matrix
|
|
132
|
+
return total
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def exact_ground_energy(obs: Observable, n_qubits: int) -> float:
|
|
136
|
+
"""Lowest eigenvalue, by dense diagonalisation. The oracle, not the method."""
|
|
137
|
+
return float(np.linalg.eigvalsh(hamiltonian_matrix(obs, n_qubits))[0])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def exact_ground_state(obs: Observable, n_qubits: int) -> tuple[float, npt.NDArray[Any]]:
|
|
141
|
+
"""Lowest eigenvalue and its eigenvector."""
|
|
142
|
+
values, vectors = np.linalg.eigh(hamiltonian_matrix(obs, n_qubits))
|
|
143
|
+
return float(values[0]), np.asarray(vectors[:, 0])
|