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,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
+ )