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/baselines.py ADDED
@@ -0,0 +1,604 @@
1
+ """The classical bar, computed on the same data, the same folds, the same metric.
2
+
3
+ The question every quantum machine learning result is asked first is "compared to
4
+ what?", and the honest answer is usually "an RBF-kernel SVM nobody ran". Not out of
5
+ bad faith — running it means a second pipeline, a second preprocessing path, a
6
+ second splitting convention, and by the time all three match, the comparison is a
7
+ day's work that adds nothing to the paper if it goes the expected way.
8
+
9
+ So it goes unrun, and the reviewer asks anyway.
10
+
11
+ This module makes it one call::
12
+
13
+ >>> import qmlkit as qk
14
+ >>> X, y = qk.datasets.moons(n_samples=60, seed=0)
15
+ >>> table = qk.baseline(X, y, cv=3, seed=0) # doctest: +SKIP
16
+ >>> print(table) # doctest: +SKIP
17
+ classification · balanced_accuracy · 3-fold stratified · n=60
18
+ rbf-kernel-ridge 0.883 +/- 0.042
19
+ nearest-centroid 0.850 +/- 0.038
20
+ majority 0.500 +/- 0.000
21
+ the bar to beat is rbf-kernel-ridge at 0.883
22
+
23
+ Pass ``model=`` and the model is fitted on the identical folds and lands in the
24
+ same table, with a verdict line that says plainly whether it cleared the bar.
25
+
26
+ **The baselines that need no scikit-learn always run.** Kernel ridge with an RBF
27
+ kernel is a closed-form solve, and it is the right classical foil for a quantum
28
+ kernel method specifically because it is the same algorithm with a different
29
+ kernel. Where scikit-learn is installed, its estimators are added; where it is
30
+ not, they are listed as skipped rather than silently dropped, because a table that
31
+ quietly omits the strong baseline is the problem this module exists to fix.
32
+
33
+ The companion check for kernel methods is
34
+ :func:`~qmlkit.kernels.matrix.geometric_difference`, which asks whether the quantum
35
+ kernel induces a geometry the classical one cannot reach at all. A large geometric
36
+ difference with no accuracy gain is a real and publishable finding; a small one
37
+ says the classical kernel was always going to be enough.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import copy
43
+ from collections.abc import Callable, Sequence
44
+ from dataclasses import dataclass, field
45
+ from typing import Any
46
+
47
+ import numpy as np
48
+ import numpy.typing as npt
49
+
50
+ from qmlkit import evaluate
51
+ from qmlkit.imbalance import stratified_folds
52
+
53
+ __all__ = [
54
+ "BaselineSpec",
55
+ "BaselineRow",
56
+ "BaselineTable",
57
+ "baseline",
58
+ "register_baseline",
59
+ "list_baselines",
60
+ "get_baseline",
61
+ ]
62
+
63
+ Array = npt.NDArray[Any]
64
+
65
+
66
+ # --------------------------------------------------------------------------- #
67
+ # the estimators that need nothing but NumPy
68
+ # --------------------------------------------------------------------------- #
69
+ class _Estimator:
70
+ """The two methods a baseline needs. Deliberately not scikit-learn's protocol."""
71
+
72
+ def fit(self, X: Array, y: Array) -> _Estimator: # pragma: no cover - overridden
73
+ raise NotImplementedError
74
+
75
+ def predict(self, X: Array) -> Array: # pragma: no cover - overridden
76
+ raise NotImplementedError
77
+
78
+
79
+ class MajorityClassifier(_Estimator):
80
+ """Always predicts the most frequent training label.
81
+
82
+ The floor. A model that does not clear this has learned nothing at all, and on
83
+ a skewed dataset it clears 90% accuracy while doing so.
84
+ """
85
+
86
+ def fit(self, X: Array, y: Array) -> MajorityClassifier:
87
+ labels, counts = np.unique(y, return_counts=True)
88
+ self.label_ = labels[int(np.argmax(counts))]
89
+ return self
90
+
91
+ def predict(self, X: Array) -> Array:
92
+ return np.full(np.atleast_2d(X).shape[0], self.label_)
93
+
94
+
95
+ class NearestCentroid(_Estimator):
96
+ """Assign each point to the nearest class mean. No solver, no hyperparameter."""
97
+
98
+ def fit(self, X: Array, y: Array) -> NearestCentroid:
99
+ self.labels_ = np.unique(y)
100
+ self.centroids_ = np.stack([np.atleast_2d(X)[y == c].mean(axis=0) for c in self.labels_])
101
+ return self
102
+
103
+ def predict(self, X: Array) -> Array:
104
+ d = np.linalg.norm(np.atleast_2d(X)[:, None, :] - self.centroids_[None, :, :], axis=2)
105
+ out: Array = self.labels_[np.argmin(d, axis=1)]
106
+ return out
107
+
108
+
109
+ def _rbf_gamma(X: Array, gamma: float | str = "scale") -> float:
110
+ """scikit-learn's ``gamma='scale'``: ``1 / (n_features * X.var())``.
111
+
112
+ Matching their convention is deliberate — it makes this baseline and their
113
+ ``SVC(kernel='rbf')`` comparable rather than two different models sharing a
114
+ name.
115
+ """
116
+ if isinstance(gamma, str):
117
+ var = float(np.asarray(X, dtype=float).var())
118
+ return 1.0 / (X.shape[1] * var) if var > 0 else 1.0
119
+ return float(gamma)
120
+
121
+
122
+ def _rbf(A: Array, B: Array, gamma: float) -> Array:
123
+ sq = (
124
+ np.einsum("ij,ij->i", A, A)[:, None]
125
+ + np.einsum("ij,ij->i", B, B)[None, :]
126
+ - 2.0 * (A @ B.T)
127
+ )
128
+ out: Array = np.exp(-gamma * np.maximum(sq, 0.0))
129
+ return out
130
+
131
+
132
+ class RBFKernelRidge(_Estimator):
133
+ """Kernel ridge regression with an RBF kernel; one-hot targets for labels.
134
+
135
+ Closed form: ``alpha = (K + lambda I)^-1 Y``. For a quantum *kernel* method
136
+ this is the honest foil — the identical algorithm with the identical solver,
137
+ differing only in which kernel fills the Gram matrix. Any gap between them is
138
+ attributable to the kernel and to nothing else.
139
+ """
140
+
141
+ def __init__(self, alpha: float = 1.0, gamma: float | str = "scale", classify: bool = True):
142
+ self.alpha = alpha
143
+ self.gamma = gamma
144
+ self.classify = classify
145
+
146
+ def fit(self, X: Array, y: Array) -> RBFKernelRidge:
147
+ self.X_ = np.atleast_2d(np.asarray(X, dtype=float))
148
+ self.gamma_ = _rbf_gamma(self.X_, self.gamma)
149
+ K = _rbf(self.X_, self.X_, self.gamma_)
150
+ if self.classify:
151
+ self.labels_ = np.unique(y)
152
+ target = (np.asarray(y).ravel()[:, None] == self.labels_[None, :]).astype(float)
153
+ else:
154
+ target = np.asarray(y, dtype=float).reshape(-1, 1)
155
+ self.dual_ = np.linalg.solve(K + self.alpha * np.eye(K.shape[0]), target)
156
+ return self
157
+
158
+ def predict(self, X: Array) -> Array:
159
+ scores = _rbf(np.atleast_2d(np.asarray(X, dtype=float)), self.X_, self.gamma_) @ self.dual_
160
+ return self.labels_[np.argmax(scores, axis=1)] if self.classify else scores[:, 0]
161
+
162
+
163
+ class MeanRegressor(_Estimator):
164
+ """Predicts the training mean. R2 is 0.0 here by construction — the floor."""
165
+
166
+ def fit(self, X: Array, y: Array) -> MeanRegressor:
167
+ self.value_ = float(np.asarray(y, dtype=float).mean())
168
+ return self
169
+
170
+ def predict(self, X: Array) -> Array:
171
+ return np.full(np.atleast_2d(X).shape[0], self.value_)
172
+
173
+
174
+ class LinearLeastSquares(_Estimator):
175
+ """Ridge-stabilised least squares with an intercept. The linear-model floor."""
176
+
177
+ def __init__(self, alpha: float = 1e-6):
178
+ self.alpha = alpha
179
+
180
+ def fit(self, X: Array, y: Array) -> LinearLeastSquares:
181
+ A = np.hstack([np.atleast_2d(np.asarray(X, dtype=float)), np.ones((len(X), 1))])
182
+ gram = A.T @ A + self.alpha * np.eye(A.shape[1])
183
+ self.coef_ = np.linalg.solve(gram, A.T @ np.asarray(y, dtype=float).ravel())
184
+ return self
185
+
186
+ def predict(self, X: Array) -> Array:
187
+ A = np.hstack([np.atleast_2d(np.asarray(X, dtype=float)), np.ones((len(X), 1))])
188
+ return A @ self.coef_
189
+
190
+
191
+ # --------------------------------------------------------------------------- #
192
+ # the registry
193
+ # --------------------------------------------------------------------------- #
194
+ @dataclass(frozen=True)
195
+ class BaselineSpec:
196
+ """One classical model that can stand next to a quantum one."""
197
+
198
+ name: str
199
+ task: str
200
+ factory: Callable[[], Any]
201
+ requires: str | None = None
202
+ note: str = ""
203
+
204
+
205
+ _BASELINES: dict[str, BaselineSpec] = {}
206
+
207
+
208
+ def register_baseline(
209
+ name: str,
210
+ task: str,
211
+ factory: Callable[[], Any],
212
+ requires: str | None = None,
213
+ note: str = "",
214
+ ) -> None:
215
+ """Add a baseline, so it appears in every table for that task from now on.
216
+
217
+ ``requires`` names an importable module; when it is missing the baseline is
218
+ reported as skipped rather than dropped. ``factory`` must return a fresh,
219
+ unfitted estimator with ``fit`` and ``predict`` — it is called once per fold.
220
+ """
221
+ if task not in ("classification", "regression"):
222
+ from qmlkit.utils.errors import unknown
223
+
224
+ raise unknown("baseline task", task, ("classification", "regression"))
225
+ # keyed by task and name, so `rbf-kernel-ridge` can exist for both tasks
226
+ _BASELINES[f"{task}/{name}"] = BaselineSpec(name, task, factory, requires, note)
227
+
228
+
229
+ def list_baselines(task: str | None = None) -> tuple[str, ...]:
230
+ """Registered baseline names, optionally filtered to one task."""
231
+ return tuple(sorted(s.name for s in _BASELINES.values() if task is None or s.task == task))
232
+
233
+
234
+ def get_baseline(name: str, task: str = "classification") -> BaselineSpec:
235
+ """One registered baseline. Names are unique within a task, not across tasks."""
236
+ key = f"{task}/{name}"
237
+ if key not in _BASELINES:
238
+ from qmlkit.utils.errors import unknown
239
+
240
+ raise unknown("baseline", name, list_baselines(task), error=KeyError)
241
+ return _BASELINES[key]
242
+
243
+
244
+ def _sklearn(path: str, **kwargs: Any) -> Callable[[], Any]:
245
+ """A factory that imports scikit-learn only when the baseline actually runs."""
246
+
247
+ def make() -> Any:
248
+ module_name, class_name = path.rsplit(".", 1)
249
+ module = __import__(module_name, fromlist=[class_name])
250
+ return getattr(module, class_name)(**kwargs)
251
+
252
+ return make
253
+
254
+
255
+ # The floor, the classical kernel, then the models a reviewer will name. Ordered
256
+ # the way the table should be read: what beating nothing looks like, what the same
257
+ # algorithm with a *classical* kernel achieves, and what the field would reach for.
258
+ register_baseline("majority", "classification", MajorityClassifier)
259
+ register_baseline("nearest-centroid", "classification", NearestCentroid)
260
+ register_baseline("rbf-kernel-ridge", "classification", RBFKernelRidge)
261
+ register_baseline(
262
+ "svc-rbf", "classification", _sklearn("sklearn.svm.SVC", kernel="rbf"), requires="sklearn"
263
+ )
264
+ register_baseline(
265
+ "svc-linear", "classification", _sklearn("sklearn.svm.SVC", kernel="linear"), requires="sklearn"
266
+ )
267
+ register_baseline(
268
+ "logistic",
269
+ "classification",
270
+ _sklearn("sklearn.linear_model.LogisticRegression", max_iter=1000),
271
+ requires="sklearn",
272
+ )
273
+ register_baseline(
274
+ "mlp",
275
+ "classification",
276
+ _sklearn(
277
+ "sklearn.neural_network.MLPClassifier",
278
+ hidden_layer_sizes=(32, 16),
279
+ max_iter=2000,
280
+ random_state=0,
281
+ ),
282
+ requires="sklearn",
283
+ )
284
+ register_baseline(
285
+ "random-forest",
286
+ "classification",
287
+ _sklearn("sklearn.ensemble.RandomForestClassifier", n_estimators=100, random_state=0),
288
+ requires="sklearn",
289
+ )
290
+
291
+ register_baseline("mean", "regression", MeanRegressor)
292
+ register_baseline("linear", "regression", LinearLeastSquares)
293
+ register_baseline("rbf-kernel-ridge", "regression", lambda: RBFKernelRidge(classify=False))
294
+ register_baseline(
295
+ "svr-rbf", "regression", _sklearn("sklearn.svm.SVR", kernel="rbf"), requires="sklearn"
296
+ )
297
+ register_baseline(
298
+ "mlp-regressor",
299
+ "regression",
300
+ _sklearn(
301
+ "sklearn.neural_network.MLPRegressor",
302
+ hidden_layer_sizes=(32, 16),
303
+ max_iter=2000,
304
+ random_state=0,
305
+ ),
306
+ requires="sklearn",
307
+ )
308
+
309
+
310
+ def _available(requires: str | None) -> bool:
311
+ if requires is None:
312
+ return True
313
+ import importlib.util
314
+
315
+ return importlib.util.find_spec(requires) is not None
316
+
317
+
318
+ # --------------------------------------------------------------------------- #
319
+ # the table
320
+ # --------------------------------------------------------------------------- #
321
+ @dataclass(frozen=True)
322
+ class BaselineRow:
323
+ """One model's score across the folds."""
324
+
325
+ name: str
326
+ mean: float
327
+ std: float
328
+ fold_scores: tuple[float, ...] = ()
329
+ is_model: bool = False
330
+ skipped: str = ""
331
+
332
+ @property
333
+ def ran(self) -> bool:
334
+ return not self.skipped
335
+
336
+
337
+ @dataclass(frozen=True)
338
+ class BaselineTable:
339
+ """Every model on the same folds, sorted best first.
340
+
341
+ ``verdict`` is the sentence to quote: whether the model under test cleared the
342
+ strongest classical baseline, and by how much relative to the fold-to-fold
343
+ spread — a gap smaller than the noise is not a gap.
344
+ """
345
+
346
+ task: str
347
+ metric: str
348
+ n_samples: int
349
+ n_folds: int
350
+ rows: tuple[BaselineRow, ...] = ()
351
+ notes: tuple[str, ...] = ()
352
+ extras: dict[str, Any] = field(default_factory=dict)
353
+
354
+ @property
355
+ def ran(self) -> tuple[BaselineRow, ...]:
356
+ return tuple(r for r in self.rows if r.ran)
357
+
358
+ @property
359
+ def model_row(self) -> BaselineRow | None:
360
+ return next((r for r in self.rows if r.is_model), None)
361
+
362
+ @property
363
+ def best_classical(self) -> BaselineRow | None:
364
+ classical = [r for r in self.ran if not r.is_model]
365
+ return max(classical, key=lambda r: r.mean) if classical else None
366
+
367
+ @property
368
+ def beats_classical(self) -> bool | None:
369
+ """``True`` only if the model's mean clears the best classical mean.
370
+
371
+ ``None`` when no model was passed. Says nothing about significance — read
372
+ :attr:`verdict` for that.
373
+ """
374
+ model, best = self.model_row, self.best_classical
375
+ if model is None or best is None or not model.ran:
376
+ return None
377
+ return bool(model.mean > best.mean)
378
+
379
+ @property
380
+ def verdict(self) -> str:
381
+ model, best = self.model_row, self.best_classical
382
+ if best is None:
383
+ return "no classical baseline could run"
384
+ if model is None or not model.ran:
385
+ return f"the bar to beat is {best.name} at {best.mean:.3f}"
386
+ gap = model.mean - best.mean
387
+ spread = float(np.hypot(model.std, best.std))
388
+ if gap <= 0:
389
+ return (
390
+ f"{model.name} ({model.mean:.3f}) does NOT beat {best.name} "
391
+ f"({best.mean:.3f}): behind by {abs(gap):.3f}"
392
+ )
393
+ if spread > 0 and gap < spread:
394
+ return (
395
+ f"{model.name} ({model.mean:.3f}) leads {best.name} ({best.mean:.3f}) by "
396
+ f"{gap:.3f}, which is inside the fold-to-fold spread ({spread:.3f}) — "
397
+ "not yet a result"
398
+ )
399
+ return (
400
+ f"{model.name} ({model.mean:.3f}) beats {best.name} ({best.mean:.3f}) by "
401
+ f"{gap:.3f}, outside the fold spread ({spread:.3f})"
402
+ )
403
+
404
+ def __str__(self) -> str:
405
+ width = max((len(r.name) for r in self.rows), default=0)
406
+ # ASCII only: this prints to a Windows console as often as to a notebook
407
+ split = "stratified" if self.task == "classification" else "shuffled"
408
+ lines = [
409
+ f"{self.task} | {self.metric} | {self.n_folds}-fold {split} | n={self.n_samples}"
410
+ ]
411
+ for row in sorted(self.rows, key=lambda r: (r.skipped != "", -r.mean)):
412
+ mark = "*" if row.is_model else " "
413
+ if row.ran:
414
+ lines.append(f" {mark} {row.name:<{width}} {row.mean: .3f} +/- {row.std:.3f}")
415
+ else:
416
+ lines.append(f" {mark} {row.name:<{width}} skipped: {row.skipped}")
417
+ lines.append(f"\n{self.verdict}")
418
+ lines.extend(f"note: {note}" for note in self.notes)
419
+ return "\n".join(lines)
420
+
421
+
422
+ def _infer_task(y: Array) -> str:
423
+ """Classification unless the targets are clearly continuous.
424
+
425
+ Float targets with more than a handful of distinct values are a regression;
426
+ everything else is treated as labels. Getting this wrong is loud rather than
427
+ silent — a regression scored as classification produces one class per sample.
428
+ """
429
+ arr = np.asarray(y).ravel()
430
+ if arr.dtype.kind in "US" or arr.dtype == object:
431
+ return "classification"
432
+ distinct = np.unique(arr).size
433
+ if arr.dtype.kind in "iub" and distinct <= max(20, int(np.sqrt(arr.size))):
434
+ return "classification"
435
+ if arr.dtype.kind == "f" and distinct <= 20 and np.allclose(arr, np.round(arr)):
436
+ return "classification"
437
+ return "regression"
438
+
439
+
440
+ def _fit_predict(estimator: Any, X_train: Array, y_train: Array, X_test: Array) -> Array:
441
+ fitted = estimator.fit(X_train, y_train)
442
+ return np.asarray((fitted if fitted is not None else estimator).predict(X_test)).ravel()
443
+
444
+
445
+ def _score(task: str, y_true: Array, y_pred: Array, metric: str) -> float:
446
+ scores = (
447
+ evaluate.classification(y_true, y_pred)
448
+ if task == "classification"
449
+ else (evaluate.regression(y_true, y_pred))
450
+ )
451
+ return float(scores[metric])
452
+
453
+
454
+ def baseline(
455
+ X: Any,
456
+ y: Any,
457
+ model: Any = None,
458
+ task: str = "auto",
459
+ cv: int = 5,
460
+ metric: str | None = None,
461
+ seed: int | None = 0,
462
+ include: Sequence[str] | None = None,
463
+ max_samples: int | None = None,
464
+ fit_kwargs: dict[str, Any] | None = None,
465
+ ) -> BaselineTable:
466
+ """Score every classical baseline — and optionally ``model`` — on shared folds.
467
+
468
+ Parameters
469
+ ----------
470
+ X, y:
471
+ The data. Folds are stratified for classification, contiguous-shuffled for
472
+ regression, and identical for every row of the table.
473
+ model:
474
+ Optional. Either an unfitted estimator with ``fit``/``predict`` (it is deep
475
+ copied per fold, so the same object can be reused), or a zero-argument
476
+ callable returning a fresh one — which is the safer form for torch models,
477
+ whose parameters would otherwise carry over.
478
+ task:
479
+ ``"classification"``, ``"regression"``, or ``"auto"`` to infer from ``y``.
480
+ cv:
481
+ Number of folds.
482
+ metric:
483
+ Which metric decides the ranking. Defaults to the primary metric for the
484
+ task, which is imbalance-aware for classification.
485
+ include:
486
+ Restrict to these baseline names. The default runs every registered one.
487
+ max_samples:
488
+ Subsample before running. A quantum model refitted on five folds of a
489
+ thousand points is an overnight job; capping it makes the comparison
490
+ something that gets run at all. The cap is recorded in the notes.
491
+
492
+ Notes
493
+ -----
494
+ Every fold sees identical indices for every model, so the differences reported
495
+ are differences between models rather than between splits.
496
+ """
497
+ data = np.atleast_2d(np.asarray(X, dtype=float))
498
+ target: npt.NDArray[Any] = np.asarray(y).ravel()
499
+ if data.shape[0] != target.size:
500
+ raise ValueError(f"X has {data.shape[0]} rows but y has {target.size}")
501
+ resolved = _infer_task(target) if task == "auto" else task
502
+ if resolved not in ("classification", "regression"):
503
+ from qmlkit.utils.errors import unknown
504
+
505
+ raise unknown("task", resolved, ("classification", "regression", "auto"))
506
+
507
+ notes: list[str] = []
508
+ rng = np.random.default_rng(seed)
509
+ if max_samples is not None and data.shape[0] > max_samples:
510
+ keep = rng.choice(data.shape[0], size=max_samples, replace=False)
511
+ data, target = data[keep], target[keep]
512
+ notes.append(f"subsampled to {max_samples} of {len(y)} rows before scoring")
513
+
514
+ metric = metric or ("balanced_accuracy" if resolved == "classification" else "r2")
515
+ if resolved == "classification":
516
+ folds = stratified_folds(target, n_folds=cv, seed=seed)
517
+ else:
518
+ order = rng.permutation(data.shape[0])
519
+ chunks = np.array_split(order, cv)
520
+ folds = [(np.setdiff1d(order, chunk), chunk) for chunk in chunks]
521
+
522
+ specs = [s for s in _BASELINES.values() if s.task == resolved]
523
+ if include is not None:
524
+ wanted = set(include)
525
+ missing = wanted - {s.name for s in specs}
526
+ if missing:
527
+ from qmlkit.utils.errors import unknown
528
+
529
+ raise unknown(f"{resolved} baseline", sorted(missing)[0], [s.name for s in specs])
530
+ specs = [s for s in specs if s.name in wanted]
531
+
532
+ rows: list[BaselineRow] = []
533
+ for spec in specs:
534
+ if not _available(spec.requires):
535
+ rows.append(
536
+ BaselineRow(
537
+ spec.name,
538
+ float("nan"),
539
+ float("nan"),
540
+ skipped=f"needs {spec.requires} (pip install 'qmlkit[{spec.requires}]')",
541
+ )
542
+ )
543
+ continue
544
+ scores = [
545
+ _score(
546
+ resolved,
547
+ target[test],
548
+ _fit_predict(spec.factory(), data[train], target[train], data[test]),
549
+ metric,
550
+ )
551
+ for train, test in folds
552
+ ]
553
+ rows.append(
554
+ BaselineRow(spec.name, float(np.mean(scores)), float(np.std(scores)), tuple(scores))
555
+ )
556
+
557
+ if model is not None:
558
+ name = getattr(model, "__name__", type(model).__name__)
559
+ try:
560
+ scores = [
561
+ _score(
562
+ resolved,
563
+ target[test],
564
+ _fit_predict(
565
+ model()
566
+ if callable(model) and not hasattr(model, "fit")
567
+ else copy.deepcopy(model),
568
+ data[train],
569
+ target[train],
570
+ data[test],
571
+ ),
572
+ metric,
573
+ )
574
+ for train, test in folds
575
+ ]
576
+ except Exception as exc: # the table is still worth having without the model
577
+ rows.append(
578
+ BaselineRow(name, float("nan"), float("nan"), is_model=True, skipped=str(exc))
579
+ )
580
+ else:
581
+ rows.append(
582
+ BaselineRow(
583
+ name,
584
+ float(np.mean(scores)),
585
+ float(np.std(scores)),
586
+ tuple(scores),
587
+ is_model=True,
588
+ )
589
+ )
590
+ if fit_kwargs:
591
+ notes.append("fit_kwargs are ignored by estimators that do not accept them")
592
+
593
+ if resolved == "classification":
594
+ from qmlkit.imbalance import imbalance_ratio
595
+
596
+ ratio = imbalance_ratio(target)
597
+ if ratio >= 1.5:
598
+ notes.append(
599
+ f"classes are {ratio:.1f}:1, so the ranking metric is {metric} rather than "
600
+ "accuracy; qk.imbalance.imbalance_report(y) lists the remedies"
601
+ )
602
+ return BaselineTable(
603
+ resolved, metric, int(target.size), cv, tuple(rows), tuple(notes), {"folds": folds}
604
+ )