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
qmlkit/search.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""One call to sweep everything tunable, with the broken configurations skipped.
|
|
2
|
+
|
|
3
|
+
best = qk.search(X, y, ansatz=["hardware_efficient", "strongly_entangling"],
|
|
4
|
+
n_qubits=[4, 6], n_layers=[2, 3], lr=[0.05, 0.1])
|
|
5
|
+
|
|
6
|
+
Every axis takes a list; anything you leave out keeps its default. The result ranks
|
|
7
|
+
the configurations on the metric the task deserves — imbalance-aware for
|
|
8
|
+
classification — scores all of them on identical folds, and refuses to call a winner
|
|
9
|
+
when the gap is inside the fold-to-fold spread, exactly as
|
|
10
|
+
:func:`~qmlkit.baselines.baseline` does.
|
|
11
|
+
|
|
12
|
+
**The part no other library can do.** A grid search normally trains everything and
|
|
13
|
+
sorts the results, so a configuration that *cannot work* still costs a full fit — and
|
|
14
|
+
then sits in the table looking merely unlucky. Before fitting anything, this runs
|
|
15
|
+
:func:`~qmlkit.diagnostics.diagnose` on the assembled model and drops the ones with an
|
|
16
|
+
error-level finding: an ansatz whose weights cannot move the state, a re-uploading
|
|
17
|
+
block that collapses to one frequency, a model with no trainable parameters. They are
|
|
18
|
+
reported as *pruned, with the reason*, not silently dropped and not quietly ranked
|
|
19
|
+
last.
|
|
20
|
+
|
|
21
|
+
On a grid where a third of the points are unlearnable that is a third of the compute,
|
|
22
|
+
and — more to the point — a third of the rows in your results table that would
|
|
23
|
+
otherwise have been noise you might have read as signal.
|
|
24
|
+
|
|
25
|
+
**It tells you the cost first.** ``qk.search(..., dry_run=True)`` returns the plan
|
|
26
|
+
without fitting anything: how many configurations, how many fits, and how many
|
|
27
|
+
circuits, so a sweep that would take a week is something you find out in a second.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import itertools
|
|
33
|
+
import time
|
|
34
|
+
from collections.abc import Callable, Sequence
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
import numpy.typing as npt
|
|
40
|
+
|
|
41
|
+
from qmlkit.ansatz.library import Ansatz, get_ansatz
|
|
42
|
+
from qmlkit.encoding.feature_maps import (
|
|
43
|
+
AngleFeatureMap,
|
|
44
|
+
FeatureMap,
|
|
45
|
+
PauliFeatureMap,
|
|
46
|
+
ZFeatureMap,
|
|
47
|
+
ZZFeatureMap,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"search",
|
|
52
|
+
"SearchResult",
|
|
53
|
+
"SearchRow",
|
|
54
|
+
"register_feature_map",
|
|
55
|
+
"list_feature_maps",
|
|
56
|
+
"AXES",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
Array = npt.NDArray[Any]
|
|
60
|
+
|
|
61
|
+
#: Feature maps reachable by name. Registering adds to every later search.
|
|
62
|
+
_FEATURE_MAPS: dict[str, Callable[..., FeatureMap]] = {
|
|
63
|
+
"angle": lambda n_qubits, **kw: AngleFeatureMap(n_qubits, entangle=n_qubits > 1, **kw),
|
|
64
|
+
"angle-plain": lambda n_qubits, **kw: AngleFeatureMap(n_qubits, entangle=False, **kw),
|
|
65
|
+
"z": lambda n_qubits, **kw: ZFeatureMap(n_qubits, **kw),
|
|
66
|
+
"zz": lambda n_qubits, **kw: ZZFeatureMap(n_qubits, **kw),
|
|
67
|
+
"pauli": lambda n_qubits, **kw: PauliFeatureMap(n_qubits, **kw),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def register_feature_map(name: str, factory: Callable[..., FeatureMap]) -> None:
|
|
72
|
+
"""Make a feature map reachable by name, here and in every later search."""
|
|
73
|
+
_FEATURE_MAPS[name] = factory
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def list_feature_maps() -> tuple[str, ...]:
|
|
77
|
+
return tuple(sorted(_FEATURE_MAPS))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
#: Every axis a search understands, with the value used when you do not give one.
|
|
81
|
+
#: A name outside this set is refused with a suggestion rather than silently ignored,
|
|
82
|
+
#: because a typo'd axis in a grid search is a sweep that quietly varies nothing.
|
|
83
|
+
#: ``n_qubits`` defaults to ``None``, meaning "read it off the data" — a fixed default
|
|
84
|
+
#: would ask a 2-feature dataset for 4 qubits, and a feature pipeline can drop columns
|
|
85
|
+
#: but cannot invent them.
|
|
86
|
+
AXES: dict[str, Any] = {
|
|
87
|
+
"n_qubits": None,
|
|
88
|
+
"ansatz": "hardware_efficient",
|
|
89
|
+
"n_layers": 2,
|
|
90
|
+
"feature_map": "angle",
|
|
91
|
+
"lr": 0.05,
|
|
92
|
+
"epochs": 30,
|
|
93
|
+
"batch_size": 256,
|
|
94
|
+
"class_weight": None,
|
|
95
|
+
"focal_gamma": 0.0,
|
|
96
|
+
"grad_method": "auto",
|
|
97
|
+
"shots": None,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class SearchRow:
|
|
103
|
+
"""One configuration's outcome: a score, or the reason it was never fitted."""
|
|
104
|
+
|
|
105
|
+
config: dict[str, Any]
|
|
106
|
+
mean: float = float("nan")
|
|
107
|
+
std: float = float("nan")
|
|
108
|
+
fold_scores: tuple[float, ...] = ()
|
|
109
|
+
pruned: str = ""
|
|
110
|
+
seconds: float = 0.0
|
|
111
|
+
#: what diagnose() said about this configuration, whether or not it was pruned
|
|
112
|
+
findings: tuple[str, ...] = ()
|
|
113
|
+
fitted: bool = True
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def ran(self) -> bool:
|
|
117
|
+
return self.fitted and not self.pruned
|
|
118
|
+
|
|
119
|
+
def label(self, axes: Sequence[str]) -> str:
|
|
120
|
+
"""Only the axes that actually varied, so the table stays readable."""
|
|
121
|
+
return " ".join(f"{k}={self.config[k]}" for k in axes)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class SearchResult:
|
|
126
|
+
"""Every configuration on identical folds, best first."""
|
|
127
|
+
|
|
128
|
+
task: str
|
|
129
|
+
metric: str
|
|
130
|
+
n_samples: int
|
|
131
|
+
n_folds: int
|
|
132
|
+
varied: tuple[str, ...]
|
|
133
|
+
rows: tuple[SearchRow, ...] = ()
|
|
134
|
+
notes: tuple[str, ...] = ()
|
|
135
|
+
extras: dict[str, Any] = field(default_factory=dict)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def ran(self) -> tuple[SearchRow, ...]:
|
|
139
|
+
return tuple(r for r in self.rows if r.ran)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def pruned(self) -> tuple[SearchRow, ...]:
|
|
143
|
+
"""Configurations skipped *because a diagnosis said so*.
|
|
144
|
+
|
|
145
|
+
Not the same as "did not run": a dry run fits nothing and prunes only what it
|
|
146
|
+
would have pruned for real, so these two have to stay distinguishable.
|
|
147
|
+
"""
|
|
148
|
+
return tuple(r for r in self.rows if r.pruned)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def best(self) -> SearchRow | None:
|
|
152
|
+
return max(self.ran, key=lambda r: r.mean) if self.ran else None
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def best_config(self) -> dict[str, Any]:
|
|
156
|
+
row = self.best
|
|
157
|
+
if row is None:
|
|
158
|
+
raise ValueError("no configuration ran; see .pruned for why")
|
|
159
|
+
return dict(row.config)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def verdict(self) -> str:
|
|
163
|
+
"""Whether the winner is actually a winner, or is inside the noise."""
|
|
164
|
+
if not self.ran:
|
|
165
|
+
if any(r.pruned for r in self.rows) and not all(r.pruned for r in self.rows):
|
|
166
|
+
return "nothing was fitted; the rest were pruned before fitting"
|
|
167
|
+
if not any(r.pruned for r in self.rows):
|
|
168
|
+
return f"nothing was fitted: {len(self.rows)} configurations would run"
|
|
169
|
+
return "every configuration was pruned before fitting"
|
|
170
|
+
ordered = sorted(self.ran, key=lambda r: -r.mean)
|
|
171
|
+
best = ordered[0]
|
|
172
|
+
if len(ordered) == 1:
|
|
173
|
+
return f"only one configuration ran: {best.label(self.varied)} at {best.mean:.3f}"
|
|
174
|
+
runner_up = ordered[1]
|
|
175
|
+
gap = best.mean - runner_up.mean
|
|
176
|
+
spread = float(np.hypot(best.std, runner_up.std))
|
|
177
|
+
if spread > 0 and gap < spread:
|
|
178
|
+
return (
|
|
179
|
+
f"{best.label(self.varied)} leads at {best.mean:.3f}, but by {gap:.3f} over "
|
|
180
|
+
f"{runner_up.label(self.varied)} -- inside the fold spread ({spread:.3f}), so "
|
|
181
|
+
"this grid has not separated them"
|
|
182
|
+
)
|
|
183
|
+
return (
|
|
184
|
+
f"{best.label(self.varied)} wins at {best.mean:.3f}, ahead of "
|
|
185
|
+
f"{runner_up.label(self.varied)} by {gap:.3f} (fold spread {spread:.3f})"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def model(self, **overrides: Any) -> Any:
|
|
189
|
+
"""A fresh, unfitted model built from the winning configuration."""
|
|
190
|
+
return _build_model(self.best_config | overrides, self.extras["n_classes"])
|
|
191
|
+
|
|
192
|
+
def __str__(self) -> str:
|
|
193
|
+
width = max((len(r.label(self.varied)) for r in self.rows), default=10)
|
|
194
|
+
split = "stratified" if self.task == "classification" else "shuffled"
|
|
195
|
+
lines = [
|
|
196
|
+
f"{self.task} | {self.metric} | {self.n_folds}-fold {split} "
|
|
197
|
+
f"| n={self.n_samples} | {len(self.rows)} configurations"
|
|
198
|
+
]
|
|
199
|
+
for row in sorted(self.rows, key=lambda r: (not r.ran, -r.mean)):
|
|
200
|
+
label = row.label(self.varied)
|
|
201
|
+
flags = f" [{', '.join(row.findings)}]" if row.findings else ""
|
|
202
|
+
if row.ran:
|
|
203
|
+
lines.append(f" {label:<{width}} {row.mean: .3f} +/- {row.std:.3f}{flags}")
|
|
204
|
+
elif row.pruned:
|
|
205
|
+
lines.append(f" {label:<{width}} pruned: {row.pruned}")
|
|
206
|
+
else:
|
|
207
|
+
lines.append(f" {label:<{width}} would run{flags}")
|
|
208
|
+
lines.append(f"\n{self.verdict}")
|
|
209
|
+
lines.extend(f"note: {note}" for note in self.notes)
|
|
210
|
+
return "\n".join(lines)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --------------------------------------------------------------------------- #
|
|
214
|
+
# building a model from one point of the grid
|
|
215
|
+
# --------------------------------------------------------------------------- #
|
|
216
|
+
def _resolve_ansatz(spec: Any, n_qubits: int, n_layers: int) -> Ansatz:
|
|
217
|
+
if isinstance(spec, Ansatz):
|
|
218
|
+
return spec
|
|
219
|
+
if callable(spec):
|
|
220
|
+
built: Ansatz = spec(n_qubits, n_layers)
|
|
221
|
+
return built
|
|
222
|
+
try:
|
|
223
|
+
return get_ansatz(spec, n_qubits=n_qubits, n_layers=n_layers)
|
|
224
|
+
except TypeError: # a registered factory that does not take n_layers
|
|
225
|
+
return get_ansatz(spec, n_qubits=n_qubits)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _resolve_feature_map(spec: Any, n_qubits: int) -> FeatureMap:
|
|
229
|
+
if isinstance(spec, FeatureMap):
|
|
230
|
+
return spec
|
|
231
|
+
if callable(spec):
|
|
232
|
+
built: FeatureMap = spec(n_qubits)
|
|
233
|
+
return built
|
|
234
|
+
if spec not in _FEATURE_MAPS:
|
|
235
|
+
from qmlkit.utils.errors import unknown
|
|
236
|
+
|
|
237
|
+
raise unknown("feature map", spec, _FEATURE_MAPS)
|
|
238
|
+
return _FEATURE_MAPS[spec](n_qubits)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _build_model(config: dict[str, Any], n_classes: int) -> Any:
|
|
242
|
+
from qmlkit.nn.models import VQC, VQRegressor
|
|
243
|
+
|
|
244
|
+
n_qubits = int(config["n_qubits"])
|
|
245
|
+
ansatz = _resolve_ansatz(config["ansatz"], n_qubits, int(config["n_layers"]))
|
|
246
|
+
feature_map = _resolve_feature_map(config["feature_map"], n_qubits)
|
|
247
|
+
shared = dict(
|
|
248
|
+
n_features=n_qubits,
|
|
249
|
+
n_qubits=n_qubits,
|
|
250
|
+
ansatz=ansatz,
|
|
251
|
+
feature_map=feature_map,
|
|
252
|
+
grad_method=config["grad_method"],
|
|
253
|
+
shots=config["shots"],
|
|
254
|
+
seed=0,
|
|
255
|
+
)
|
|
256
|
+
if n_classes:
|
|
257
|
+
return VQC(
|
|
258
|
+
n_classes=n_classes,
|
|
259
|
+
class_weight=config["class_weight"],
|
|
260
|
+
focal_gamma=float(config["focal_gamma"]),
|
|
261
|
+
**shared,
|
|
262
|
+
)
|
|
263
|
+
return VQRegressor(**shared)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# --------------------------------------------------------------------------- #
|
|
267
|
+
# the search
|
|
268
|
+
# --------------------------------------------------------------------------- #
|
|
269
|
+
def search(
|
|
270
|
+
X: Any,
|
|
271
|
+
y: Any,
|
|
272
|
+
task: str = "auto",
|
|
273
|
+
cv: int = 3,
|
|
274
|
+
metric: str | None = None,
|
|
275
|
+
seed: int | None = 0,
|
|
276
|
+
prune: str | Sequence[str] = "error",
|
|
277
|
+
max_configs: int | None = None,
|
|
278
|
+
dry_run: bool = False,
|
|
279
|
+
verbose: bool = True,
|
|
280
|
+
**axes: Any,
|
|
281
|
+
) -> SearchResult:
|
|
282
|
+
"""Sweep every tunable axis, skipping the configurations that cannot work.
|
|
283
|
+
|
|
284
|
+
Parameters
|
|
285
|
+
----------
|
|
286
|
+
X, y:
|
|
287
|
+
The data. Folds are stratified for classification and identical for every
|
|
288
|
+
configuration, so the table compares models rather than splits.
|
|
289
|
+
cv:
|
|
290
|
+
Folds per configuration.
|
|
291
|
+
metric:
|
|
292
|
+
What to rank on. Defaults to the task's primary metric, which is
|
|
293
|
+
imbalance-aware for classification.
|
|
294
|
+
prune:
|
|
295
|
+
Which diagnoses stop a configuration being fitted, as a named level or an
|
|
296
|
+
explicit list of finding codes.
|
|
297
|
+
|
|
298
|
+
``"error"`` (the default) skips only what cannot work at all: no trainable
|
|
299
|
+
weights, a re-uploading block collapsed to one frequency, input features the
|
|
300
|
+
circuit discards. ``"untrainable"`` also skips flat gradients and circuits that
|
|
301
|
+
never entangle. ``"warning"`` additionally skips merely *wasteful* ones — dead
|
|
302
|
+
or unmeasurable weights — which is aggressive: ``hardware_efficient`` carries an
|
|
303
|
+
``UNMEASURABLE_WEIGHTS`` finding by construction, so that level can empty a
|
|
304
|
+
grid. ``"none"`` fits everything.
|
|
305
|
+
|
|
306
|
+
Every configuration is diagnosed regardless of this setting, and its findings
|
|
307
|
+
are printed beside its score — a point that scores well *and* carries
|
|
308
|
+
``DEAD_WEIGHTS`` is worth seeing as exactly that.
|
|
309
|
+
max_configs:
|
|
310
|
+
Sample this many points from the grid instead of taking all of them. The
|
|
311
|
+
sample is seeded, so it is reproducible.
|
|
312
|
+
dry_run:
|
|
313
|
+
Build and prune the grid, report what it would cost, and fit nothing.
|
|
314
|
+
**axes:
|
|
315
|
+
Any key in :data:`AXES`, given a list of values (a bare value is treated as a
|
|
316
|
+
one-element list). An unrecognised axis is an error with a suggestion, because
|
|
317
|
+
a typo'd axis name is a sweep that silently varies nothing.
|
|
318
|
+
|
|
319
|
+
Examples
|
|
320
|
+
--------
|
|
321
|
+
>>> import qmlkit as qk
|
|
322
|
+
>>> X, y = qk.datasets.make_moons(n_samples=40, seed=0)
|
|
323
|
+
>>> plan = qk.search(X, y, n_layers=[1, 2], cv=2, dry_run=True)
|
|
324
|
+
>>> len(plan.rows)
|
|
325
|
+
2
|
|
326
|
+
"""
|
|
327
|
+
from qmlkit.baselines import _infer_task
|
|
328
|
+
from qmlkit.imbalance import stratified_folds
|
|
329
|
+
|
|
330
|
+
data = np.atleast_2d(np.asarray(X, dtype=float))
|
|
331
|
+
target = np.asarray(y).ravel()
|
|
332
|
+
if data.shape[0] != target.size:
|
|
333
|
+
raise ValueError(f"X has {data.shape[0]} rows but y has {target.size}")
|
|
334
|
+
|
|
335
|
+
unknown_axes = set(axes) - set(AXES)
|
|
336
|
+
if unknown_axes:
|
|
337
|
+
from qmlkit.utils.errors import unknown
|
|
338
|
+
|
|
339
|
+
raise unknown("search axis", sorted(unknown_axes)[0], AXES)
|
|
340
|
+
|
|
341
|
+
n_features = data.shape[1]
|
|
342
|
+
resolved = _infer_task(target) if task == "auto" else task
|
|
343
|
+
metric = metric or ("balanced_accuracy" if resolved == "classification" else "r2")
|
|
344
|
+
n_classes = int(np.unique(target).size) if resolved == "classification" else 0
|
|
345
|
+
|
|
346
|
+
grid = {k: (list(v) if isinstance(v, list | tuple) else [v]) for k, v in axes.items()}
|
|
347
|
+
too_wide = [int(q) for q in grid.get("n_qubits", []) if q is not None and int(q) > n_features]
|
|
348
|
+
if too_wide:
|
|
349
|
+
raise ValueError(
|
|
350
|
+
f"n_qubits={too_wide} exceeds the {n_features} feature(s) in X. A feature "
|
|
351
|
+
"pipeline reduces columns; it cannot invent them. Either widen the data or "
|
|
352
|
+
f"keep n_qubits at or below {n_features}."
|
|
353
|
+
)
|
|
354
|
+
varied = tuple(k for k, v in grid.items() if len(v) > 1) or tuple(grid) or ("n_qubits",)
|
|
355
|
+
|
|
356
|
+
notes: list[str] = []
|
|
357
|
+
if "n_qubits" in grid:
|
|
358
|
+
width = AXES["n_qubits"]
|
|
359
|
+
else:
|
|
360
|
+
# one qubit per feature, capped: past six the sweep costs more than it teaches
|
|
361
|
+
width = min(n_features, 6)
|
|
362
|
+
notes.append(
|
|
363
|
+
f"n_qubits was not given, so it is {width} "
|
|
364
|
+
+ ("one per feature" if width == n_features else f"capped from {n_features}")
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
defaults = {**AXES, "n_qubits": width}
|
|
368
|
+
full = {**{k: [v] for k, v in defaults.items()}, **grid}
|
|
369
|
+
names = list(full)
|
|
370
|
+
points = [dict(zip(names, combo, strict=True)) for combo in itertools.product(*full.values())]
|
|
371
|
+
|
|
372
|
+
rng = np.random.default_rng(seed)
|
|
373
|
+
if max_configs is not None and max_configs < len(points):
|
|
374
|
+
chosen = rng.choice(len(points), size=max_configs, replace=False)
|
|
375
|
+
notes.append(f"sampled {max_configs} of {len(points)} grid points")
|
|
376
|
+
points = [points[i] for i in sorted(chosen.tolist())]
|
|
377
|
+
|
|
378
|
+
# ---- prune before fitting, and say why -------------------------------- #
|
|
379
|
+
if isinstance(prune, str):
|
|
380
|
+
if prune not in _PRUNE_LEVELS:
|
|
381
|
+
from qmlkit.utils.errors import unknown
|
|
382
|
+
|
|
383
|
+
raise unknown("prune level", prune, _PRUNE_LEVELS)
|
|
384
|
+
blocking = _PRUNE_LEVELS[prune]
|
|
385
|
+
else: # an explicit list of finding codes
|
|
386
|
+
blocking = tuple(prune)
|
|
387
|
+
|
|
388
|
+
rows: list[SearchRow] = []
|
|
389
|
+
runnable: list[tuple[dict[str, Any], tuple[str, ...]]] = []
|
|
390
|
+
for config in points:
|
|
391
|
+
codes, reason = _diagnose_config(config, n_classes, blocking)
|
|
392
|
+
if reason:
|
|
393
|
+
rows.append(SearchRow(config, pruned=reason, findings=codes, fitted=False))
|
|
394
|
+
else:
|
|
395
|
+
runnable.append((config, codes))
|
|
396
|
+
|
|
397
|
+
if dry_run:
|
|
398
|
+
notes.append(
|
|
399
|
+
f"dry run: {len(runnable)} configurations x {cv} folds = {len(runnable) * cv} fits"
|
|
400
|
+
+ (f", {len(rows)} pruned before fitting" if rows else "")
|
|
401
|
+
)
|
|
402
|
+
rows.extend(SearchRow(c, findings=codes, fitted=False) for c, codes in runnable)
|
|
403
|
+
return SearchResult(
|
|
404
|
+
resolved,
|
|
405
|
+
metric,
|
|
406
|
+
int(target.size),
|
|
407
|
+
cv,
|
|
408
|
+
varied,
|
|
409
|
+
tuple(rows),
|
|
410
|
+
tuple(notes),
|
|
411
|
+
{"n_classes": n_classes},
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# ---- identical folds for every configuration -------------------------- #
|
|
415
|
+
if resolved == "classification":
|
|
416
|
+
folds = stratified_folds(target, n_folds=cv, seed=seed)
|
|
417
|
+
else:
|
|
418
|
+
order = rng.permutation(data.shape[0])
|
|
419
|
+
folds = [(np.setdiff1d(order, chunk), chunk) for chunk in np.array_split(order, cv)]
|
|
420
|
+
|
|
421
|
+
for index, (config, codes) in enumerate(runnable, start=1):
|
|
422
|
+
started = time.perf_counter()
|
|
423
|
+
try:
|
|
424
|
+
scores = _score_config(config, data, target, folds, resolved, metric, n_classes)
|
|
425
|
+
except Exception as exc: # a broken point must not lose the rest of the table
|
|
426
|
+
rows.append(SearchRow(config, pruned=f"failed: {exc}", findings=codes, fitted=False))
|
|
427
|
+
continue
|
|
428
|
+
row = SearchRow(
|
|
429
|
+
config,
|
|
430
|
+
float(np.mean(scores)),
|
|
431
|
+
float(np.std(scores)),
|
|
432
|
+
tuple(scores),
|
|
433
|
+
seconds=time.perf_counter() - started,
|
|
434
|
+
findings=codes,
|
|
435
|
+
)
|
|
436
|
+
rows.append(row)
|
|
437
|
+
if verbose:
|
|
438
|
+
print(
|
|
439
|
+
f" [{index}/{len(runnable)}] {row.label(varied)}"
|
|
440
|
+
f" {row.mean:.3f} +/- {row.std:.3f} ({row.seconds:.0f}s)",
|
|
441
|
+
flush=True,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if rows and any(not r.ran and "dry run" not in r.pruned for r in rows):
|
|
445
|
+
n_pruned = sum(1 for r in rows if not r.ran)
|
|
446
|
+
notes.append(
|
|
447
|
+
f"{n_pruned} of {len(rows)} configurations were skipped before fitting; "
|
|
448
|
+
"they are listed with their reason rather than ranked last"
|
|
449
|
+
)
|
|
450
|
+
if resolved == "classification":
|
|
451
|
+
from qmlkit.imbalance import imbalance_ratio
|
|
452
|
+
|
|
453
|
+
ratio = imbalance_ratio(target)
|
|
454
|
+
if ratio >= 1.5:
|
|
455
|
+
notes.append(
|
|
456
|
+
f"classes are {ratio:.1f}:1, so the ranking metric is {metric}; "
|
|
457
|
+
"class_weight=['balanced'] is worth putting on the grid"
|
|
458
|
+
)
|
|
459
|
+
return SearchResult(
|
|
460
|
+
resolved,
|
|
461
|
+
metric,
|
|
462
|
+
int(target.size),
|
|
463
|
+
cv,
|
|
464
|
+
varied,
|
|
465
|
+
tuple(rows),
|
|
466
|
+
tuple(notes),
|
|
467
|
+
{"n_classes": n_classes, "folds": folds},
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
#: Named prune levels, as sets of finding *codes* rather than severities.
|
|
472
|
+
#:
|
|
473
|
+
#: Severity alone is the wrong axis here. ``DEAD_WEIGHTS`` and ``UNMEASURABLE_WEIGHTS``
|
|
474
|
+
#: are warnings that mean "wasteful" — a model carrying them still learns, and
|
|
475
|
+
#: ``hardware_efficient`` carries one by construction, so pruning on severity would drop
|
|
476
|
+
#: the library's own default and leave an empty grid. ``FLAT_GRADIENTS`` is also a
|
|
477
|
+
#: warning and means "cannot learn". So the levels name codes.
|
|
478
|
+
_CANNOT_WORK = ("NO_TRAINABLE_PARAMETERS", "ENCODING_COMMUTES", "INPUTS_UNUSED")
|
|
479
|
+
_CANNOT_LEARN = (*_CANNOT_WORK, "FLAT_GRADIENTS", "NO_ENTANGLEMENT")
|
|
480
|
+
_WASTEFUL = (*_CANNOT_LEARN, "DEAD_WEIGHTS", "UNMEASURABLE_WEIGHTS")
|
|
481
|
+
_PRUNE_LEVELS: dict[str, tuple[str, ...]] = {
|
|
482
|
+
"none": (),
|
|
483
|
+
"error": _CANNOT_WORK,
|
|
484
|
+
"untrainable": _CANNOT_LEARN,
|
|
485
|
+
"warning": _WASTEFUL,
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _first_sentence(text: str, limit: int = 96) -> str:
|
|
490
|
+
"""The first sentence, capped — and without splitting a decimal in half.
|
|
491
|
+
|
|
492
|
+
Naively splitting on ``"."`` turns "gradient variance 1.14e-33" into "gradient
|
|
493
|
+
variance 1", so the break has to be a period *followed by a space*.
|
|
494
|
+
"""
|
|
495
|
+
for i in range(len(text) - 1):
|
|
496
|
+
if text[i] == "." and text[i + 1] == " ":
|
|
497
|
+
text = text[: i + 1]
|
|
498
|
+
break
|
|
499
|
+
return text if len(text) <= limit else text[: limit - 3].rstrip() + "..."
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _diagnose_config(
|
|
503
|
+
config: dict[str, Any], n_classes: int, blocking: tuple[str, ...]
|
|
504
|
+
) -> tuple[tuple[str, ...], str]:
|
|
505
|
+
"""``(finding codes, reason to skip)``. Never raises — a build failure is a reason.
|
|
506
|
+
|
|
507
|
+
Every configuration is diagnosed whether or not it is pruned, because a point that
|
|
508
|
+
*scores* well while carrying ``DEAD_WEIGHTS`` is worth seeing as exactly that. Only
|
|
509
|
+
findings at or above ``levels`` stop it being fitted.
|
|
510
|
+
"""
|
|
511
|
+
from qmlkit.diagnostics import diagnose
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
model = _build_model(config, n_classes)
|
|
515
|
+
except Exception as exc: # noqa: BLE001 - the reason is the point
|
|
516
|
+
return (), f"could not be built ({exc})"
|
|
517
|
+
try:
|
|
518
|
+
report = diagnose(model)
|
|
519
|
+
except Exception: # noqa: BLE001 - a diagnosis that fails must not block a fit
|
|
520
|
+
return (), ""
|
|
521
|
+
codes = tuple(f.code for f in report)
|
|
522
|
+
stoppers = [f for f in report if f.code in blocking]
|
|
523
|
+
if not stoppers:
|
|
524
|
+
return codes, ""
|
|
525
|
+
worst = stoppers[0]
|
|
526
|
+
return codes, f"{worst.code}: {_first_sentence(worst.message)}"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _score_config(
|
|
530
|
+
config: dict[str, Any],
|
|
531
|
+
X: Array,
|
|
532
|
+
y: Array,
|
|
533
|
+
folds: list[tuple[Array, Array]],
|
|
534
|
+
task: str,
|
|
535
|
+
metric: str,
|
|
536
|
+
n_classes: int,
|
|
537
|
+
) -> list[float]:
|
|
538
|
+
"""One configuration across the folds. The pipeline is refitted per fold."""
|
|
539
|
+
from qmlkit import evaluate
|
|
540
|
+
from qmlkit.encoding.pipeline import FeaturePipeline
|
|
541
|
+
|
|
542
|
+
n_qubits = int(config["n_qubits"])
|
|
543
|
+
scores = []
|
|
544
|
+
for train, test in folds:
|
|
545
|
+
pipeline = FeaturePipeline(n_qubits=n_qubits).fit(X[train])
|
|
546
|
+
model = _build_model(config, n_classes)
|
|
547
|
+
model.fit(
|
|
548
|
+
pipeline.transform(X[train]),
|
|
549
|
+
y[train],
|
|
550
|
+
epochs=int(config["epochs"]),
|
|
551
|
+
lr=float(config["lr"]),
|
|
552
|
+
batch_size=int(config["batch_size"]),
|
|
553
|
+
)
|
|
554
|
+
predicted = model.predict(pipeline.transform(X[test]))
|
|
555
|
+
got = (
|
|
556
|
+
evaluate.classification(y[test], predicted)
|
|
557
|
+
if task == "classification"
|
|
558
|
+
else evaluate.regression(y[test], predicted)
|
|
559
|
+
)
|
|
560
|
+
scores.append(float(got[metric]))
|
|
561
|
+
return scores
|
qmlkit/shadows.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
r"""Classical shadows — estimate many observables from few measurements.
|
|
2
|
+
|
|
3
|
+
Huang, Kueng & Preskill (2020). Measure in a *randomly chosen* basis each shot, invert
|
|
4
|
+
the resulting depolarising channel, and the collection of snapshots predicts
|
|
5
|
+
:math:`M` observables to fixed accuracy from :math:`O(\log M)` measurements — rather
|
|
6
|
+
than measuring each one separately.
|
|
7
|
+
|
|
8
|
+
On an exact simulator this buys nothing: ``shots=None`` returns every observable
|
|
9
|
+
exactly. It earns its place because **measurement cost is what binds on hardware**,
|
|
10
|
+
and because it makes that cost visible: :func:`shadow_shot_cost` against
|
|
11
|
+
:func:`qmlkit.kernels.kernel_shot_cost` is the comparison worth looking at before
|
|
12
|
+
committing to a device run.
|
|
13
|
+
|
|
14
|
+
shadow = ClassicalShadow(spec, n_snapshots=2000, seed=0)
|
|
15
|
+
shadow.expectation(qk.Z(0) + 0.5 * qk.ZZ(0, 2))
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from qmlkit.core.builder import QCircuit
|
|
25
|
+
from qmlkit.core.execute import BackendLike, run_counts
|
|
26
|
+
from qmlkit.core.ir import CircuitSpec, bound_angle
|
|
27
|
+
from qmlkit.core.observables import Observable, as_sum
|
|
28
|
+
|
|
29
|
+
__all__ = ["ClassicalShadow", "shadow_shot_cost"]
|
|
30
|
+
|
|
31
|
+
#: single-qubit inverse channel: 3 |b><b| - I, in the measured basis
|
|
32
|
+
_BASES = ("X", "Y", "Z")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ClassicalShadow:
|
|
36
|
+
"""A set of randomised single-qubit measurements, and what they predict.
|
|
37
|
+
|
|
38
|
+
Each snapshot picks a random Pauli basis per qubit, measures once, and stores
|
|
39
|
+
``(basis, outcome)``. An observable's estimate then averages over only the
|
|
40
|
+
snapshots whose bases happen to match its support — which is why the cost grows
|
|
41
|
+
with the observable's *locality*, not with how many observables you ask for.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
spec: CircuitSpec,
|
|
47
|
+
n_snapshots: int = 1000,
|
|
48
|
+
seed: int | None = None,
|
|
49
|
+
backend: BackendLike = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
if not spec.is_bound:
|
|
52
|
+
raise ValueError("bind the circuit before taking shadows")
|
|
53
|
+
self.spec = spec
|
|
54
|
+
self.n_qubits = spec.n_qubits
|
|
55
|
+
self.n_snapshots = n_snapshots
|
|
56
|
+
self.backend = backend
|
|
57
|
+
rng = np.random.default_rng(seed)
|
|
58
|
+
#: (n_snapshots, n_qubits) of basis indices, and of +-1 outcomes
|
|
59
|
+
self.bases = rng.integers(0, 3, size=(n_snapshots, self.n_qubits))
|
|
60
|
+
self.outcomes = np.empty((n_snapshots, self.n_qubits), dtype=np.int8)
|
|
61
|
+
self._collect(rng)
|
|
62
|
+
|
|
63
|
+
def _collect(self, rng: np.random.Generator) -> None:
|
|
64
|
+
"""One shot per snapshot, in that snapshot's randomly chosen basis."""
|
|
65
|
+
for index in range(self.n_snapshots):
|
|
66
|
+
builder = QCircuit(self.n_qubits)
|
|
67
|
+
for op in self.spec.ops:
|
|
68
|
+
builder.apply(op.gate, op.qubits, *[bound_angle(p) for p in op.params])
|
|
69
|
+
for qubit in range(self.n_qubits):
|
|
70
|
+
basis = _BASES[self.bases[index, qubit]]
|
|
71
|
+
if basis == "X":
|
|
72
|
+
builder.h(qubit)
|
|
73
|
+
elif basis == "Y":
|
|
74
|
+
builder.sdg(qubit)
|
|
75
|
+
builder.h(qubit)
|
|
76
|
+
counts = run_counts(
|
|
77
|
+
builder.to_spec(), shots=1, seed=int(rng.integers(2**31)), backend=self.backend
|
|
78
|
+
)
|
|
79
|
+
bits = next(iter(counts))
|
|
80
|
+
self.outcomes[index] = [1 if b == "0" else -1 for b in bits]
|
|
81
|
+
|
|
82
|
+
def expectation(self, obs: Observable) -> float:
|
|
83
|
+
"""Estimate ``<O>`` from the stored snapshots."""
|
|
84
|
+
total = 0.0
|
|
85
|
+
for term in as_sum(obs).terms:
|
|
86
|
+
total += float(term.coeff.real) * self._term_estimate(term)
|
|
87
|
+
return total
|
|
88
|
+
|
|
89
|
+
def _term_estimate(self, term: Any) -> float:
|
|
90
|
+
support = [(q, p) for q, p in term.paulis if p != "I"]
|
|
91
|
+
if not support:
|
|
92
|
+
return 1.0
|
|
93
|
+
wanted = np.array([_BASES.index(p) for _, p in support])
|
|
94
|
+
wires = np.array([q for q, _ in support])
|
|
95
|
+
|
|
96
|
+
# Only snapshots that happened to measure every wire in the right basis carry
|
|
97
|
+
# information; the inverse channel contributes a factor of 3 per matched wire.
|
|
98
|
+
matched = np.all(self.bases[:, wires] == wanted, axis=1)
|
|
99
|
+
if not matched.any():
|
|
100
|
+
return 0.0
|
|
101
|
+
products = np.prod(self.outcomes[np.ix_(matched, wires)], axis=1)
|
|
102
|
+
return float(3 ** len(support) * products.sum() / self.n_snapshots)
|
|
103
|
+
|
|
104
|
+
def __repr__(self) -> str:
|
|
105
|
+
return f"ClassicalShadow(n_qubits={self.n_qubits}, snapshots={self.n_snapshots})"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def shadow_shot_cost(locality: int, n_observables: int, epsilon: float = 0.1) -> int:
|
|
109
|
+
r"""Snapshots for :math:`M` observables of given locality to accuracy ``epsilon``.
|
|
110
|
+
|
|
111
|
+
The headline scaling: :math:`O(3^k \log M / \epsilon^2)` — **logarithmic** in how
|
|
112
|
+
many observables you want, exponential only in their locality. Measuring each one
|
|
113
|
+
separately is instead linear in ``M``, which is the trade this whole method makes.
|
|
114
|
+
"""
|
|
115
|
+
if locality < 1 or n_observables < 1:
|
|
116
|
+
raise ValueError("locality and n_observables must be at least 1")
|
|
117
|
+
return int(np.ceil(3**locality * np.log(2 * n_observables) / epsilon**2))
|
qmlkit/utils/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Utilities."""
|
|
2
|
+
|
|
3
|
+
from qmlkit.utils.shots import (
|
|
4
|
+
p0_from_z,
|
|
5
|
+
runtime_estimate,
|
|
6
|
+
shots_for_precision,
|
|
7
|
+
standard_error,
|
|
8
|
+
variance,
|
|
9
|
+
z_from_p0,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"standard_error",
|
|
14
|
+
"variance",
|
|
15
|
+
"shots_for_precision",
|
|
16
|
+
"p0_from_z",
|
|
17
|
+
"z_from_p0",
|
|
18
|
+
"runtime_estimate",
|
|
19
|
+
]
|