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
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))
@@ -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
+ ]