Evaluation¶
Scoring predictions, handling skewed classes, comparing against classical baselines, costing a run before it starts, and recording what produced a number.
The guide is Evaluating a quantum model honestly.
qmlkit.evaluate¶
evaluate ¶
Every score a task needs, in one call — and a note when a score is lying.
:mod:qmlkit.metrics measures circuits: expressibility, entanglement, how flat
the gradients are. This module measures predictions, which is a different
question with a different failure mode.
The failure mode is that a single number is easy to report and easy to be misled by. Accuracy on a 95/5 split is 0.95 for a model that has learned to say "no", and that model looks better than one which actually separates the classes at 0.91.
So each task returns all of its metrics at once, and the ones that disagree with each other stay visible::
>>> import numpy as np, qmlkit as qk
>>> y = np.array([0] * 95 + [1] * 5)
>>> scores = qk.evaluate.classification(y, np.zeros(100, dtype=int))
>>> round(scores["accuracy"], 3), round(scores["balanced_accuracy"], 3)
(0.95, 0.5)
>>> bool(scores.notes)
True
A :class:Scores object indexes like a dict, iterates like one, and prints as a
table. Nothing here needs scikit-learn; everything here is cross-checked against
scikit-learn in the test suite, the same way the library is cross-checked against
PennyLane.
Four tasks are covered: :func:classification, :func:regression,
:func:clustering and :func:generative.
Scores
dataclass
¶
Scores(
task: str,
values: dict[str, float],
primary: str,
n_samples: int,
notes: tuple[str, ...] = (),
extras: dict[str, Any] = dict(),
)
The metrics for one task, plus what they mean together.
Indexes and iterates like a mapping, so scores["f1_macro"] and
dict(scores) both work. primary is the single number to quote when a
single number is unavoidable — chosen to be the one that survives imbalance.
get ¶
The metric, or default.
An explicit default is honoured without comment - the caller has said what
they want when the key is absent. Without one, a key that nearly matches a
real metric is answered the way scores[key] answers it, rather than with
None: get("precision") on a classification report is a reach for
precision_macro, and returning None there turns a typo into a
TypeError raised much later from inside numpy.
Source code in src/qmlkit/evaluate.py
confusion_matrix ¶
confusion_matrix(
y_true: Any,
y_pred: Any,
labels: Sequence[Any] | None = None,
) -> tuple[Array, Array]
(matrix, labels) with rows the truth and columns the prediction.
Source code in src/qmlkit/evaluate.py
roc_auc ¶
Binary ROC AUC by the rank identity, so ties are handled exactly.
AUC = (sum of positive ranks - n_pos(n_pos+1)/2) / (n_pos * n_neg) with
average ranks over tied scores — identical to integrating the ROC curve with
the trapezoid rule, and cheaper.
Source code in src/qmlkit/evaluate.py
average_precision ¶
Area under the precision-recall curve, sum (R_n - R_{n-1}) P_n.
The right summary when the positive class is rare: unlike ROC AUC, it does not flatter a model for correctly rejecting an abundant negative class.
Source code in src/qmlkit/evaluate.py
classification ¶
classification(
y_true: Any,
y_pred: Any,
y_score: Any | None = None,
labels: Sequence[Any] | None = None,
) -> Scores
Every classification metric worth reporting, and a note when one misleads.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y_true
|
Any
|
Labels. Any hashable label type; they are matched by value. |
required |
y_pred
|
Any
|
Labels. Any hashable label type; they are matched by value. |
required |
y_score
|
Any | None
|
Optional. Either an |
None
|
labels
|
Sequence[Any] | None
|
Optional fixed class order, for when a fold is missing a class. |
None
|
Notes
The primary metric is balanced_accuracy on imbalanced data and accuracy
otherwise, because quoting accuracy on a skewed problem is the most common way
a quantum classifier gets reported as working when it is not.
Source code in src/qmlkit/evaluate.py
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 | |
selective ¶
selective(
y_true: Any,
y_pred: Any,
abstain: Any = None,
labels: Sequence[Any] | None = None,
) -> Scores
Metrics for a classifier that is allowed to decline.
An abstaining classifier answers some samples and refuses the rest. Its accuracy is therefore measured on a subset it chose, and comparing that number against a classifier which answered everything is not a comparison at all — it is the single most effective way to make a weak model look strong, because raising the abstention threshold raises accuracy monotonically until one sample is left.
So this reports both halves and refuses to let the first be quoted alone:
coverage
Fraction of samples answered.
selective_accuracy
Accuracy among the answered — the number that is not comparable across
different coverages.
selective_risk
1 - selective_accuracy.
accuracy
Accuracy over everything, counting an abstention as wrong. Pessimistic, and
the one number that is directly comparable to a model with no reject option.
full_coverage_equivalent
The accuracy a model answering everything would need in order to match this
one's selective risk on the covered part. Quote this when comparing against a
baseline that cannot abstain.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y_true
|
Any
|
Labels, matched by value. |
required |
y_pred
|
Any
|
Labels, matched by value. |
required |
abstain
|
Any
|
The value in |
None
|
labels
|
Sequence[Any] | None
|
Optional fixed class order, for when a fold is missing a class. |
None
|
Examples:
Source code in src/qmlkit/evaluate.py
423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 | |
risk_coverage ¶
The accuracy an abstaining classifier reaches at every coverage it could pick.
A single (coverage, accuracy) pair says nothing on its own, because the
threshold that produced it was chosen. Sweeping the threshold shows the whole
trade and makes two models comparable at equal coverage.
aurc — area under the risk-coverage curve — summarises it in one number that
is not gameable by abstaining more: lower is better, and a model that abstains
its way to a high selective accuracy pays for it in the low-coverage region.
Returns coverage, risk and threshold arrays plus aurc.
Source code in src/qmlkit/evaluate.py
regression ¶
regression(y_true: Any, y_pred: Any) -> Scores
Every regression metric worth reporting, with R2 as the primary.
Source code in src/qmlkit/evaluate.py
clustering ¶
clustering(
X: Any, labels: Any, y_true: Any | None = None
) -> Scores
Cluster quality — internal always, external when y_true is given.
Internal metrics (silhouette, Davies-Bouldin) need no ground truth and say whether the clusters are separated at all. External ones (ARI, NMI, purity) say whether they are the clusters you were looking for; the two routinely disagree, which is the useful part.
Source code in src/qmlkit/evaluate.py
generative ¶
generative(p_model: Any, q_target: Any) -> Scores
How far a generated distribution is from the one it was fitted to.
p_model is the model's distribution, q_target the data's. Both may be
{bitstring: count} dicts or dense arrays; counts are normalised.
Divergences are in nats. Total variation is the primary because it is a
metric, is bounded in [0, 1], and stays finite when the model puts zero
mass where the target has some — the case in which KL is inf and stops
being a training signal at all.
Source code in src/qmlkit/evaluate.py
scores_for ¶
scores_for(task: str, *args: Any, **kwargs: Any) -> Scores
Call one of the four by name — for code that is generic over the task.
Source code in src/qmlkit/evaluate.py
qmlkit.imbalance¶
imbalance ¶
Skewed classes: measuring the skew, weighting the loss, and splitting safely.
Quantum classifiers are usually trained on small datasets, because every sample costs circuits. Small and skewed is the common case, and it breaks three things at once:
- The loss. Cross-entropy on a 95/5 split is minimised by ignoring the minority class. The model converges, the loss curve looks healthy, and the circuit has learned a constant.
- The split. A random 80/20 split of 100 samples with 5 positives puts one positive in test on average, and none at all about a third of the time. The test score is then noise.
- The score. Accuracy rewards the constant model. :mod:
qmlkit.evaluatehandles that end; this module handles the first two.
Everything here is NumPy and returns plain arrays or indices, so it composes with scikit-learn, with torch, and with neither::
>>> import numpy as np, qmlkit as qk
>>> y = np.array([0] * 90 + [1] * 10)
>>> qk.imbalance.pos_weight(y)
9.0
>>> train, test = qk.imbalance.stratified_split(y, test_size=0.2, seed=0)
>>> int(y[test].sum()) # two positives, not "on average two"
2
The torch losses that consume these weights live in :mod:qmlkit.nn.losses, so
importing this module never requires torch.
class_counts ¶
{label: count}, in sorted label order.
Source code in src/qmlkit/imbalance.py
imbalance_ratio ¶
Majority count over minority count. 1.0 is perfectly balanced.
class_weights ¶
{label: weight} for a weighted loss.
"balanced" gives n / (n_classes * count) — scikit-learn's convention,
so the weights are interchangeable with class_weight="balanced" there. The
weights average to 1 over the data, which keeps the loss on the same scale as
the unweighted one and means the learning rate does not need retuning.
"inverse" gives 1 / count normalised to a mean of 1, which is more
aggressive on severe skew. "none" gives 1 everywhere, so a caller can pass
the scheme through without branching.
Source code in src/qmlkit/imbalance.py
sample_weights ¶
Per-sample weights, one per row of y — the array form of the above.
Source code in src/qmlkit/imbalance.py
pos_weight ¶
n_negative / n_positive — the weight for a single-logit binary loss.
This is what torch.nn.BCEWithLogitsLoss(pos_weight=...) takes, and the
number that stops a 90/10 problem training to a constant. Multiclass problems
want :func:class_weights instead.
Source code in src/qmlkit/imbalance.py
resample ¶
resample(
y: Any,
strategy: str = "oversample",
seed: int | None = None,
ratio: float = 1.0,
) -> Array
Indices that rebalance the classes. Apply them to X and y alike.
"oversample" draws the minority classes up with replacement; "undersample"
draws the majority classes down without it. ratio is how far towards balance
to go — 1.0 is fully balanced, 0.5 closes half the gap, which is often
the better trade because oversampling a tiny class to parity mostly duplicates
the same few points.
Oversampling is the safer default on quantum models: undersampling throws away data that cost circuits to label, and these datasets are small already.
Source code in src/qmlkit/imbalance.py
stratified_split ¶
(train_index, test_index) holding each class's share in both halves.
Every class contributes at least one row to the test set whenever it has two or more samples, so a rare class cannot vanish from the evaluation — the failure that makes a small-data test score meaningless.
Source code in src/qmlkit/imbalance.py
stratified_folds ¶
n_folds (train_index, test_index) pairs, each class spread evenly.
Raises when a class has fewer members than folds, rather than silently producing folds that cannot contain it — a cross-validated score whose folds disagree about which classes exist is not a score.
Source code in src/qmlkit/imbalance.py
imbalance_report ¶
What the skew in y will break, and the call that fixes each thing.
Returns a :class:~qmlkit.diagnostics.Report, so it prints, is falsy when
there is nothing wrong, and carries codes to branch on. Findings use the
imbalance.* code prefix.
Source code in src/qmlkit/imbalance.py
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 | |
qmlkit.search¶
search ¶
One call to sweep everything tunable, with the broken configurations skipped.
best = qk.search(X, y, ansatz=["hardware_efficient", "strongly_entangling"],
n_qubits=[4, 6], n_layers=[2, 3], lr=[0.05, 0.1])
Every axis takes a list; anything you leave out keeps its default. The result ranks
the configurations on the metric the task deserves — imbalance-aware for
classification — scores all of them on identical folds, and refuses to call a winner
when the gap is inside the fold-to-fold spread, exactly as
:func:~qmlkit.baselines.baseline does.
The part no other library can do. A grid search normally trains everything and
sorts the results, so a configuration that cannot work still costs a full fit — and
then sits in the table looking merely unlucky. Before fitting anything, this runs
:func:~qmlkit.diagnostics.diagnose on the assembled model and drops the ones with an
error-level finding: an ansatz whose weights cannot move the state, a re-uploading
block that collapses to one frequency, a model with no trainable parameters. They are
reported as pruned, with the reason, not silently dropped and not quietly ranked
last.
On a grid where a third of the points are unlearnable that is a third of the compute, and — more to the point — a third of the rows in your results table that would otherwise have been noise you might have read as signal.
It tells you the cost first. qk.search(..., dry_run=True) returns the plan
without fitting anything: how many configurations, how many fits, and how many
circuits, so a sweep that would take a week is something you find out in a second.
SearchRow
dataclass
¶
SearchRow(
config: dict[str, Any],
mean: float = float("nan"),
std: float = float("nan"),
fold_scores: tuple[float, ...] = (),
pruned: str = "",
seconds: float = 0.0,
findings: tuple[str, ...] = (),
fitted: bool = True,
)
One configuration's outcome: a score, or the reason it was never fitted.
label ¶
SearchResult
dataclass
¶
SearchResult(
task: str,
metric: str,
n_samples: int,
n_folds: int,
varied: tuple[str, ...],
rows: tuple[SearchRow, ...] = (),
notes: tuple[str, ...] = (),
extras: dict[str, Any] = dict(),
)
Every configuration on identical folds, best first.
register_feature_map ¶
register_feature_map(
name: str, factory: Callable[..., FeatureMap]
) -> None
Make a feature map reachable by name, here and in every later search.
search ¶
search(
X: Any,
y: Any,
task: str = "auto",
cv: int = 3,
metric: str | None = None,
seed: int | None = 0,
prune: str | Sequence[str] = "error",
max_configs: int | None = None,
dry_run: bool = False,
verbose: bool = True,
n_jobs: int | None = None,
**axes: Any,
) -> SearchResult
Sweep every tunable axis, skipping the configurations that cannot work.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
X
|
Any
|
The data. Folds are stratified for classification and identical for every configuration, so the table compares models rather than splits. |
required |
y
|
Any
|
The data. Folds are stratified for classification and identical for every configuration, so the table compares models rather than splits. |
required |
cv
|
int
|
Folds per configuration. |
3
|
metric
|
str | None
|
What to rank on. Defaults to the task's primary metric, which is imbalance-aware for classification. |
None
|
prune
|
str | Sequence[str]
|
Which diagnoses stop a configuration being fitted, as a named level or an explicit list of finding codes.
Every configuration is diagnosed regardless of this setting, and its findings
are printed beside its score — a point that scores well and carries
|
'error'
|
max_configs
|
int | None
|
Sample this many points from the grid instead of taking all of them. The sample is seeded, so it is reproducible. |
None
|
dry_run
|
bool
|
Build and prune the grid, report what it would cost, and fit nothing. |
False
|
**axes
|
Any
|
Any key in :data: |
{}
|
Examples:
>>> import qmlkit as qk
>>> X, y = qk.datasets.make_moons(n_samples=40, seed=0)
>>> plan = qk.search(X, y, n_layers=[1, 2], cv=2, dry_run=True)
>>> len(plan.rows)
2
Source code in src/qmlkit/search.py
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 | |
qmlkit.baselines¶
baselines ¶
The classical bar, computed on the same data, the same folds, the same metric.
The question every quantum machine learning result is asked first is "compared to what?", and the honest answer is usually "an RBF-kernel SVM nobody ran". Not out of bad faith — running it means a second pipeline, a second preprocessing path, a second splitting convention, and by the time all three match, the comparison is a day's work that adds nothing to the paper if it goes the expected way.
So it goes unrun, and the reviewer asks anyway.
This module makes it one call::
>>> import qmlkit as qk
>>> X, y = qk.datasets.make_moons(n_samples=60, seed=0)
>>> table = qk.baseline(X, y, cv=3, seed=0) # doctest: +SKIP
>>> print(table) # doctest: +SKIP
classification · balanced_accuracy · 3-fold stratified · n=60
rbf-kernel-ridge 0.883 +/- 0.042
nearest-centroid 0.850 +/- 0.038
majority 0.500 +/- 0.000
the bar to beat is rbf-kernel-ridge at 0.883
Pass model= and the model is fitted on the identical folds and lands in the
same table, with a verdict line that says plainly whether it cleared the bar.
The baselines that need no scikit-learn always run. Kernel ridge with an RBF kernel is a closed-form solve, and it is the right classical foil for a quantum kernel method specifically because it is the same algorithm with a different kernel. Where scikit-learn is installed, its estimators are added; where it is not, they are listed as skipped rather than silently dropped, because a table that quietly omits the strong baseline is the problem this module exists to fix.
The companion check for kernel methods is
:func:~qmlkit.kernels.matrix.geometric_difference, which asks whether the quantum
kernel induces a geometry the classical one cannot reach at all. A large geometric
difference with no accuracy gain is a real and publishable finding; a small one
says the classical kernel was always going to be enough.
MajorityClassifier ¶
Bases: _Estimator
Always predicts the most frequent training label.
The floor. A model that does not clear this has learned nothing at all, and on a skewed dataset it clears 90% accuracy while doing so.
NearestCentroid ¶
Bases: _Estimator
Assign each point to the nearest class mean. No solver, no hyperparameter.
RBFKernelRidge ¶
Bases: _Estimator
Kernel ridge regression with an RBF kernel; one-hot targets for labels.
Closed form: alpha = (K + lambda I)^-1 Y. For a quantum kernel method
this is the honest foil — the identical algorithm with the identical solver,
differing only in which kernel fills the Gram matrix. Any gap between them is
attributable to the kernel and to nothing else.
Source code in src/qmlkit/baselines.py
MeanRegressor ¶
Bases: _Estimator
Predicts the training mean. R2 is 0.0 here by construction — the floor.
LinearLeastSquares ¶
BaselineSpec
dataclass
¶
BaselineSpec(
name: str,
task: str,
factory: Callable[[], Any],
requires: str | None = None,
note: str = "",
)
One classical model that can stand next to a quantum one.
BaselineRow
dataclass
¶
BaselineRow(
name: str,
mean: float,
std: float,
fold_scores: tuple[float, ...] = (),
is_model: bool = False,
skipped: str = "",
)
One model's score across the folds.
BaselineTable
dataclass
¶
BaselineTable(
task: str,
metric: str,
n_samples: int,
n_folds: int,
rows: tuple[BaselineRow, ...] = (),
notes: tuple[str, ...] = (),
extras: dict[str, Any] = dict(),
)
Every model on the same folds, sorted best first.
verdict is the sentence to quote: whether the model under test cleared the
strongest classical baseline, and by how much relative to the fold-to-fold
spread — a gap smaller than the noise is not a gap.
beats_classical
property
¶
True only if the model's mean clears the best classical mean.
None when no model was passed. Says nothing about significance — read
:attr:verdict for that.
register_baseline ¶
register_baseline(
name: str,
task: str,
factory: Callable[[], Any],
requires: str | None = None,
note: str = "",
) -> None
Add a baseline, so it appears in every table for that task from now on.
requires names an importable module; when it is missing the baseline is
reported as skipped rather than dropped. factory must return a fresh,
unfitted estimator with fit and predict — it is called once per fold.
Source code in src/qmlkit/baselines.py
list_baselines ¶
Registered baseline names, optionally filtered to one task.
Deduplicated. The registry is keyed by task and name so that one name can serve
both tasks - rbf-kernel-ridge is registered for classification and for
regression - and listing the values unfiltered showed such a name once per task,
which reads as a duplicate registration rather than as one name doing two jobs.
Source code in src/qmlkit/baselines.py
get_baseline ¶
get_baseline(
name: str, task: str = "classification"
) -> BaselineSpec
One registered baseline. Names are unique within a task, not across tasks.
Source code in src/qmlkit/baselines.py
baseline ¶
baseline(
X: Any,
y: Any,
model: Any = None,
task: str = "auto",
cv: int = 5,
metric: str | None = None,
seed: int | None = 0,
include: Sequence[str] | None = None,
max_samples: int | None = None,
fit_kwargs: dict[str, Any] | None = None,
) -> BaselineTable
Score every classical baseline — and optionally model — on shared folds.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
X
|
Any
|
The data. Folds are stratified for classification, contiguous-shuffled for regression, and identical for every row of the table. |
required |
y
|
Any
|
The data. Folds are stratified for classification, contiguous-shuffled for regression, and identical for every row of the table. |
required |
model
|
Any
|
Optional. Either an unfitted estimator with |
None
|
task
|
str
|
|
'auto'
|
cv
|
int
|
Number of folds. |
5
|
metric
|
str | None
|
Which metric decides the ranking. Defaults to the primary metric for the task, which is imbalance-aware for classification. |
None
|
include
|
Sequence[str] | None
|
Restrict to these baseline names. The default runs every registered one. |
None
|
max_samples
|
int | None
|
Subsample before running. A quantum model refitted on five folds of a thousand points is an overnight job; capping it makes the comparison something that gets run at all. The cap is recorded in the notes. |
None
|
Notes
Every fold sees identical indices for every model, so the differences reported are differences between models rather than between splits.
Source code in src/qmlkit/baselines.py
461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 | |
qmlkit.budget¶
budget ¶
What an experiment costs in circuits, before it is run rather than after.
The number that decides whether a quantum machine learning experiment is possible is not accuracy. It is circuits: samples times steps times the cost of one gradient, and on hardware that cost is multiplied by a queue.
Nobody computes it. People start the run, watch the first epoch take four minutes,
multiply in their head, and stop. The arithmetic is not hard — it is just spread
across :func:~qmlkit.gradients.dispatch.gradient_cost,
:func:~qmlkit.core.observables.group_qubit_wise_commuting and
:func:~qmlkit.utils.shots.shots_for_precision, and nobody assembles it up front::
>>> import qmlkit as qk
>>> plan = qk.plan(qk.hardware_efficient(4, 3), n_samples=100, steps=50)
>>> plan.circuits > 0
True
:class:Plan prints the total, the wall-clock at a given seconds-per-circuit, and
the reductions available with what each one costs in exactness. The reductions are
the point: a plan that says "24 days" and stops is a discouragement, while one that
says "24 days, or 6 hours on adjoint, or 90 minutes with SPSA at the price of an
unbiased estimate instead of an exact one" is a decision.
Reduction
dataclass
¶
One way to make the run cheaper, and what it costs to take it.
Plan
dataclass
¶
Plan(
circuits: int,
shots_total: int | None,
method: str,
n_params: int,
n_samples: int,
steps: int,
shots: int | None,
measurement_settings: int,
observable_terms: int,
reductions: tuple[Reduction, ...] = (),
notes: tuple[str, ...] = (),
)
plan ¶
plan(
model: Any,
n_samples: int = 1,
steps: int = 1,
method: str = "parameter-shift",
obs: Observable | None = None,
shots: int | None = None,
) -> Plan
Circuits, shots and wall-clock for a training run, plus the ways to shrink it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Any
|
An :class: |
required |
n_samples
|
int
|
The training set size and the number of optimiser steps. The product is how many gradients get taken. |
1
|
steps
|
int
|
The training set size and the number of optimiser steps. The product is how many gradients get taken. |
1
|
method
|
str
|
Which gradient rule to cost. |
'parameter-shift'
|
obs
|
Observable | None
|
The observable being measured. Its terms are grouped into qubit-wise commuting sets, because that grouping is the difference between a k-term observable costing k circuits and costing one. |
None
|
shots
|
int | None
|
Shots per circuit, or |
None
|
Returns:
| Type | Description |
|---|---|
Plan
|
Printable, and carrying the numbers so a caller can branch on them. |
Source code in src/qmlkit/budget.py
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | |
qmlkit.provenance¶
provenance ¶
Two questions a result has to answer: is it right, and can it be reproduced.
A quantum machine learning number is produced by a stack — library version, SDK version, backend, seed, shot count — where any layer can change the answer and none of them is usually recorded. Six months later the same script gives a different number and there is no way to tell which layer moved.
:func:fingerprint records the stack. :func:selfcheck asks whether the number was
right in the first place, by computing it more than one way.
The second is the more unusual. This library ships four independent exact routes to a gradient — adjoint, backprop, Hadamard-test and parameter-shift — and any two of them agreeing to machine precision is strong evidence that both are correct, because they share almost no code. Disagreement localises a bug that no single implementation could have caught::
>>> import numpy as np, qmlkit as qk
>>> a = qk.hardware_efficient(3, 2)
>>> spec = a.build()
>>> report = qk.selfcheck(spec, np.full(a.n_params, 0.3), qk.Z(0))
>>> bool(report) # falsy when every route agrees
False
That is the parity idea from tests/test_pennylane_parity.py turned into
something a user can point at their own circuit.
Fingerprint
dataclass
¶
Fingerprint(
qmlkit: str,
python: str,
platform: str,
numpy: str,
default_backend: str,
backends: dict[str, str | None] = dict(),
optional: dict[str, str | None] = dict(),
seed: int | None = None,
extra: dict[str, Any] = dict(),
)
Everything that could change a number, recorded in one object.
Paste :meth:as_dict into a results file, or :func:str into a paper
appendix. The point is that it is cheap enough to attach to every run.
as_dict ¶
A plain, JSON-serialisable mapping.
Source code in src/qmlkit/provenance.py
fingerprint ¶
fingerprint(
seed: int | None = None, **extra: Any
) -> Fingerprint
The versions and settings that decide what a number comes out as.
seed and any keyword extras are carried verbatim, so the run's own
parameters — shot count, ansatz name, dataset — sit alongside the environment
that produced them.
Source code in src/qmlkit/provenance.py
selfcheck ¶
selfcheck(
spec: CircuitSpec,
theta: ArrayLike,
obs: Observable,
backend: Any = None,
cross_backend: bool = True,
) -> Any
Compute this circuit's value and gradient every available way, and compare.
Returns a :class:~qmlkit.diagnostics.Report, falsy when everything agrees.
Two independent checks run:
- Gradient routes. Adjoint, backprop, Hadamard-test and parameter-shift are four separate derivations of the same quantity. They share the circuit IR and almost nothing else, so agreement is evidence and disagreement localises the wrong one — the method that stands alone against the others.
- Backends. When more than one SDK is installed, the same circuit is run through each. This catches the translation-layer mistakes that no amount of testing against a single simulator can: endianness, controlled-gate qubit order, dropped idle qubits.
cross_backend=False skips the second, which is the slower one.
This is what to run when a number looks wrong and nothing raised.
Source code in src/qmlkit/provenance.py
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | |
qmlkit.nn.losses¶
losses ¶
Losses that survive a skewed training set.
:mod:qmlkit.imbalance computes the weights; this module is where torch consumes
them. Both losses here are drop-in nn.Module replacements, so they work in any
training loop, not only the one in :class:~qmlkit.nn.models.HybridModel::
import qmlkit as qk
from qmlkit.nn.losses import FocalLoss, weighted_cross_entropy
loss_fn = weighted_cross_entropy(y_train) # class-weighted
loss_fn = FocalLoss(gamma=2.0, weight=...) # down-weights easy examples
Which to reach for. Class weighting is the first thing to try and usually enough: it rescales the loss so the minority class contributes as much total gradient as the majority one. Focal loss goes further and down-weights easy examples of any class, which helps when the majority class is not merely abundant but trivially separable — the regime where a weighted loss still spends most of its gradient re-learning what it already knows.
On a variational circuit the distinction matters more than it does classically. Every gradient entry costs circuits, so a loss that spends its signal on examples the model already gets right is spending a budget measured in wall-clock hours.
FocalLoss ¶
Bases: Module
-(1 - p_t)^gamma * log p_t, averaged over the batch.
Lin et al. (2017). gamma=0 is exactly weighted cross-entropy, so the
parameter interpolates rather than switching behaviour; gamma=2 is the
published default and down-weights an example the model already assigns
p=0.9 by a factor of 100.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gamma
|
float
|
How hard to discount easy examples. Must be non-negative. |
2.0
|
weight
|
Tensor | None
|
Optional per-class weights, as :func: |
None
|
reduction
|
str
|
|
'mean'
|
Source code in src/qmlkit/nn/losses.py
class_weight_tensor ¶
Class weights as a tensor indexed by class, ready for CrossEntropyLoss.
Labels must be the integer class indices the model outputs, which is what
:class:~qmlkit.nn.models.VQC trains against. A class absent from y gets
weight 1.0 rather than being dropped, so the tensor always has n_classes
entries and the loss never indexes past its end.
Source code in src/qmlkit/nn/losses.py
weighted_cross_entropy ¶
weighted_cross_entropy(
y: Any,
scheme: str = "balanced",
n_classes: int | None = None,
) -> CrossEntropyLoss
CrossEntropyLoss already carrying the class weights for y.
Source code in src/qmlkit/nn/losses.py
qmlkit.progress¶
See Watching a run for the guide.
progress ¶
What a long run is doing, and how much of it is left.
qk.plan answers this before a run starts and qk.diagnose answers it afterwards.
In between there has been nothing, and "in between" is where the hours go: a
quantum kernel on a few hundred points is tens of thousands of circuits, and every
library in this field runs them behind a silent call that returns when it returns.
>>> import qmlkit as qk
>>> with qk.progress() as run: # doctest: +SKIP
... gram = kernel(X)
...
kernel gram 3,412/12,720 27% 14.2s elapsed ~37s left
>>> print(run.report()) # doctest: +SKIP
Run finished in 51.4s
kernel gram 12,720 items 51.2s 4.0 ms/item
Three properties this has to keep, in this order:
- It must not change any number. Recording is a counter and a clock; nothing here touches a state, an angle or a seed.
- It must be free when nobody is watching. With no active reporter, advancing a
task is one attribute lookup against
None. There is no import of this module on any hot path that does not already need it. - It must not claim to know what it does not. An estimate from four samples in
half a second is a guess, and it says
estimatingrather than printing a confident number that will be wrong by an order of magnitude. This is the same rule the rest of the library follows about reporting measurements.
TaskRecord
dataclass
¶
One finished unit of work, kept for the report.
Task ¶
Task(
owner: Progress,
label: str,
total: int | None,
depth: int,
)
A unit of work being counted.
Obtained from :meth:Progress.task, and used as a context manager so that the
end time is recorded even when the work raises.
Source code in src/qmlkit/progress.py
Progress ¶
Collects what the run is doing, and optionally shows it live.
Source code in src/qmlkit/progress.py
task ¶
task(label: str, total: int | None = None) -> Task
Begin a unit of work. Use as a context manager.
log ¶
Record one point of a named scalar series.
The trajectory, not just the timings: a loss that fell and then stopped, a gradient norm that went to zero, a parameter norm that ran away. These are what the report plots, and what makes a finished run answerable afterwards rather than only observable while it happens.
Source code in src/qmlkit/progress.py
note ¶
report ¶
What the run spent its time on, worst first within each nesting level.
Source code in src/qmlkit/progress.py
html ¶
The whole run as one self-contained HTML page.
No dependencies, no server, no asset directory - the charts are inline SVG drawn from the recorded series. A file you can open, keep beside the result, or attach to whatever you are writing up.
Source code in src/qmlkit/progress.py
save_html ¶
Write :meth:html to path and return the path.
progress ¶
progress(
live: bool = True, stream: IO[str] | None = None
) -> Iterator[Progress]
Watch a run, and keep what it did.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
live
|
bool
|
Draw a line to |
True
|
stream
|
IO[str] | None
|
Where the live line goes. Defaults to |
None
|
Notes
Nothing outside this block is affected: with no active reporter, the tracking
calls inside the library cost one comparison against None.
Source code in src/qmlkit/progress.py
track ¶
Count an iterable's items as they are consumed, if anything is watching.
The convenience form for a plain loop. total is taken from len when the
iterable has one.
Source code in src/qmlkit/progress.py
task ¶
A task if something is watching, and a no-op object if not.
Lets library code write with task(...) as t: ... t.advance() without
branching on whether a reporter exists.
Source code in src/qmlkit/progress.py
log ¶
Record a scalar into the active run, or do nothing if none is active.
The counterpart to :func:task for library code: one comparison when nobody is
watching, so a training loop can log unconditionally.
Source code in src/qmlkit/progress.py
note ¶
Attach run-level facts to the active run, or do nothing if none is active.
qmlkit.report¶
report ¶
One HTML file that says what a run did.
A run produces a number, and a month later the number is all that is left. This writes down the rest of it: where the time went, what the loss did, what the library was told and what it was running on.
Deliberately a file, not a server. A dashboard you have to start, connect to and keep alive is a dependency, a port and a process; a page you can open, email, and drop next to the result in a directory is none of those and outlives all of them. The charts are inline SVG for the same reason - nothing to fetch, nothing to pin, nothing to break in two years.
>>> import qmlkit as qk
>>> with qk.progress(live=False) as run: # doctest: +SKIP
... model.fit(X, y)
>>> run.save_html("run.html") # doctest: +SKIP
'run.html'
render ¶
render(run: Progress, title: str = 'qmlkit run') -> str
The run as one self-contained HTML page.
Source code in src/qmlkit/report.py
qmlkit.utils.errors¶
How every "unknown gate / backend / ansatz / method" message in the library is built. Worth reading before adding one, since the shape of the message is the contract: what was wrong, what was probably meant, and what is allowed.
errors ¶
Error messages written for a reader who will not go and look it up.
Most of this library's callers now are language models writing code, and they work in a tight loop: guess an API, run it, read the traceback, try again. That makes the exception the primary documentation — the only page that is guaranteed to be read, at exactly the moment it is needed.
So an error about a name should answer three questions in one line:
- What was wrong (
unknown gradient method 'parameter_shift') - What was probably meant (
Did you mean 'parameter-shift'?) - What is actually allowed (
Valid: adjoint, backprop, ...)
Answering only (1) costs the caller a round trip through the docs. Answering all three usually costs them nothing: the next attempt is correct.
The near-match is deliberately more forgiving than :func:difflib.get_close_matches
alone. The two mistakes that dominate are separator and case drift — snake_case
where the library uses kebab-case, or Parameter-Shift for parameter-shift
— because they are what a model produces when it half-remembers a name from a
different library. Those resolve to a single unambiguous suggestion.
UnknownName ¶
Bases: KeyError
A :class:KeyError whose message survives the traceback intact.
KeyError is alone among the builtins in rendering as repr(args[0])
rather than the message itself, so a sentence containing quoted names comes
out escaped:
KeyError: 'unknown gate 'cnott'. Did you mean 'cnot'? ...'
Every quote in a message built to be read is exactly the thing that gets
mangled. Restoring __str__ fixes the rendering while leaving except
KeyError and pytest.raises(KeyError) working, since this is still one.
did_you_mean ¶
The closest valid spellings of got, best first, possibly empty.
A name that differs only by case or separator is treated as certain and returned alone; anything else falls back to fuzzy matching.
did_you_mean("parameter_shift", ["parameter-shift", "adjoint"]) ('parameter-shift',) did_you_mean("adjiont", ["parameter-shift", "adjoint"]) ('adjoint',) did_you_mean("wildly-different", ["adjoint"]) ()
Source code in src/qmlkit/utils/errors.py
unknown ¶
unknown(
kind: str,
got: object,
valid: Iterable[str],
*,
hint: str | None = None,
error: type[Exception] = ValueError,
) -> Exception
Build (do not raise) the exception for an unrecognised name.
Call it as raise unknown("gradient method", name, list_gradient_methods()).
Returning rather than raising keeps the raise visible at the call site, so
static analysis and readers can both still see the control flow.
hint is appended verbatim, for the cases where knowing the valid names is
not enough to know what to do next.
Asking for error=KeyError gets :class:UnknownName, which is one, but
prints its message rather than the repr of it.
Source code in src/qmlkit/utils/errors.py
wrong_size ¶
wrong_size(
what: str,
expected: int,
got: int,
*,
unit: str = "value",
hint: str | None = None,
error: type[Exception] = ValueError,
) -> Exception
Build (do not raise) the exception for a length or width mismatch.
Size errors are the other half of the repair loop, and the same rule applies:
say what the shapes were and what to change. hint should name a concrete
edit — a different constructor argument, a reducer to insert — not a restatement
of the problem.