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