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,297 @@
|
|
|
1
|
+
r"""ADAPT-VQE — grow the ansatz instead of guessing it.
|
|
2
|
+
|
|
3
|
+
A fixed ansatz is a bet placed before you have seen the Hamiltonian. ADAPT-VQE
|
|
4
|
+
(Grimsley et al. 2019) makes the circuit itself part of the optimisation: keep a pool
|
|
5
|
+
of candidate generators, and at each iteration append the one whose gradient is
|
|
6
|
+
largest, then re-optimise everything.
|
|
7
|
+
|
|
8
|
+
The gradient of appending :math:`e^{-i\theta P/2}` to the current state, evaluated at
|
|
9
|
+
:math:`\theta = 0`, is
|
|
10
|
+
|
|
11
|
+
.. math:: \left.\frac{\partial E}{\partial\theta}\right|_0 = -i\langle\psi|[H, P]|\psi\rangle
|
|
12
|
+
|
|
13
|
+
so ranking the pool costs one commutator expectation per candidate — no re-training
|
|
14
|
+
to find out which operator would have helped.
|
|
15
|
+
|
|
16
|
+
This is the algorithm that most depends on a circuit being *data*: growing an ansatz
|
|
17
|
+
mid-optimisation is a list append here, not a rebuild.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import itertools
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import numpy.typing as npt
|
|
29
|
+
|
|
30
|
+
from qmlkit.algorithms.hamiltonians import exact_ground_energy
|
|
31
|
+
from qmlkit.algorithms.vqe import OPTIMIZERS, Optimizer
|
|
32
|
+
from qmlkit.ansatz.blocks import BuildContext, Custom
|
|
33
|
+
from qmlkit.ansatz.library import Ansatz
|
|
34
|
+
from qmlkit.core.builder import QCircuit
|
|
35
|
+
from qmlkit.core.execute import BackendLike, expectation
|
|
36
|
+
from qmlkit.core.observables import Observable, PauliString, PauliSum, as_sum
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AdaptVQE",
|
|
40
|
+
"AdaptResult",
|
|
41
|
+
"pauli_rotation",
|
|
42
|
+
"default_operator_pool",
|
|
43
|
+
"chemistry_operator_pool",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pauli_rotation(qc: QCircuit, term: PauliString, angle: Any) -> None:
|
|
48
|
+
r"""Emit :math:`e^{-i\theta P/2}` for an arbitrary Pauli string ``P``.
|
|
49
|
+
|
|
50
|
+
The standard construction: rotate each wire into the Z basis, run a CX ladder to
|
|
51
|
+
collect the parity onto one wire, apply a single ``rz``, then undo both. Built
|
|
52
|
+
from ordinary registered gates, so it runs on every backend.
|
|
53
|
+
"""
|
|
54
|
+
wires = [q for q, p in term.paulis if p != "I"]
|
|
55
|
+
if not wires:
|
|
56
|
+
return
|
|
57
|
+
for qubit, pauli in term.paulis:
|
|
58
|
+
if pauli == "X":
|
|
59
|
+
qc.h(qubit)
|
|
60
|
+
elif pauli == "Y":
|
|
61
|
+
qc.sdg(qubit)
|
|
62
|
+
qc.h(qubit)
|
|
63
|
+
for a, b in zip(wires, wires[1:], strict=False):
|
|
64
|
+
qc.cx(a, b)
|
|
65
|
+
qc.rz(wires[-1], angle)
|
|
66
|
+
for a, b in reversed(list(zip(wires, wires[1:], strict=False))):
|
|
67
|
+
qc.cx(a, b)
|
|
68
|
+
for qubit, pauli in term.paulis:
|
|
69
|
+
if pauli == "X":
|
|
70
|
+
qc.h(qubit)
|
|
71
|
+
elif pauli == "Y":
|
|
72
|
+
qc.h(qubit)
|
|
73
|
+
qc.s(qubit)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def default_operator_pool(n_qubits: int) -> list[PauliString]:
|
|
77
|
+
"""Single-qubit ``Y`` and neighbouring ``YZ`` generators.
|
|
78
|
+
|
|
79
|
+
Deliberately all imaginary-valued generators: those are the ones that move a real
|
|
80
|
+
starting state, which is what a real-amplitude ground state needs. The pool is an
|
|
81
|
+
argument, so a chemistry-flavoured (UCCSD-style) pool drops straight in.
|
|
82
|
+
"""
|
|
83
|
+
# Note these do *not* conserve particle number, so they are useless on a
|
|
84
|
+
# molecular Hamiltonian -- see chemistry_operator_pool.
|
|
85
|
+
pool = [PauliString(((q, "Y"),), 1.0) for q in range(n_qubits)]
|
|
86
|
+
pool += [PauliString(((q, "Y"), (q + 1, "Z")), 1.0) for q in range(n_qubits - 1)]
|
|
87
|
+
pool += [PauliString(((q, "Y"), (q + 1, "X")), 1.0) for q in range(n_qubits - 1)]
|
|
88
|
+
return pool
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def chemistry_operator_pool(n_qubits: int) -> list[PauliString]:
|
|
92
|
+
r"""A particle-number-conserving pool, for molecular Hamiltonians.
|
|
93
|
+
|
|
94
|
+
:func:`default_operator_pool` is generic and *wrong for chemistry*: a molecular
|
|
95
|
+
Hamiltonian commutes with the number operator, so any generator that changes
|
|
96
|
+
particle number has exactly zero gradient at the Hartree-Fock state. Measured on
|
|
97
|
+
H\ :sub:`2`: every operator in the default pool scores ``0.00e+00``, ADAPT
|
|
98
|
+
correctly concludes nothing helps, and returns an empty circuit.
|
|
99
|
+
|
|
100
|
+
This is the qubit-ADAPT pool of Tang et al. (2021) — the individual Pauli strings
|
|
101
|
+
appearing in single and double excitations, which under Jordan-Wigner carry an
|
|
102
|
+
**odd number of Y factors**. On H\ :sub:`2` the winning operator is the double
|
|
103
|
+
excitation ``Y0 X1 X2 X3``, and one parameter is enough to reach the exact ground
|
|
104
|
+
state.
|
|
105
|
+
"""
|
|
106
|
+
pool: list[PauliString] = []
|
|
107
|
+
for p_, q_ in itertools.combinations(range(n_qubits), 2): # singles
|
|
108
|
+
pool.append(PauliString(((p_, "Y"), (q_, "X")), 1.0))
|
|
109
|
+
pool.append(PauliString(((p_, "X"), (q_, "Y")), 1.0))
|
|
110
|
+
for quad in itertools.combinations(range(n_qubits), 4): # doubles
|
|
111
|
+
for y_at in range(4):
|
|
112
|
+
paulis = tuple((wire, "Y" if index == y_at else "X") for index, wire in enumerate(quad))
|
|
113
|
+
pool.append(PauliString(paulis, 1.0))
|
|
114
|
+
return pool
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class AdaptResult:
|
|
119
|
+
energy: float
|
|
120
|
+
theta: npt.NDArray[Any]
|
|
121
|
+
operators: list[PauliString] = field(default_factory=list)
|
|
122
|
+
history: list[float] = field(default_factory=list)
|
|
123
|
+
gradients: list[float] = field(default_factory=list)
|
|
124
|
+
exact: float | None = None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def error_vs_exact(self) -> float | None:
|
|
128
|
+
return None if self.exact is None else abs(self.energy - self.exact)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def n_operators(self) -> int:
|
|
132
|
+
return len(self.operators)
|
|
133
|
+
|
|
134
|
+
def __repr__(self) -> str:
|
|
135
|
+
tail = "" if self.exact is None else f", error={self.error_vs_exact:.2e}"
|
|
136
|
+
return f"AdaptResult(energy={self.energy:.8f}, operators={self.n_operators}{tail})"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AdaptVQE:
|
|
140
|
+
"""Build the ansatz one operator at a time, largest gradient first."""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
hamiltonian: Observable,
|
|
145
|
+
n_qubits: int,
|
|
146
|
+
pool: Sequence[PauliString] | None = None,
|
|
147
|
+
optimizer: str | Optimizer = "gradient-descent",
|
|
148
|
+
backend: BackendLike = None,
|
|
149
|
+
reference: Sequence[int] | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
self.hamiltonian = hamiltonian
|
|
152
|
+
self.n_qubits = n_qubits
|
|
153
|
+
self.pool = list(pool) if pool is not None else default_operator_pool(n_qubits)
|
|
154
|
+
self.optimizer = optimizer
|
|
155
|
+
self.backend = backend
|
|
156
|
+
#: the starting state, as bits. Hartree-Fock plays this role in chemistry.
|
|
157
|
+
self.reference = list(reference) if reference is not None else []
|
|
158
|
+
|
|
159
|
+
# -------------------------------------------------------------- machinery --
|
|
160
|
+
def _ansatz(self, operators: Sequence[PauliString]) -> Ansatz:
|
|
161
|
+
"""The circuit built from the operators chosen so far — a list, not a class."""
|
|
162
|
+
reference = self.reference
|
|
163
|
+
|
|
164
|
+
def build(qc: QCircuit, ctx: BuildContext) -> None:
|
|
165
|
+
for wire in reference:
|
|
166
|
+
qc.x(wire)
|
|
167
|
+
for term in operators:
|
|
168
|
+
pauli_rotation(qc, term, ctx.new_param())
|
|
169
|
+
|
|
170
|
+
return Ansatz(self.n_qubits, Custom(build, "adapt"), "adapt")
|
|
171
|
+
|
|
172
|
+
def _commutator_gradient(
|
|
173
|
+
self, operators: Sequence[PauliString], theta: npt.NDArray[Any], candidate: PauliString
|
|
174
|
+
) -> float:
|
|
175
|
+
r"""``|<psi|[H, P]|psi>|`` — how much appending ``P`` would move the energy."""
|
|
176
|
+
commutator = _commutator(self.hamiltonian, candidate)
|
|
177
|
+
if not commutator.terms:
|
|
178
|
+
return 0.0
|
|
179
|
+
spec = self._ansatz(operators).build(theta) if len(operators) else self._ansatz([]).build()
|
|
180
|
+
return abs(float(expectation(spec, commutator, backend=self.backend)))
|
|
181
|
+
|
|
182
|
+
# -------------------------------------------------------------------- run --
|
|
183
|
+
def run(
|
|
184
|
+
self,
|
|
185
|
+
max_operators: int = 8,
|
|
186
|
+
gradient_tol: float = 1e-3,
|
|
187
|
+
seed: int | None = None,
|
|
188
|
+
compare_exact: bool | None = None,
|
|
189
|
+
**optimizer_kwargs: Any,
|
|
190
|
+
) -> AdaptResult:
|
|
191
|
+
operators: list[PauliString] = []
|
|
192
|
+
theta: npt.NDArray[Any] = np.zeros(0)
|
|
193
|
+
history: list[float] = []
|
|
194
|
+
picked_gradients: list[float] = []
|
|
195
|
+
|
|
196
|
+
base = self._ansatz([]).build()
|
|
197
|
+
history.append(float(expectation(base, self.hamiltonian, backend=self.backend)))
|
|
198
|
+
|
|
199
|
+
for _ in range(max_operators):
|
|
200
|
+
scores = [self._commutator_gradient(operators, theta, p) for p in self.pool]
|
|
201
|
+
best = int(np.argmax(scores))
|
|
202
|
+
if scores[best] < gradient_tol:
|
|
203
|
+
break # nothing left in the pool moves the energy
|
|
204
|
+
operators.append(self.pool[best])
|
|
205
|
+
picked_gradients.append(float(scores[best]))
|
|
206
|
+
|
|
207
|
+
ansatz = self._ansatz(operators)
|
|
208
|
+
spec = ansatz.build()
|
|
209
|
+
start = np.concatenate([theta, [0.0]]) # a new operator starts at identity
|
|
210
|
+
|
|
211
|
+
def energy(t: Sequence[float], spec: Any = spec) -> float:
|
|
212
|
+
return float(
|
|
213
|
+
expectation(
|
|
214
|
+
spec, self.hamiltonian, theta=np.asarray(t, float), backend=self.backend
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def gradient(t: Sequence[float], spec: Any = spec) -> npt.NDArray[Any]:
|
|
219
|
+
from qmlkit.gradients.dispatch import grad
|
|
220
|
+
|
|
221
|
+
return grad(spec, np.asarray(t, float), self.hamiltonian, backend=self.backend)
|
|
222
|
+
|
|
223
|
+
fn = OPTIMIZERS[self.optimizer] if isinstance(self.optimizer, str) else self.optimizer
|
|
224
|
+
kwargs = dict(optimizer_kwargs)
|
|
225
|
+
if fn is OPTIMIZERS["gradient-descent"]:
|
|
226
|
+
kwargs.setdefault("grad", gradient)
|
|
227
|
+
kwargs.setdefault("n_steps", 60)
|
|
228
|
+
kwargs.setdefault("lr", 0.2)
|
|
229
|
+
if fn is OPTIMIZERS["spsa"]:
|
|
230
|
+
kwargs.setdefault("seed", seed)
|
|
231
|
+
theta, run_history = fn(energy, start, **kwargs)
|
|
232
|
+
history.append(float(run_history[-1]))
|
|
233
|
+
|
|
234
|
+
if compare_exact is None:
|
|
235
|
+
compare_exact = self.n_qubits <= 12
|
|
236
|
+
exact = exact_ground_energy(self.hamiltonian, self.n_qubits) if compare_exact else None
|
|
237
|
+
|
|
238
|
+
return AdaptResult(
|
|
239
|
+
energy=history[-1],
|
|
240
|
+
theta=theta,
|
|
241
|
+
operators=operators,
|
|
242
|
+
history=history,
|
|
243
|
+
gradients=picked_gradients,
|
|
244
|
+
exact=exact,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def __repr__(self) -> str:
|
|
248
|
+
return f"AdaptVQE(n_qubits={self.n_qubits}, pool={len(self.pool)} operators)"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# --------------------------------------------------------------------------- #
|
|
252
|
+
def _multiply(a: PauliString, b: PauliString) -> tuple[complex, PauliString]:
|
|
253
|
+
"""Product of two Pauli strings, as ``(phase, string)``."""
|
|
254
|
+
table = {
|
|
255
|
+
("X", "Y"): (1j, "Z"),
|
|
256
|
+
("Y", "X"): (-1j, "Z"),
|
|
257
|
+
("Y", "Z"): (1j, "X"),
|
|
258
|
+
("Z", "Y"): (-1j, "X"),
|
|
259
|
+
("Z", "X"): (1j, "Y"),
|
|
260
|
+
("X", "Z"): (-1j, "Y"),
|
|
261
|
+
}
|
|
262
|
+
left, right = dict(a.paulis), dict(b.paulis)
|
|
263
|
+
phase = complex(a.coeff) * complex(b.coeff)
|
|
264
|
+
out: dict[int, str] = {}
|
|
265
|
+
for qubit in set(left) | set(right):
|
|
266
|
+
p, q = left.get(qubit), right.get(qubit)
|
|
267
|
+
if p is None or q is None:
|
|
268
|
+
out[qubit] = p or q # type: ignore[assignment]
|
|
269
|
+
elif p == q:
|
|
270
|
+
continue # P^2 = I
|
|
271
|
+
else:
|
|
272
|
+
factor, letter = table[(p, q)]
|
|
273
|
+
phase *= factor
|
|
274
|
+
out[qubit] = letter
|
|
275
|
+
return phase, PauliString(tuple(sorted(out.items())), 1.0)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _commutator(h: Observable, p: PauliString) -> PauliSum:
|
|
279
|
+
r"""``i[H, P]`` — the *Hermitian* combination, which is what can be measured.
|
|
280
|
+
|
|
281
|
+
``[H, P]`` with both operands Hermitian is anti-Hermitian, so every coefficient it
|
|
282
|
+
produces is purely imaginary and its expectation value is imaginary too. Taking
|
|
283
|
+
the real part of that discards the whole thing: an early version of this function
|
|
284
|
+
did exactly that, and ADAPT-VQE then scored every candidate at zero and grew an
|
|
285
|
+
empty circuit. Multiplying by ``i`` first is what makes the result an observable.
|
|
286
|
+
"""
|
|
287
|
+
collected: dict[tuple[tuple[int, str], ...], complex] = {}
|
|
288
|
+
for term in as_sum(h).terms:
|
|
289
|
+
for left, right, sign in ((term, p, 1.0), (p, term, -1.0)):
|
|
290
|
+
phase, product = _multiply(left, right)
|
|
291
|
+
collected[product.paulis] = collected.get(product.paulis, 0j) + sign * phase
|
|
292
|
+
terms = [
|
|
293
|
+
PauliString(paulis, float(np.real(1j * coeff)))
|
|
294
|
+
for paulis, coeff in collected.items()
|
|
295
|
+
if abs(np.real(1j * coeff)) > 1e-12
|
|
296
|
+
]
|
|
297
|
+
return PauliSum(tuple(terms))
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Quantum autoencoder — compress ``n`` qubits into ``k`` (Romero, Olson & Aspuru-Guzik 2017).
|
|
2
|
+
|
|
3
|
+
The trick is that you never need the decoder to train. If the encoder has genuinely
|
|
4
|
+
pushed all the information into ``k`` latent qubits, the discarded "trash" qubits must
|
|
5
|
+
be left in a known pure state — so **maximising the trash qubits' purity is the whole
|
|
6
|
+
loss**, and it costs no extra circuits.
|
|
7
|
+
|
|
8
|
+
from qmlkit.algorithms import QuantumAutoencoder
|
|
9
|
+
|
|
10
|
+
model = QuantumAutoencoder(n_qubits=4, n_latent=2)
|
|
11
|
+
result = model.fit(states, seed=0)
|
|
12
|
+
print(result.fidelity) # how well the input survives a round trip
|
|
13
|
+
|
|
14
|
+
The encoder is an ``Ansatz`` argument like everywhere else, so "which circuit
|
|
15
|
+
compresses best" is an experiment you run, not a fork of this file.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
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.vqe import OPTIMIZERS, Optimizer
|
|
29
|
+
from qmlkit.ansatz.library import Ansatz, hardware_efficient
|
|
30
|
+
from qmlkit.core.execute import BackendLike, expectation, statevector
|
|
31
|
+
from qmlkit.core.ir import CircuitSpec, bound_angle
|
|
32
|
+
from qmlkit.core.observables import Observable, PauliString, PauliSum
|
|
33
|
+
from qmlkit.info import purity, state_fidelity
|
|
34
|
+
|
|
35
|
+
__all__ = ["QuantumAutoencoder", "AutoencoderResult"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class AutoencoderResult:
|
|
40
|
+
theta: npt.NDArray[Any]
|
|
41
|
+
trash_purity: float
|
|
42
|
+
fidelity: float
|
|
43
|
+
trash_fidelity: float = 0.0
|
|
44
|
+
history: list[float] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
return (
|
|
48
|
+
f"AutoencoderResult(trash_fidelity={self.trash_fidelity:.6f}, "
|
|
49
|
+
f"fidelity={self.fidelity:.6f}, steps={len(self.history) - 1})"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class QuantumAutoencoder:
|
|
54
|
+
"""Train an encoder that concentrates a state into ``n_latent`` qubits.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
n_qubits, n_latent
|
|
59
|
+
Width in, width kept. The remaining ``n_qubits - n_latent`` are the trash.
|
|
60
|
+
encoder
|
|
61
|
+
Any ``Ansatz`` of the right width. Defaults to hardware-efficient.
|
|
62
|
+
trash
|
|
63
|
+
Which wires to discard. Defaults to the last ones.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
n_qubits: int,
|
|
69
|
+
n_latent: int,
|
|
70
|
+
encoder: Ansatz | None = None,
|
|
71
|
+
n_layers: int = 3,
|
|
72
|
+
trash: Sequence[int] | None = None,
|
|
73
|
+
optimizer: str | Optimizer = "rotosolve",
|
|
74
|
+
backend: BackendLike = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
if not 0 < n_latent < n_qubits:
|
|
77
|
+
raise ValueError(f"n_latent must be between 1 and {n_qubits - 1}, got {n_latent}")
|
|
78
|
+
self.n_qubits = n_qubits
|
|
79
|
+
self.n_latent = n_latent
|
|
80
|
+
self.trash = list(trash) if trash is not None else list(range(n_latent, n_qubits))
|
|
81
|
+
self.encoder = encoder or hardware_efficient(n_qubits, n_layers)
|
|
82
|
+
self.optimizer = optimizer
|
|
83
|
+
self.backend = backend
|
|
84
|
+
self._spec = self.encoder.build()
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------- loss --
|
|
87
|
+
def _trash_projector(self) -> Observable:
|
|
88
|
+
r"""The observable :math:`\prod_{i \in \mathrm{trash}} (I + Z_i)/2`.
|
|
89
|
+
|
|
90
|
+
This projects onto "every trash wire reads zero". Expanding the product gives
|
|
91
|
+
an ordinary Pauli sum, which matters more than it looks: the loss is then a
|
|
92
|
+
plain expectation value, so gradients come from :func:`qmlkit.grad` and
|
|
93
|
+
Rotosolve is valid — neither of which is true of a purity-based loss.
|
|
94
|
+
"""
|
|
95
|
+
terms: list[PauliString] = []
|
|
96
|
+
scale = 0.5 ** len(self.trash)
|
|
97
|
+
for mask in range(2 ** len(self.trash)):
|
|
98
|
+
chosen = [w for i, w in enumerate(self.trash) if mask >> i & 1]
|
|
99
|
+
terms.append(PauliString(tuple((w, "Z") for w in sorted(chosen)), scale))
|
|
100
|
+
return PauliSum(tuple(terms))
|
|
101
|
+
|
|
102
|
+
def trash_fidelity(self, theta: ArrayLike, states: Sequence[CircuitSpec]) -> float:
|
|
103
|
+
r"""Mean :math:`\langle 0|
|
|
104
|
+
ho_\mathrm{trash}|0
|
|
105
|
+
angle` — 1.0 is perfect compression.
|
|
106
|
+
|
|
107
|
+
Purity alone is **not** enough, and getting that wrong is easy: an encoder can
|
|
108
|
+
leave the trash in a pure state pointing somewhere other than
|
|
109
|
+
:math:`|0
|
|
110
|
+
angle`, scoring purity 0.998 while the round trip only returns
|
|
111
|
+
fidelity 0.21. Measured, on the way to writing this. What the decoder needs is
|
|
112
|
+
the trash reset to a *known* state, so that is what the loss asks for.
|
|
113
|
+
"""
|
|
114
|
+
arr = np.asarray(theta, dtype=float)
|
|
115
|
+
projector = self._trash_projector()
|
|
116
|
+
total = 0.0
|
|
117
|
+
for prep in states:
|
|
118
|
+
encoded = prep.compose(self._spec.bind(arr))
|
|
119
|
+
total += float(expectation(encoded, projector, backend=self.backend))
|
|
120
|
+
return total / len(states)
|
|
121
|
+
|
|
122
|
+
def trash_purity(self, theta: ArrayLike, states: Sequence[CircuitSpec]) -> float:
|
|
123
|
+
"""Mean purity of the discarded wires. Reported, but not what is optimised."""
|
|
124
|
+
arr = np.asarray(theta, dtype=float)
|
|
125
|
+
total = 0.0
|
|
126
|
+
for prep in states:
|
|
127
|
+
encoded = prep.compose(self._spec.bind(arr))
|
|
128
|
+
total += purity(encoded, self.trash, backend=self.backend)
|
|
129
|
+
return total / len(states)
|
|
130
|
+
|
|
131
|
+
def loss(self, theta: Sequence[float], states: Sequence[CircuitSpec]) -> float:
|
|
132
|
+
"""One minus the trash fidelity. No decoder is ever built to train this."""
|
|
133
|
+
return 1.0 - self.trash_fidelity(theta, states)
|
|
134
|
+
|
|
135
|
+
# -------------------------------------------------------------------- fit --
|
|
136
|
+
def fit(
|
|
137
|
+
self,
|
|
138
|
+
states: Sequence[CircuitSpec],
|
|
139
|
+
theta0: Sequence[float] | None = None,
|
|
140
|
+
seed: int | None = None,
|
|
141
|
+
**optimizer_kwargs: Any,
|
|
142
|
+
) -> AutoencoderResult:
|
|
143
|
+
start = (
|
|
144
|
+
np.asarray(theta0, dtype=float)
|
|
145
|
+
if theta0 is not None
|
|
146
|
+
else self.encoder.init("small", seed=seed)
|
|
147
|
+
)
|
|
148
|
+
fn = OPTIMIZERS[self.optimizer] if isinstance(self.optimizer, str) else self.optimizer
|
|
149
|
+
if fn is OPTIMIZERS["spsa"]:
|
|
150
|
+
optimizer_kwargs.setdefault("seed", seed)
|
|
151
|
+
theta, history = fn(lambda t: self.loss(t, states), start, **optimizer_kwargs)
|
|
152
|
+
|
|
153
|
+
return AutoencoderResult(
|
|
154
|
+
theta=theta,
|
|
155
|
+
trash_purity=self.trash_purity(theta, states),
|
|
156
|
+
trash_fidelity=self.trash_fidelity(theta, states),
|
|
157
|
+
fidelity=self.round_trip_fidelity(theta, states),
|
|
158
|
+
history=list(history),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------- validation --
|
|
162
|
+
def round_trip_fidelity(self, theta: ArrayLike, states: Sequence[CircuitSpec]) -> float:
|
|
163
|
+
"""Encode, reset the trash to ``|0>``, decode, and compare to the input.
|
|
164
|
+
|
|
165
|
+
This is the quantity the compression *claims*, and it is deliberately not the
|
|
166
|
+
training loss — it is the independent check that maximising trash purity was
|
|
167
|
+
the right proxy at all.
|
|
168
|
+
"""
|
|
169
|
+
arr = np.asarray(theta, dtype=float)
|
|
170
|
+
encoder = self._spec.bind(arr)
|
|
171
|
+
decoder = encoder.adjoint()
|
|
172
|
+
total = 0.0
|
|
173
|
+
for prep in states:
|
|
174
|
+
original = statevector(prep, backend=self.backend)
|
|
175
|
+
encoded = statevector(prep.compose(encoder), backend=self.backend)
|
|
176
|
+
restored = self._apply_spec(self._reset_trash(encoded), decoder)
|
|
177
|
+
total += state_fidelity(restored, original)
|
|
178
|
+
return total / len(states)
|
|
179
|
+
|
|
180
|
+
def _reset_trash(self, state: npt.NDArray[Any]) -> npt.NDArray[Any]:
|
|
181
|
+
"""Project the trash wires onto ``|0>`` and renormalise."""
|
|
182
|
+
tensor = np.asarray(state).reshape((2,) * self.n_qubits)
|
|
183
|
+
keep: list[Any] = [slice(None)] * self.n_qubits
|
|
184
|
+
for wire in self.trash:
|
|
185
|
+
keep[wire] = 0
|
|
186
|
+
projected = np.zeros_like(tensor)
|
|
187
|
+
projected[tuple(keep)] = tensor[tuple(keep)]
|
|
188
|
+
flat = projected.reshape(-1)
|
|
189
|
+
norm = np.linalg.norm(flat)
|
|
190
|
+
return flat / norm if norm > 1e-12 else flat
|
|
191
|
+
|
|
192
|
+
def _apply_spec(self, state: npt.NDArray[Any], spec: CircuitSpec) -> npt.NDArray[Any]:
|
|
193
|
+
from qmlkit.core.backends.numpy_backend import _apply
|
|
194
|
+
from qmlkit.core.gates import gate_matrix
|
|
195
|
+
|
|
196
|
+
out = np.asarray(state, dtype=complex).reshape((2,) * self.n_qubits)
|
|
197
|
+
for op in spec.ops:
|
|
198
|
+
angles = tuple(bound_angle(p) for p in op.params)
|
|
199
|
+
out = _apply(out, gate_matrix(op.gate, angles), op.qubits)
|
|
200
|
+
return out.reshape(-1)
|
|
201
|
+
|
|
202
|
+
def __repr__(self) -> str:
|
|
203
|
+
return (
|
|
204
|
+
f"QuantumAutoencoder({self.n_qubits} -> {self.n_latent}, "
|
|
205
|
+
f"trash={self.trash}, encoder={self.encoder.name!r})"
|
|
206
|
+
)
|