Skip to content

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.

score property
score: float

The value of :attr:primary.

get
get(key: str, default: Any = _MISSING) -> float | None

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
def get(self, key: str, default: Any = _MISSING) -> float | None:
    """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.
    """
    if key in self.values:
        return self.values[key]
    if default is not Scores._MISSING:
        return cast("float | None", default)
    from qmlkit.utils.errors import did_you_mean

    if did_you_mean(key, self.values):
        return self[key]  # __getitem__ raises with the suggestion
    return None

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
def 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."""
    truth = _as_labels(y_true, "y_true")
    pred = _as_labels(y_pred, "y_pred")
    _check_same_length(truth, pred)
    classes = np.asarray(labels) if labels is not None else np.unique(np.concatenate([truth, pred]))
    index = {label: i for i, label in enumerate(classes.tolist())}
    matrix = np.zeros((classes.size, classes.size), dtype=np.int64)
    for t, p in zip(truth.tolist(), pred.tolist(), strict=True):
        if t in index and p in index:
            matrix[index[t], index[p]] += 1
    return matrix, classes

roc_auc

roc_auc(y_true: Any, y_score: Any) -> float

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
def roc_auc(y_true: Any, y_score: Any) -> float:
    """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.
    """
    truth = _as_labels(y_true, "y_true").astype(int)
    score: npt.NDArray[Any] = np.asarray(y_score, dtype=float).ravel()
    _check_same_length(truth, score)
    n_pos = int((truth == 1).sum())
    n_neg = int(truth.size - n_pos)
    if n_pos == 0 or n_neg == 0:
        return float("nan")

    order = np.argsort(score, kind="mergesort")
    sorted_scores = score[order]
    ranks = np.empty(score.size, dtype=float)
    i = 0
    while i < sorted_scores.size:
        j = i
        while j + 1 < sorted_scores.size and sorted_scores[j + 1] == sorted_scores[i]:
            j += 1
        ranks[order[i : j + 1]] = 0.5 * (i + j) + 1.0  # average rank, 1-based
        i = j + 1
    return float((ranks[truth == 1].sum() - n_pos * (n_pos + 1) / 2.0) / (n_pos * n_neg))

average_precision

average_precision(y_true: Any, y_score: Any) -> float

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
def average_precision(y_true: Any, y_score: Any) -> float:
    """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.
    """
    truth: npt.NDArray[Any] = _as_labels(y_true, "y_true").astype(int)
    score: npt.NDArray[Any] = np.asarray(y_score, dtype=float).ravel()
    _check_same_length(truth, score)
    n_pos = int((truth == 1).sum())
    if n_pos == 0:
        return float("nan")

    order = np.argsort(-score, kind="mergesort")
    truth, score = truth[order], score[order]
    tp = np.cumsum(truth)
    fp = np.cumsum(1 - truth)
    # collapse ties: no threshold can separate two identical scores
    keep = np.r_[np.diff(score) != 0, True]
    tp, fp = tp[keep], fp[keep]
    precision = tp / np.maximum(tp + fp, 1)
    recall = tp / n_pos
    return float(np.sum(np.diff(np.r_[0.0, recall]) * precision))

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 (n,) vector of scores for the positive class, or an (n, n_classes) array of probabilities. Unlocks roc_auc, average_precision, log_loss and brier.

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
def 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
    ----------
    y_true, y_pred:
        Labels. Any hashable label type; they are matched by value.
    y_score:
        Optional. Either an ``(n,)`` vector of scores for the positive class, or an
        ``(n, n_classes)`` array of probabilities. Unlocks ``roc_auc``,
        ``average_precision``, ``log_loss`` and ``brier``.
    labels:
        Optional fixed class order, for when a fold is missing a class.

    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.
    """
    truth = _as_labels(y_true, "y_true")
    pred = _as_labels(y_pred, "y_pred")
    _check_same_length(truth, pred)
    matrix, classes = confusion_matrix(truth, pred, labels)
    n = int(matrix.sum())
    if n == 0:
        raise ValueError("no samples fall inside the given labels")

    support = matrix.sum(axis=1).astype(float)
    predicted = matrix.sum(axis=0).astype(float)
    correct = np.diag(matrix).astype(float)

    recall = _safe_divide(correct, support)
    precision = _safe_divide(correct, predicted)
    f1 = _safe_divide(2 * precision * recall, precision + recall)
    weight = support / n

    accuracy = float(correct.sum() / n)
    present = support > 0
    balanced = float(recall[present].mean()) if present.any() else 0.0

    # Matthews correlation straight from the confusion matrix — the multiclass
    # generalisation, which reduces to the familiar 2x2 formula for two classes.
    total_sq = float(n) ** 2
    cov_pt = float(correct.sum() * n - predicted @ support)
    cov_pp = total_sq - float(predicted @ predicted)
    cov_tt = total_sq - float(support @ support)
    denom = float(np.sqrt(cov_pp * cov_tt))
    mcc = cov_pt / denom if denom > 0 else 0.0

    chance = float(predicted @ support) / total_sq
    kappa = (accuracy - chance) / (1.0 - chance) if chance < 1.0 else 0.0

    values: dict[str, float] = {
        "accuracy": accuracy,
        "balanced_accuracy": balanced,
        "precision_macro": float(precision[present].mean()) if present.any() else 0.0,
        "recall_macro": float(recall[present].mean()) if present.any() else 0.0,
        "f1_macro": float(f1[present].mean()) if present.any() else 0.0,
        "precision_weighted": float(precision @ weight),
        "recall_weighted": float(recall @ weight),
        "f1_weighted": float(f1 @ weight),
        "mcc": float(mcc),
        "cohen_kappa": float(kappa),
    }

    notes: list[str] = []
    extras: dict[str, Any] = {
        "confusion_matrix": matrix,
        "labels": classes,
        "support": support.astype(int),
        "per_class": {
            str(label): {
                "precision": float(precision[i]),
                "recall": float(recall[i]),
                "f1": float(f1[i]),
                "support": int(support[i]),
            }
            for i, label in enumerate(classes.tolist())
        },
    }

    # ---- scores, when they were supplied ---------------------------------- #
    if y_score is not None:
        score_arr = np.asarray(y_score, dtype=float)
        if score_arr.ndim == 1:
            score_arr = score_arr.reshape(-1, 1)
        _check_same_length(truth, score_arr)
        onehot = (truth[:, None] == classes[None, :]).astype(float)

        if classes.size == 2:
            positive = score_arr[:, -1]
            binary_truth = (truth == classes[1]).astype(int)
            values["roc_auc"] = roc_auc(binary_truth, positive)
            values["average_precision"] = average_precision(binary_truth, positive)
            if score_arr.shape[1] == 2:
                values["log_loss"] = _log_loss(onehot, score_arr)
                values["brier"] = _brier(onehot, score_arr)
        elif score_arr.shape[1] == classes.size:
            # one-vs-rest, averaged over the classes that actually occur
            aucs = [
                roc_auc((truth == label).astype(int), score_arr[:, i])
                for i, label in enumerate(classes.tolist())
                if support[i] > 0
            ]
            finite = [a for a in aucs if np.isfinite(a)]
            if finite:
                values["roc_auc_ovr"] = float(np.mean(finite))
            values["log_loss"] = _log_loss(onehot, score_arr)
            values["brier"] = _brier(onehot, score_arr)
        else:
            notes.append(
                f"y_score has {score_arr.shape[1]} column(s) for {classes.size} classes, "
                "so the threshold metrics were skipped"
            )

    # ---- the notes that stop a number being misread ----------------------- #
    majority = float(support.max() / n)
    smallest = int(support[present].min()) if present.any() else 0
    if accuracy <= majority + 1e-12:
        argmax = int(np.argmax(support))
        notes.append(
            f"accuracy {accuracy:.3f} is at the majority-class rate ({majority:.3f}): this "
            f'model has not beaten "always predict class {classes[argmax]}". Read '
            f"balanced_accuracy ({balanced:.3f}) or mcc ({mcc:.3f}) instead."
        )
    elif majority >= 0.65:
        notes.append(
            f"classes are imbalanced ({majority:.1%} majority), so accuracy {accuracy:.3f} "
            f"overstates this model: balanced_accuracy is {balanced:.3f}, mcc {mcc:.3f}"
        )
    if 0 < smallest < 10:
        notes.append(
            f"the smallest class has {smallest} sample(s), so its precision and recall "
            "carry very wide error bars"
        )
    if classes.size == 2 and majority >= 0.8 and "average_precision" in values:
        notes.append(
            f"at {majority:.1%} majority, average_precision "
            f"({values['average_precision']:.3f}) is the more honest curve summary; "
            f"roc_auc is {values['roc_auc']:.3f}"
        )

    primary = "balanced_accuracy" if majority >= 0.65 else "accuracy"
    return Scores("classification", values, primary, n, tuple(notes), extras)

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 y_pred meaning "declined". None (the default) treats None entries, and NaN in a numeric array, as abstentions.

None
labels Sequence[Any] | None

Optional fixed class order, for when a fold is missing a class.

None

Examples:

>>> import qmlkit as qk
>>> qk.evaluate.selective([0, 1, 0, 1], [0, 1, None, 0]).score
Source code in src/qmlkit/evaluate.py
def selective(
    y_true: Any,
    y_pred: Any,
    abstain: Any = None,
    labels: Sequence[Any] | None = None,
) -> Scores:
    r"""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
    ----------
    y_true, y_pred:
        Labels, matched by value.
    abstain:
        The value in ``y_pred`` meaning "declined". ``None`` (the default) treats
        ``None`` entries, and NaN in a numeric array, as abstentions.
    labels:
        Optional fixed class order, for when a fold is missing a class.

    Examples
    --------
    >>> import qmlkit as qk                                          # doctest: +SKIP
    >>> qk.evaluate.selective([0, 1, 0, 1], [0, 1, None, 0]).score   # doctest: +SKIP
    """
    truth = _as_labels(y_true, "y_true")
    predicted = np.asarray(y_pred, dtype=object).ravel()
    _check_same_length(truth, predicted)

    declined = _abstentions(predicted, abstain)
    n = int(truth.size)
    n_answered = int((~declined).sum())
    coverage = n_answered / n if n else 0.0

    if n_answered:
        inner = classification(truth[~declined], predicted[~declined], labels=labels)
        selective_accuracy = float(inner["accuracy"])
        balanced = float(inner["balanced_accuracy"])
    else:
        selective_accuracy = balanced = 0.0

    values = {
        "coverage": coverage,
        "selective_accuracy": selective_accuracy,
        "selective_risk": 1.0 - selective_accuracy,
        "selective_balanced_accuracy": balanced,
        "accuracy": selective_accuracy * coverage,
        "n_answered": float(n_answered),
        "n_declined": float(n - n_answered),
        "full_coverage_equivalent": selective_accuracy * coverage,
    }

    notes: list[str] = []
    if coverage < 1.0:
        notes.append(
            f"selective_accuracy {selective_accuracy:.3f} is measured on "
            f"{coverage:.1%} of the data ({n_answered} of {n}). It is not comparable "
            f"to a classifier that answered everything; that one needs "
            f"{values['full_coverage_equivalent']:.3f} to match, which is 'accuracy' here."
        )
    if coverage < _LOW_COVERAGE:
        notes.append(
            f"coverage is only {coverage:.1%}: raising an abstention threshold raises "
            "selective accuracy monotonically, so a high number at low coverage is "
            "close to free. Report the risk-coverage curve, not a single point."
        )
    if n_answered == 0:
        notes.append("the classifier declined every sample, so there is nothing to score")

    return Scores(
        task="selective-classification",
        values=values,
        primary="accuracy",
        n_samples=n,
        notes=tuple(notes),
        extras={"coverage": coverage},
    )

risk_coverage

risk_coverage(
    y_true: Any,
    y_pred: Any,
    confidence: Any,
    n_points: int = 20,
) -> dict[str, Any]

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
def risk_coverage(
    y_true: Any,
    y_pred: Any,
    confidence: Any,
    n_points: int = 20,
) -> dict[str, Any]:
    """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``.
    """
    truth = _as_labels(y_true, "y_true")
    predicted = _as_labels(y_pred, "y_pred")
    scores = np.asarray(confidence, dtype=float).ravel()
    _check_same_length(truth, predicted)
    _check_same_length(truth, scores)

    order = np.argsort(-scores, kind="mergesort")
    wrong = (truth[order] != predicted[order]).astype(float)
    n = truth.size
    cumulative_risk = np.cumsum(wrong) / np.arange(1, n + 1)
    cumulative_coverage = np.arange(1, n + 1) / n

    take = np.unique(np.linspace(0, n - 1, min(n_points, n)).astype(int))
    # trapezoid by hand: np.trapezoid is NumPy 2 only and np.trapz is gone in NumPy 2,
    # so neither name works across the versions this package supports
    widths = np.diff(cumulative_coverage)
    heights = (cumulative_risk[1:] + cumulative_risk[:-1]) / 2.0
    aurc = float(np.sum(widths * heights))
    return {
        "coverage": cumulative_coverage[take],
        "risk": cumulative_risk[take],
        "threshold": scores[order][take],
        "aurc": aurc,
    }

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
def regression(y_true: Any, y_pred: Any) -> Scores:
    """Every regression metric worth reporting, with R2 as the primary."""
    truth = np.asarray(y_true, dtype=float).ravel()
    pred = np.asarray(y_pred, dtype=float).ravel()
    _check_same_length(truth, pred)
    n = truth.size
    residual = truth - pred

    ss_res = float(residual @ residual)
    centred = truth - truth.mean()
    ss_tot = float(centred @ centred)
    # R2 scores a model against the variance baseline: predict the mean everywhere.
    # A constant target has no variance, so the ratio is 0/0 and there is no number
    # that can be read as a score - 0.0 makes a perfect fit look like a failure, and
    # 1.0 makes any fit look perfect. nan is what is true; the note below says why,
    # and points at the metrics that still mean something.
    no_baseline = ss_tot <= 0.0
    r2 = float("nan") if no_baseline else 1.0 - ss_res / ss_tot
    mse = ss_res / n

    values = {
        "r2": float(r2),
        "mse": float(mse),
        "rmse": float(np.sqrt(mse)),
        "mae": float(np.abs(residual).mean()),
        "median_absolute_error": float(np.median(np.abs(residual))),
        "max_error": float(np.abs(residual).max()),
        "explained_variance": (
            float("nan") if no_baseline else float(1.0 - residual.var() / truth.var())
        ),
    }

    notes: list[str] = []
    nonzero = truth != 0
    if bool(nonzero.all()):
        values["mape"] = float(np.abs(residual / truth).mean())
    else:
        notes.append(
            f"{int((~nonzero).sum())} target(s) are exactly zero, so mape is undefined "
            "and was omitted"
        )
    if no_baseline:
        notes.append(
            f"every target is {truth.mean():.4g}, so there is no variance for r2 and "
            "explained_variance to score against and both are nan. Read mse "
            f"({mse:.4g}) or max_error ({values['max_error']:.4g}), which do not need "
            "a baseline."
        )
    elif r2 <= 0.0:
        notes.append(
            f"r2 {r2:.3f} is at or below zero: predicting the mean ({truth.mean():.4g}) "
            "everywhere would do as well or better"
        )
    return Scores("regression", values, "r2", n, tuple(notes))

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
def 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.
    """
    data = np.atleast_2d(np.asarray(X, dtype=float))
    assigned = _as_labels(labels, "labels")
    if data.shape[0] != assigned.size:
        raise ValueError(f"X has {data.shape[0]} rows but {assigned.size} labels were given")
    classes = np.unique(assigned)

    values = {
        "n_clusters": float(classes.size),
        "silhouette": _silhouette(data, assigned),
        "davies_bouldin": _davies_bouldin(data, assigned),
    }
    notes: list[str] = []
    extras: dict[str, Any] = {
        "cluster_sizes": {str(c): int((assigned == c).sum()) for c in classes.tolist()}
    }
    primary = "silhouette"

    if y_true is not None:
        truth = _as_labels(y_true, "y_true")
        _check_same_length(truth, assigned)
        table = _contingency(truth, assigned)
        values["adjusted_rand"] = _adjusted_rand(table)
        values["normalized_mutual_info"] = _normalised_mutual_info(table)
        values["purity"] = float(table.max(axis=0).sum() / table.sum())
        extras["contingency"] = table
        primary = "adjusted_rand"
        if values["adjusted_rand"] < 0.05:
            notes.append(
                f"adjusted_rand {values['adjusted_rand']:.3f} is near zero: these clusters "
                "agree with the labels about as well as a random partition would"
            )

    if classes.size < 2:
        notes.append("only one cluster was assigned, so the internal metrics are undefined")
    elif np.isfinite(values["silhouette"]) and values["silhouette"] < 0.1:
        notes.append(
            f"silhouette {values['silhouette']:.3f} is near zero: points sit about as close "
            "to a neighbouring cluster as to their own, so the partition is weak"
        )
    return Scores("clustering", values, primary, int(assigned.size), tuple(notes), extras)

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
def 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.
    """
    p, q = _align(p_model, q_target)

    tv = float(0.5 * np.abs(p - q).sum())
    hellinger = float(np.linalg.norm(np.sqrt(p) - np.sqrt(q)) / np.sqrt(2.0))
    m = 0.5 * (p + q)
    js = 0.5 * _kl(p, m) + 0.5 * _kl(q, m)

    values = {
        "total_variation": tv,
        "hellinger": hellinger,
        "js_divergence": float(js),
        "js_distance": float(np.sqrt(max(js, 0.0))),
        "kl_model_target": _kl(p, q),
        "kl_target_model": _kl(q, p),
        "support_coverage": float((q[p > 0] > 0).sum() / max(int((q > 0).sum()), 1)),
    }

    notes: list[str] = []
    missed = int(((q > 0) & (p == 0)).sum())
    if missed:
        notes.append(
            f"the model puts zero mass on {missed} outcome(s) the target reaches, so "
            "kl_target_model is infinite; total_variation and js_distance stay finite "
            "and are the ones to optimise"
        )
    if tv > 0.5:
        notes.append(
            f"total_variation {tv:.3f} exceeds 0.5: the two distributions disagree on more "
            "than half their mass, which is not a small modelling error"
        )
    return Scores("generative", values, "total_variation", int(p.size), tuple(notes))

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
def scores_for(task: str, *args: Any, **kwargs: Any) -> Scores:
    """Call one of the four by name — for code that is generic over the task."""
    table: dict[str, Callable[..., Scores]] = {
        "classification": classification,
        "regression": regression,
        "clustering": clustering,
        "generative": generative,
    }
    if task not in table:
        from qmlkit.utils.errors import unknown

        raise unknown("task", task, table)
    return table[task](*args, **kwargs)

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:

  1. 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.
  2. 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.
  3. The score. Accuracy rewards the constant model. :mod:qmlkit.evaluate handles 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

class_counts(y: Any) -> dict[Any, int]

{label: count}, in sorted label order.

Source code in src/qmlkit/imbalance.py
def class_counts(y: Any) -> dict[Any, int]:
    """``{label: count}``, in sorted label order."""
    labels, counts = np.unique(_labels(y), return_counts=True)
    return {
        label: int(count) for label, count in zip(labels.tolist(), counts.tolist(), strict=True)
    }

imbalance_ratio

imbalance_ratio(y: Any) -> float

Majority count over minority count. 1.0 is perfectly balanced.

Source code in src/qmlkit/imbalance.py
def imbalance_ratio(y: Any) -> float:
    """Majority count over minority count. ``1.0`` is perfectly balanced."""
    counts = np.asarray(list(class_counts(y).values()), dtype=float)
    return float(counts.max() / counts.min())

class_weights

class_weights(
    y: Any, scheme: str = "balanced"
) -> dict[Any, float]

{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
def class_weights(y: Any, scheme: str = "balanced") -> dict[Any, float]:
    """``{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.
    """
    counts = class_counts(y)
    n = float(sum(counts.values()))
    k = len(counts)
    if scheme == "none":
        return dict.fromkeys(counts, 1.0)
    if scheme == "balanced":
        return {label: n / (k * count) for label, count in counts.items()}
    if scheme == "inverse":
        raw = {label: 1.0 / count for label, count in counts.items()}
        mean = float(np.mean([raw[label] * counts[label] for label in counts])) / (n / k)
        return {label: value / mean for label, value in raw.items()}
    from qmlkit.utils.errors import unknown

    raise unknown("weighting scheme", scheme, ("balanced", "inverse", "none"))

sample_weights

sample_weights(y: Any, scheme: str = 'balanced') -> Array

Per-sample weights, one per row of y — the array form of the above.

Source code in src/qmlkit/imbalance.py
def sample_weights(y: Any, scheme: str = "balanced") -> Array:
    """Per-sample weights, one per row of ``y`` — the array form of the above."""
    arr = _labels(y)
    table = class_weights(arr, scheme)
    return np.array([table[label] for label in arr.tolist()], dtype=float)

pos_weight

pos_weight(y: Any, positive: Any = None) -> float

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
def pos_weight(y: Any, positive: Any = None) -> float:
    """``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.
    """
    arr = _labels(y)
    counts = class_counts(arr)
    if len(counts) != 2:
        raise ValueError(
            f"pos_weight is for two classes, got {len(counts)}: "
            f"{sorted(counts)}. Use class_weights(y) for a multiclass loss."
        )
    label = max(counts) if positive is None else positive
    if label not in counts:
        raise ValueError(
            f"positive label {label!r} does not occur in y; labels are {sorted(counts)}"
        )
    n_pos = counts[label]
    return float((arr.size - n_pos) / n_pos)

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
def 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.
    """
    arr = _labels(y)
    if not 0.0 <= ratio <= 1.0:
        raise ValueError(f"ratio must be in [0, 1], got {ratio}")
    if strategy == "none":
        return np.arange(arr.size)

    rng = np.random.default_rng(seed)
    counts = class_counts(arr)
    by_label = {label: np.flatnonzero(arr == label) for label in counts}

    if strategy == "oversample":
        target = max(counts.values())
        picked = []
        for index in by_label.values():
            want = int(round(index.size + ratio * (target - index.size)))
            picked.append(index)
            if want > index.size:
                picked.append(rng.choice(index, size=want - index.size, replace=True))
    elif strategy == "undersample":
        target = min(counts.values())
        picked = []
        for index in by_label.values():
            want = int(round(index.size - ratio * (index.size - target)))
            picked.append(
                rng.choice(index, size=want, replace=False) if want < index.size else index
            )
    else:
        from qmlkit.utils.errors import unknown

        raise unknown("resampling strategy", strategy, ("oversample", "undersample", "none"))

    out = np.concatenate(picked)
    rng.shuffle(out)
    return out

stratified_split

stratified_split(
    y: Any, test_size: float = 0.25, seed: int | None = None
) -> tuple[Array, Array]

(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
def stratified_split(
    y: Any, test_size: float = 0.25, seed: int | None = None
) -> tuple[Array, Array]:
    """``(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.
    """
    arr = _labels(y)
    if not 0.0 < test_size < 1.0:
        raise ValueError(f"test_size must be in (0, 1), got {test_size}")
    rng = np.random.default_rng(seed)
    train: list[Array] = []
    test: list[Array] = []
    for label in class_counts(arr):
        index = np.flatnonzero(arr == label)
        rng.shuffle(index)
        n_test = int(round(test_size * index.size))
        n_test = min(max(n_test, 1 if index.size > 1 else 0), index.size - 1)
        test.append(index[:n_test])
        train.append(index[n_test:])
    return np.sort(np.concatenate(train)), np.sort(np.concatenate(test))

stratified_folds

stratified_folds(
    y: Any, n_folds: int = 5, seed: int | None = None
) -> list[tuple[Array, Array]]

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
def stratified_folds(
    y: Any, n_folds: int = 5, seed: int | None = None
) -> list[tuple[Array, Array]]:
    """``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.
    """
    arr = _labels(y)
    counts = class_counts(arr)
    if n_folds < 2:
        raise ValueError(f"n_folds must be at least 2, got {n_folds}")
    smallest = min(counts.values())
    if smallest < n_folds:
        label = min(counts, key=lambda k: counts[k])
        raise ValueError(
            f"class {label!r} has {smallest} sample(s) but {n_folds} folds were asked for. "
            f"Use n_folds<={smallest}, or resample(y) first."
        )

    rng = np.random.default_rng(seed)
    assignment = np.empty(arr.size, dtype=int)
    for label in counts:
        index = np.flatnonzero(arr == label)
        rng.shuffle(index)
        assignment[index] = np.arange(index.size) % n_folds
    everything = np.arange(arr.size)
    return [
        (everything[assignment != fold], everything[assignment == fold]) for fold in range(n_folds)
    ]

imbalance_report

imbalance_report(y: Any, n_folds: int = 5) -> Any

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
def imbalance_report(y: Any, n_folds: int = 5) -> Any:
    """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.
    """
    from qmlkit.diagnostics import Finding, Report

    arr = _labels(y)
    counts = class_counts(arr)
    n = arr.size
    majority = max(counts.values()) / n
    smallest_label = min(counts, key=lambda k: counts[k])
    smallest = counts[smallest_label]
    findings: list[Finding] = []

    if len(counts) < 2:
        findings.append(
            Finding(
                "imbalance.single-class",
                "error",
                f"y has one class ({smallest_label!r}), so there is nothing to classify",
                "check the labels reaching fit(); a filtered split can drop a class entirely",
                1.0,
            )
        )
        return Report(f"labels (n={n})", tuple(findings))

    if majority >= 0.9:
        findings.append(
            Finding(
                "imbalance.severe",
                "error",
                f"the majority class is {majority:.1%} of the data (ratio "
                f"{imbalance_ratio(arr):.1f}:1), so an unweighted loss is minimised by "
                "predicting it always",
                f"VQC(..., class_weight='balanced'), or pos_weight={pos_weight(arr):.2f} "
                "for a single-logit loss"
                if len(counts) == 2
                else "VQC(..., class_weight='balanced')",
                float(majority),
            )
        )
    elif majority >= _SKEWED:
        findings.append(
            Finding(
                "imbalance.skewed",
                "warning",
                f"the majority class is {majority:.1%} of the data: accuracy will overstate "
                "any model trained on it",
                "class_weight='balanced' when training, and read balanced_accuracy or mcc "
                "from qk.evaluate.classification",
                float(majority),
            )
        )

    if smallest < n_folds:
        findings.append(
            Finding(
                "imbalance.too-few-for-cv",
                "error",
                f"class {smallest_label!r} has {smallest} sample(s), fewer than the "
                f"{n_folds} folds asked for, so some folds cannot contain it",
                f"n_folds<={smallest}, or a single stratified_split, or collect more of that class",
                float(smallest),
            )
        )
    elif smallest < 10:
        findings.append(
            Finding(
                "imbalance.tiny-class",
                "warning",
                f"class {smallest_label!r} has {smallest} samples, so every per-class score "
                "for it moves by more than 0.1 with one sample",
                "report the confusion matrix alongside any per-class metric",
                float(smallest),
            )
        )

    if majority >= _SKEWED:
        findings.append(
            Finding(
                "imbalance.split",
                "info",
                "a random split can leave the minority class out of the test set entirely",
                "qk.imbalance.stratified_split(y) or stratified_folds(y)",
                None,
            )
        )
    return Report(f"labels (n={n}, {len(counts)} classes)", tuple(findings))

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
label(axes: Sequence[str]) -> str

Only the axes that actually varied, so the table stays readable.

Source code in src/qmlkit/search.py
def label(self, axes: Sequence[str]) -> str:
    """Only the axes that actually varied, so the table stays readable."""
    return " ".join(f"{k}={self.config[k]}" for k in axes)

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.

pruned property
pruned: tuple[SearchRow, ...]

Configurations skipped because a diagnosis said so.

Not the same as "did not run": a dry run fits nothing and prunes only what it would have pruned for real, so these two have to stay distinguishable.

verdict property
verdict: str

Whether the winner is actually a winner, or is inside the noise.

model
model(**overrides: Any) -> Any

A fresh, unfitted model built from the winning configuration.

Source code in src/qmlkit/search.py
def model(self, **overrides: Any) -> Any:
    """A fresh, unfitted model built from the winning configuration."""
    return _build_model(self.best_config | overrides, self.extras["n_classes"])

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.

Source code in src/qmlkit/search.py
def register_feature_map(name: str, factory: Callable[..., FeatureMap]) -> None:
    """Make a feature map reachable by name, here and in every later search."""
    _FEATURE_MAPS[name] = factory

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.

"error" (the default) skips only what cannot work at all: no trainable weights, a re-uploading block collapsed to one frequency, input features the circuit discards. "untrainable" also skips flat gradients and circuits that never entangle. "warning" additionally skips merely wasteful ones — dead or unmeasurable weights — which is aggressive: hardware_efficient carries an UNMEASURABLE_WEIGHTS finding by construction, so that level can empty a grid. "none" fits everything.

Every configuration is diagnosed regardless of this setting, and its findings are printed beside its score — a point that scores well and carries DEAD_WEIGHTS is worth seeing as exactly that.

'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:AXES, given a list of values (a bare value is treated as a one-element list). An unrecognised axis is an error with a suggestion, because a typo'd axis name is a sweep that silently varies nothing.

{}

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
def 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
    ----------
    X, y:
        The data. Folds are stratified for classification and identical for every
        configuration, so the table compares models rather than splits.
    cv:
        Folds per configuration.
    metric:
        What to rank on. Defaults to the task's primary metric, which is
        imbalance-aware for classification.
    prune:
        Which diagnoses stop a configuration being fitted, as a named level or an
        explicit list of finding codes.

        ``"error"`` (the default) skips only what cannot work at all: no trainable
        weights, a re-uploading block collapsed to one frequency, input features the
        circuit discards. ``"untrainable"`` also skips flat gradients and circuits that
        never entangle. ``"warning"`` additionally skips merely *wasteful* ones — dead
        or unmeasurable weights — which is aggressive: ``hardware_efficient`` carries an
        ``UNMEASURABLE_WEIGHTS`` finding by construction, so that level can empty a
        grid. ``"none"`` fits everything.

        Every configuration is diagnosed regardless of this setting, and its findings
        are printed beside its score — a point that scores well *and* carries
        ``DEAD_WEIGHTS`` is worth seeing as exactly that.
    max_configs:
        Sample this many points from the grid instead of taking all of them. The
        sample is seeded, so it is reproducible.
    dry_run:
        Build and prune the grid, report what it would cost, and fit nothing.
    **axes:
        Any key in :data:`AXES`, given a list of values (a bare value is treated as a
        one-element list). An unrecognised axis is an error with a suggestion, because
        a typo'd axis name is a sweep that silently varies nothing.

    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
    """
    from qmlkit.baselines import _infer_task
    from qmlkit.imbalance import stratified_folds
    from qmlkit.parallel import parallel_map

    data = np.atleast_2d(np.asarray(X, dtype=float))
    target = np.asarray(y).ravel()
    if data.shape[0] != target.size:
        raise ValueError(f"X has {data.shape[0]} rows but y has {target.size}")

    unknown_axes = set(axes) - set(AXES)
    if unknown_axes:
        from qmlkit.utils.errors import unknown

        raise unknown("search axis", sorted(unknown_axes)[0], AXES)

    n_features = data.shape[1]
    resolved = _infer_task(target) if task == "auto" else task
    metric = metric or ("balanced_accuracy" if resolved == "classification" else "r2")
    n_classes = int(np.unique(target).size) if resolved == "classification" else 0

    grid = {k: (list(v) if isinstance(v, list | tuple) else [v]) for k, v in axes.items()}
    too_wide = [int(q) for q in grid.get("n_qubits", []) if q is not None and int(q) > n_features]
    if too_wide:
        raise ValueError(
            f"n_qubits={too_wide} exceeds the {n_features} feature(s) in X. A feature "
            "pipeline reduces columns; it cannot invent them. Either widen the data or "
            f"keep n_qubits at or below {n_features}."
        )
    varied = tuple(k for k, v in grid.items() if len(v) > 1) or tuple(grid) or ("n_qubits",)

    notes: list[str] = []
    if "n_qubits" in grid:
        width = AXES["n_qubits"]
    else:
        # one qubit per feature, capped: past six the sweep costs more than it teaches
        width = min(n_features, 6)
        notes.append(
            f"n_qubits was not given, so it is {width} "
            + ("one per feature" if width == n_features else f"capped from {n_features}")
        )

    defaults = {**AXES, "n_qubits": width}
    full = {**{k: [v] for k, v in defaults.items()}, **grid}
    names = list(full)
    points = [dict(zip(names, combo, strict=True)) for combo in itertools.product(*full.values())]

    rng = np.random.default_rng(seed)
    if max_configs is not None and max_configs < len(points):
        chosen = rng.choice(len(points), size=max_configs, replace=False)
        notes.append(f"sampled {max_configs} of {len(points)} grid points")
        points = [points[i] for i in sorted(chosen.tolist())]

    # ---- prune before fitting, and say why -------------------------------- #
    if isinstance(prune, str):
        if prune not in _PRUNE_LEVELS:
            from qmlkit.utils.errors import unknown

            raise unknown("prune level", prune, _PRUNE_LEVELS)
        blocking = _PRUNE_LEVELS[prune]
    else:  # an explicit list of finding codes
        blocking = tuple(prune)

    rows: list[SearchRow] = []
    runnable: list[tuple[dict[str, Any], tuple[str, ...]]] = []
    for config in points:
        codes, reason = _diagnose_config(config, n_classes, blocking)
        if reason:
            rows.append(SearchRow(config, pruned=reason, findings=codes, fitted=False))
        else:
            runnable.append((config, codes))

    if dry_run:
        notes.append(
            f"dry run: {len(runnable)} configurations x {cv} folds = {len(runnable) * cv} fits"
            + (f", {len(rows)} pruned before fitting" if rows else "")
        )
        rows.extend(SearchRow(c, findings=codes, fitted=False) for c, codes in runnable)
        return SearchResult(
            resolved,
            metric,
            int(target.size),
            cv,
            varied,
            tuple(rows),
            tuple(notes),
            {"n_classes": n_classes},
        )

    # ---- identical folds for every configuration -------------------------- #
    if resolved == "classification":
        folds = stratified_folds(target, n_folds=cv, seed=seed)
    else:
        order = rng.permutation(data.shape[0])
        folds = [(np.setdiff1d(order, chunk), chunk) for chunk in np.array_split(order, cv)]

    def _fit_one(item: tuple[dict[str, Any], tuple[str, ...]]) -> SearchRow:
        """One configuration, scored across every fold. Independent of the others.

        Which is what makes `n_jobs` safe here: configurations share the folds and
        the data read-only and touch nothing else, so the only thing threading can
        change is how long it takes.
        """
        config, codes = item
        started = time.perf_counter()
        try:
            scores = _score_config(config, data, target, folds, resolved, metric, n_classes)
        except Exception as exc:  # a broken point must not lose the rest of the table
            return SearchRow(config, pruned=f"failed: {exc}", findings=codes, fitted=False)
        return SearchRow(
            config,
            float(np.mean(scores)),
            float(np.std(scores)),
            tuple(scores),
            seconds=time.perf_counter() - started,
            findings=codes,
        )

    # order is preserved, so the table and the verbose log read the same either way
    for index, row in enumerate(parallel_map(_fit_one, runnable, n_jobs=n_jobs), start=1):
        rows.append(row)
        if verbose and row.ran:
            print(
                f"  [{index}/{len(runnable)}] {row.label(varied)}"
                f"  {row.mean:.3f} +/- {row.std:.3f}  ({row.seconds:.0f}s)",
                flush=True,
            )

    if rows and any(not r.ran and "dry run" not in r.pruned for r in rows):
        n_pruned = sum(1 for r in rows if not r.ran)
        notes.append(
            f"{n_pruned} of {len(rows)} configurations were skipped before fitting; "
            "they are listed with their reason rather than ranked last"
        )
    if resolved == "classification":
        from qmlkit.imbalance import imbalance_ratio

        ratio = imbalance_ratio(target)
        if ratio >= 1.5:
            notes.append(
                f"classes are {ratio:.1f}:1, so the ranking metric is {metric}; "
                "class_weight=['balanced'] is worth putting on the grid"
            )
    return SearchResult(
        resolved,
        metric,
        int(target.size),
        cv,
        varied,
        tuple(rows),
        tuple(notes),
        {"n_classes": n_classes, "folds": folds},
    )

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

RBFKernelRidge(
    alpha: float = 1.0,
    gamma: float | str = "scale",
    classify: bool = True,
)

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
def __init__(self, alpha: float = 1.0, gamma: float | str = "scale", classify: bool = True):
    self.alpha = alpha
    self.gamma = gamma
    self.classify = classify

MeanRegressor

Bases: _Estimator

Predicts the training mean. R2 is 0.0 here by construction — the floor.

LinearLeastSquares

LinearLeastSquares(alpha: float = 1e-06)

Bases: _Estimator

Ridge-stabilised least squares with an intercept. The linear-model floor.

Source code in src/qmlkit/baselines.py
def __init__(self, alpha: float = 1e-6):
    self.alpha = alpha

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
beats_classical: bool | None

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
def 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.
    """
    if task not in ("classification", "regression"):
        from qmlkit.utils.errors import unknown

        raise unknown("baseline task", task, ("classification", "regression"))
    # keyed by task and name, so `rbf-kernel-ridge` can exist for both tasks
    _BASELINES[f"{task}/{name}"] = BaselineSpec(name, task, factory, requires, note)

list_baselines

list_baselines(task: str | None = None) -> tuple[str, ...]

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
def list_baselines(task: str | None = None) -> tuple[str, ...]:
    """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.
    """
    names = {s.name for s in _BASELINES.values() if task is None or s.task == task}
    return tuple(sorted(names))

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
def get_baseline(name: str, task: str = "classification") -> BaselineSpec:
    """One registered baseline. Names are unique within a task, not across tasks."""
    key = f"{task}/{name}"
    if key not in _BASELINES:
        from qmlkit.utils.errors import unknown

        raise unknown("baseline", name, list_baselines(task), error=KeyError)
    return _BASELINES[key]

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 fit/predict (it is deep copied per fold, so the same object can be reused), or a zero-argument callable returning a fresh one — which is the safer form for torch models, whose parameters would otherwise carry over.

None
task str

"classification", "regression", or "auto" to infer from y.

'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
def 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
    ----------
    X, y:
        The data. Folds are stratified for classification, contiguous-shuffled for
        regression, and identical for every row of the table.
    model:
        Optional. Either an unfitted estimator with ``fit``/``predict`` (it is deep
        copied per fold, so the same object can be reused), or a zero-argument
        callable returning a fresh one — which is the safer form for torch models,
        whose parameters would otherwise carry over.
    task:
        ``"classification"``, ``"regression"``, or ``"auto"`` to infer from ``y``.
    cv:
        Number of folds.
    metric:
        Which metric decides the ranking. Defaults to the primary metric for the
        task, which is imbalance-aware for classification.
    include:
        Restrict to these baseline names. The default runs every registered one.
    max_samples:
        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.

    Notes
    -----
    Every fold sees identical indices for every model, so the differences reported
    are differences between models rather than between splits.
    """
    data = np.atleast_2d(np.asarray(X, dtype=float))
    target: npt.NDArray[Any] = np.asarray(y).ravel()
    if data.shape[0] != target.size:
        raise ValueError(f"X has {data.shape[0]} rows but y has {target.size}")
    resolved = _infer_task(target) if task == "auto" else task
    if resolved not in ("classification", "regression"):
        from qmlkit.utils.errors import unknown

        raise unknown("task", resolved, ("classification", "regression", "auto"))

    notes: list[str] = []
    rng = np.random.default_rng(seed)
    if max_samples is not None and data.shape[0] > max_samples:
        keep = rng.choice(data.shape[0], size=max_samples, replace=False)
        data, target = data[keep], target[keep]
        notes.append(f"subsampled to {max_samples} of {len(y)} rows before scoring")

    metric = metric or ("balanced_accuracy" if resolved == "classification" else "r2")
    if resolved == "classification":
        folds = stratified_folds(target, n_folds=cv, seed=seed)
    else:
        order = rng.permutation(data.shape[0])
        chunks = np.array_split(order, cv)
        folds = [(np.setdiff1d(order, chunk), chunk) for chunk in chunks]

    specs = [s for s in _BASELINES.values() if s.task == resolved]
    if include is not None:
        wanted = set(include)
        missing = wanted - {s.name for s in specs}
        if missing:
            from qmlkit.utils.errors import unknown

            raise unknown(f"{resolved} baseline", sorted(missing)[0], [s.name for s in specs])
        specs = [s for s in specs if s.name in wanted]

    rows: list[BaselineRow] = []
    for spec in specs:
        if not _available(spec.requires):
            rows.append(
                BaselineRow(
                    spec.name,
                    float("nan"),
                    float("nan"),
                    skipped=f"needs {spec.requires} (pip install 'qmlkit[{spec.requires}]')",
                )
            )
            continue
        scores = [
            _score(
                resolved,
                target[test],
                _fit_predict(spec.factory(), data[train], target[train], data[test]),
                metric,
            )
            for train, test in folds
        ]
        rows.append(
            BaselineRow(spec.name, float(np.mean(scores)), float(np.std(scores)), tuple(scores))
        )

    if model is not None:
        name = getattr(model, "__name__", type(model).__name__)
        try:
            scores = [
                _score(
                    resolved,
                    target[test],
                    _fit_predict(
                        model()
                        if callable(model) and not hasattr(model, "fit")
                        else copy.deepcopy(model),
                        data[train],
                        target[train],
                        data[test],
                    ),
                    metric,
                )
                for train, test in folds
            ]
        except Exception as exc:  # the table is still worth having without the model
            rows.append(
                BaselineRow(name, float("nan"), float("nan"), is_model=True, skipped=str(exc))
            )
        else:
            rows.append(
                BaselineRow(
                    name,
                    float(np.mean(scores)),
                    float(np.std(scores)),
                    tuple(scores),
                    is_model=True,
                )
            )
        if fit_kwargs:
            notes.append("fit_kwargs are ignored by estimators that do not accept them")

    if resolved == "classification":
        from qmlkit.imbalance import imbalance_ratio

        ratio = imbalance_ratio(target)
        if ratio >= 1.5:
            notes.append(
                f"classes are {ratio:.1f}:1, so the ranking metric is {metric} rather than "
                "accuracy; qk.imbalance.imbalance_report(y) lists the remedies"
            )
    return BaselineTable(
        resolved, metric, int(target.size), cv, tuple(rows), tuple(notes), {"folds": folds}
    )

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

Reduction(
    name: str, circuits: int, factor: float, trade: str
)

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, ...] = (),
)

The circuit budget for a training run, and the ways to shrink it.

circuits_per_gradient property
circuits_per_gradient: int

The per-gradient factor, so the arithmetic in the printout checks out.

hours
hours(seconds_per_circuit: float) -> float

Wall-clock at a given per-circuit latency. A queued device is ~0.5-2 s.

Source code in src/qmlkit/budget.py
def hours(self, seconds_per_circuit: float) -> float:
    """Wall-clock at a given per-circuit latency. A queued device is ~0.5-2 s."""
    return self.circuits * seconds_per_circuit / 3600.0

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:~qmlkit.ansatz.library.Ansatz, a :class:~qmlkit.core.ir.CircuitSpec, or a model carrying one.

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" is the default because it is what a device would use; pass "adjoint" to see the simulator 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 for an exact simulator.

None

Returns:

Type Description
Plan

Printable, and carrying the numbers so a caller can branch on them.

Source code in src/qmlkit/budget.py
def 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
    ----------
    model:
        An :class:`~qmlkit.ansatz.library.Ansatz`, a
        :class:`~qmlkit.core.ir.CircuitSpec`, or a model carrying one.
    n_samples, steps:
        The training set size and the number of optimiser steps. The product is
        how many gradients get taken.
    method:
        Which gradient rule to cost. ``"parameter-shift"`` is the default because
        it is what a device would use; pass ``"adjoint"`` to see the simulator cost.
    obs:
        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.
    shots:
        Shots per circuit, or ``None`` for an exact simulator.

    Returns
    -------
    Plan
        Printable, and carrying the numbers so a caller can branch on them.
    """
    spec, default_obs = _as_spec(model)
    observable = obs if obs is not None else default_obs
    terms = len(as_sum(observable).terms)
    settings = max(len(group_qubit_wise_commuting(observable)), 1)

    cost = gradient_cost(spec, method)
    if isinstance(cost, str):  # a method whose cost is not a fixed circuit count
        raise ValueError(
            f"the {method!r} method reports its cost as {cost!r}, so it cannot be planned"
        )

    per_gradient = int(cost) * settings
    circuits = per_gradient * n_samples * steps
    notes: list[str] = []

    reductions: list[Reduction] = []
    for alternative in ("adjoint", "hadamard", "parameter-shift", "spsa"):
        if alternative == method:
            continue
        try:
            other = gradient_cost(spec, alternative)
        except (KeyError, ValueError):  # pragma: no cover - registry dependent
            continue
        if isinstance(other, str):
            continue
        total = int(other) * settings * n_samples * steps
        if total < circuits:
            reductions.append(
                Reduction(
                    alternative,
                    total,
                    circuits / total,
                    _METHOD_NOTES.get(alternative, ""),
                )
            )
    if terms > settings:
        notes.append(
            f"qubit-wise-commuting grouping is already saving {terms / settings:.1f}x "
            f"({terms} terms in {settings} settings)"
        )
    if spec.n_params == 0:
        notes.append("this circuit has no parameters, so a gradient costs nothing to take")

    return Plan(
        circuits=circuits,
        shots_total=circuits * shots if shots else None,
        method=method,
        n_params=spec.n_params,
        n_samples=n_samples,
        steps=steps,
        shots=shots,
        measurement_settings=settings,
        observable_terms=terms,
        reductions=tuple(sorted(reductions, key=lambda r: r.circuits)),
        notes=tuple(notes),
    )

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
as_dict() -> dict[str, Any]

A plain, JSON-serialisable mapping.

Source code in src/qmlkit/provenance.py
def as_dict(self) -> dict[str, Any]:
    """A plain, JSON-serialisable mapping."""
    return {
        "qmlkit": self.qmlkit,
        "python": self.python,
        "platform": self.platform,
        "numpy": self.numpy,
        "default_backend": self.default_backend,
        "backends": dict(self.backends),
        "optional": dict(self.optional),
        "seed": self.seed,
        **self.extra,
    }

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
def 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.
    """
    from qmlkit import __version__
    from qmlkit.core.backends.registry import default_backend

    try:
        current = default_backend().name
    except Exception:  # pragma: no cover - a broken default should not break the record
        current = "unavailable"

    return Fingerprint(
        qmlkit=__version__,
        python=sys.version.split()[0],
        platform=f"{platform.system()} {platform.release()} ({platform.machine()})",
        numpy=np.__version__,
        default_backend=current,
        backends={name: _version(name) for name in ("qiskit", "cirq", "spinqit")},
        optional={name: _version(name) for name in ("torch", "sklearn", "matplotlib")},
        seed=seed,
        extra=dict(extra),
    )

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
def 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.
    """
    from qmlkit.core.backends.registry import available_backends, get_backend
    from qmlkit.core.execute import expectation
    from qmlkit.diagnostics import Finding, Report

    values = np.asarray(theta, dtype=float)
    findings: list[Finding] = []

    routes = _gradient_routes(spec, values, obs, backend)
    if spec.n_params == 0:
        # every route returns an empty gradient, which agrees trivially and says
        # nothing. Report that rather than comparing zero-length arrays.
        findings.append(
            Finding(
                "selfcheck.one-route",
                "info",
                "this circuit has no parameters, so there is no gradient to cross-check",
                "selfcheck compares gradients; for a fixed circuit the backend "
                "comparison below is the whole check",
                0.0,
            )
        )
    elif len(routes) < 2:
        findings.append(
            Finding(
                "selfcheck.one-route",
                "info",
                f"only {len(routes)} exact gradient route could run here "
                f"({', '.join(routes) or 'none'}), so nothing was cross-checked",
                "pip install 'qmlkit[torch]' adds backprop as a second opinion",
                float(len(routes)),
            )
        )
    else:
        names = list(routes)
        reference = names[0]
        for name in names[1:]:
            delta = float(np.max(np.abs(routes[name] - routes[reference])))
            if delta > _AGREEMENT:
                findings.append(
                    Finding(
                        "selfcheck.gradient-disagreement",
                        "error",
                        f"{name} and {reference} disagree by {delta:.3e}, which is far above "
                        f"the {_AGREEMENT:.0e} these exact methods agree to. One of them is "
                        "computing something else",
                        "compare against a third method to see which one stands alone; a "
                        "custom gate with wrong `frequencies` is the usual cause",
                        delta,
                    )
                )

    if cross_backend:
        installed = [n for n in available_backends() if n != "numpy"]
        if installed:
            reference_value = expectation(spec, obs, values, backend="numpy")
            for name in installed:
                try:
                    other = expectation(spec, obs, values, backend=get_backend(name))
                except Exception as exc:  # noqa: BLE001 - report, do not raise
                    findings.append(
                        Finding(
                            "selfcheck.backend-failed",
                            "warning",
                            f"the {name!r} backend could not run this circuit: {exc}",
                            "qk.backend_report() lists what is installed and working",
                        )
                    )
                    continue
                delta = abs(other - reference_value)
                if delta > _BACKEND_AGREEMENT:
                    findings.append(
                        Finding(
                            "selfcheck.backend-disagreement",
                            "error",
                            f"{name} gives {other:.12g} where the NumPy reference gives "
                            f"{reference_value:.12g} (difference {delta:.3e})",
                            f"qk.get_backend({name!r}).to_{name}(spec) shows the translated "
                            "circuit; bit order and controlled-gate qubit order are where "
                            "backends differ",
                            delta,
                        )
                    )

    subject = f"circuit ({spec.n_qubits} qubits, {spec.n_params} parameters)"
    return Report(subject, tuple(findings))

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

FocalLoss(
    gamma: float = 2.0,
    weight: Tensor | None = None,
    reduction: str = "mean",
)

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:class_weight_tensor returns. Focal loss and class weighting compose — the first addresses easy examples, the second abundant ones, and a badly skewed problem usually has both.

None
reduction str

"mean", "sum" or "none", matching torch's convention.

'mean'
Source code in src/qmlkit/nn/losses.py
def __init__(
    self,
    gamma: float = 2.0,
    weight: torch.Tensor | None = None,
    reduction: str = "mean",
) -> None:
    super().__init__()
    if gamma < 0:
        raise ValueError(f"gamma must be non-negative, got {gamma}")
    if reduction not in ("mean", "sum", "none"):
        raise ValueError(f"reduction must be 'mean', 'sum' or 'none', got {reduction!r}")
    self.gamma = float(gamma)
    self.reduction = reduction
    # a buffer, so .to(device) and state_dict() both carry the weights.
    # register_buffer types as Tensor | Module, so keep a narrowed alias for use.
    self.register_buffer("weight", weight)
    self.class_weight: torch.Tensor | None = weight

class_weight_tensor

class_weight_tensor(
    y: Any,
    scheme: str = "balanced",
    n_classes: int | None = None,
) -> 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
def class_weight_tensor(
    y: Any, scheme: str = "balanced", n_classes: int | None = None
) -> torch.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.
    """
    table = class_weights(y, scheme)
    size = n_classes if n_classes is not None else int(max(table)) + 1
    weights = np.ones(size, dtype=float)
    for label, weight in table.items():
        index = int(label)
        if not 0 <= index < size:
            raise ValueError(
                f"label {label!r} is outside the {size} classes this model has; "
                "class weights index the output layer, so labels must be 0..n_classes-1"
            )
        weights[index] = weight
    return torch.as_tensor(weights, dtype=torch.get_default_dtype())

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
def weighted_cross_entropy(
    y: Any, scheme: str = "balanced", n_classes: int | None = None
) -> nn.CrossEntropyLoss:
    """``CrossEntropyLoss`` already carrying the class weights for ``y``."""
    return nn.CrossEntropyLoss(weight=class_weight_tensor(y, scheme, n_classes))

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:

  1. It must not change any number. Recording is a counter and a clock; nothing here touches a state, an angle or a seed.
  2. 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.
  3. 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 estimating rather 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

TaskRecord(
    label: str, items: int, seconds: float, depth: int = 0
)

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
def __init__(self, owner: Progress, label: str, total: int | None, depth: int) -> None:
    self.label = label
    self.total = total
    self.done = 0
    self.started = time.perf_counter()
    self._owner = owner
    self._depth = depth
remaining property
remaining: float | None

Seconds left at the rate measured so far, or None when that is a guess.

Deliberately refuses to answer early. An extrapolation from a handful of items in under a second says more about scheduling noise than about the run.

advance
advance(n: int = 1) -> None

Count n more items done, and redraw if it has been long enough.

Source code in src/qmlkit/progress.py
def advance(self, n: int = 1) -> None:
    """Count ``n`` more items done, and redraw if it has been long enough."""
    self.done += n
    self._owner._maybe_draw()

Progress

Progress(stream: IO[str] | None = None, live: bool = True)

Collects what the run is doing, and optionally shows it live.

Source code in src/qmlkit/progress.py
def __init__(self, stream: IO[str] | None = None, live: bool = True) -> None:
    self.records: list[TaskRecord] = []
    self.series: dict[str, list[tuple[int, float]]] = {}
    self.meta: dict[str, Any] = {}
    self.started = time.perf_counter()
    self.started_at = time.time()
    self._stack: list[Task] = []
    self._stream = stream if stream is not None else sys.stderr
    self._live = live
    self._last_draw = 0.0
    self._drawn = 0
task
task(label: str, total: int | None = None) -> Task

Begin a unit of work. Use as a context manager.

Source code in src/qmlkit/progress.py
def task(self, label: str, total: int | None = None) -> Task:
    """Begin a unit of work. Use as a context manager."""
    task = Task(self, label, total, depth=len(self._stack))
    self._stack.append(task)
    self._draw(force=True)
    return task
log
log(
    name: str, value: float, step: int | None = None
) -> None

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
def log(self, name: str, value: float, step: int | None = None) -> None:
    """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.
    """
    points = self.series.setdefault(name, [])
    points.append((len(points) if step is None else step, float(value)))
note
note(**facts: Any) -> None

Attach run-level facts - shapes, seeds, configuration - to the record.

Source code in src/qmlkit/progress.py
def note(self, **facts: Any) -> None:
    """Attach run-level facts - shapes, seeds, configuration - to the record."""
    self.meta.update(facts)
report
report() -> str

What the run spent its time on, worst first within each nesting level.

Source code in src/qmlkit/progress.py
def report(self) -> str:
    """What the run spent its time on, worst first within each nesting level."""
    if not self.records:
        return f"Run finished in {_human(self.elapsed)}; nothing was tracked."
    lines = [f"Run finished in {_human(self.elapsed)}"]
    lines.extend(str(record) for record in self.records)
    tracked = sum(r.seconds for r in self.records if r.depth == 0)
    untracked = self.elapsed - tracked
    if untracked > 0.05 * self.elapsed and untracked > 0.1:
        lines.append(
            f"  {'(untracked)':<24}{'':>10}      {untracked:>8.1f}s"
            "   - setup, data handling, and anything outside a task"
        )
        # On a first run this row is usually an optional SDK being imported
        # lazily, which is not work the run did and not worth chasing. Measured:
        # `import sklearn.svm` alone is ~1.8s, and the same run warm accounts for
        # 98% of its own time.
        lines.append(
            f"  {'':<24}{'':>10}              "
            "  on a first run this is mostly one-time imports; re-run to see"
        )
    return "\n".join(lines)
html
html(title: str = 'qmlkit run') -> str

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
def html(self, title: str = "qmlkit run") -> str:
    """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.
    """
    from qmlkit.report import render

    return render(self, title=title)
save_html
save_html(path: str, title: str = 'qmlkit run') -> str

Write :meth:html to path and return the path.

Source code in src/qmlkit/progress.py
def save_html(self, path: str, title: str = "qmlkit run") -> str:
    """Write :meth:`html` to ``path`` and return the path."""
    with open(path, "w", encoding="utf-8") as handle:
        handle.write(self.html(title=title))
    return path

current

current() -> Progress | None

The active :class:Progress, or None when nothing is watching.

Source code in src/qmlkit/progress.py
def current() -> Progress | None:
    """The active :class:`Progress`, or ``None`` when nothing is watching."""
    return _CURRENT

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 stream as the run proceeds. False records silently, which is what a script or a test wants.

True
stream IO[str] | None

Where the live line goes. Defaults to sys.stderr, so piping a script's stdout to a file does not collect progress redraws.

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
@contextmanager
def progress(live: bool = True, stream: IO[str] | None = None) -> Iterator[Progress]:
    """Watch a run, and keep what it did.

    Parameters
    ----------
    live
        Draw a line to ``stream`` as the run proceeds. ``False`` records silently,
        which is what a script or a test wants.
    stream
        Where the live line goes. Defaults to ``sys.stderr``, so piping a script's
        stdout to a file does not collect progress redraws.

    Notes
    -----
    Nothing outside this block is affected: with no active reporter, the tracking
    calls inside the library cost one comparison against ``None``.
    """
    global _CURRENT
    previous = _CURRENT
    reporter = Progress(stream=stream, live=live)
    _CURRENT = reporter
    try:
        yield reporter
    finally:
        reporter._clear()
        _CURRENT = previous

track

track(
    iterable: Iterable[T],
    label: str,
    total: int | None = None,
) -> Iterator[T]

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
def track(iterable: Iterable[T], label: str, total: int | None = None) -> Iterator[T]:
    """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.
    """
    reporter = _CURRENT
    if reporter is None:
        yield from iterable
        return
    if total is None:
        try:
            total = len(iterable)  # type: ignore[arg-type]
        except TypeError:
            total = None
    with reporter.task(label, total) as task:
        for item in iterable:
            yield item
            task.advance()

task

task(label: str, total: int | None = None) -> Iterator[Any]

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
@contextmanager
def task(label: str, total: int | None = None) -> Iterator[Any]:
    """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.
    """
    reporter = _CURRENT
    if reporter is None:
        yield _SILENT
        return
    with reporter.task(label, total) as live:
        yield live

log

log(
    name: str, value: float, step: int | None = None
) -> None

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
def log(name: str, value: float, step: int | None = None) -> None:
    """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.
    """
    reporter = _CURRENT
    if reporter is not None:
        reporter.log(name, value, step)

note

note(**facts: Any) -> None

Attach run-level facts to the active run, or do nothing if none is active.

Source code in src/qmlkit/progress.py
def note(**facts: Any) -> None:
    """Attach run-level facts to the active run, or do nothing if none is active."""
    reporter = _CURRENT
    if reporter is not None:
        reporter.note(**facts)

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
def render(run: Progress, title: str = "qmlkit run") -> str:
    """The run as one self-contained HTML page."""
    charts = "".join(_line_chart(name, points) for name, points in sorted(run.series.items()))
    series_block = (
        f"<h2>What the run did</h2>{charts}"
        if charts
        else "<h2>What the run did</h2><p class='empty'>No series were logged. "
        "A training loop logs its loss here; anything else can call "
        "<code>qmlkit.progress.log(name, value)</code>.</p>"
    )
    return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{_esc(title)}</title><style>{_CSS}</style></head>
<body><div class="wrap">
<h1>{_esc(title)}</h1>
<p class="sub">{len(run.records)} task(s) · {_human(run.elapsed)} · generated by qmlkit</p>
<h2>Where the time went</h2>
{_timing_table(run)}
{series_block}
<h2>What it was running on</h2>
{_facts(run)}
<footer>Every number here was measured during the run it describes.
Nothing on this page was estimated or rounded from a different run.</footer>
</div></body></html>
"""

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:

  1. What was wrong (unknown gradient method 'parameter_shift')
  2. What was probably meant (Did you mean 'parameter-shift'?)
  3. 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

did_you_mean(
    got: object, valid: Iterable[str], n: int = 3
) -> tuple[str, ...]

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
def did_you_mean(got: object, valid: Iterable[str], n: int = 3) -> tuple[str, ...]:
    """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"])
    ()
    """
    options = list(dict.fromkeys(str(v) for v in valid))
    text = str(got)
    squashed = [v for v in options if _squash(v) == _squash(text)]
    if squashed:
        return tuple(squashed[:n])
    return tuple(difflib.get_close_matches(text, options, n=n, cutoff=0.6))

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
def 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.
    """
    if error is KeyError:  # keep the quotes in the message readable
        error = UnknownName
    options = sorted(dict.fromkeys(str(v) for v in valid))
    near = did_you_mean(got, options)
    parts = [f"unknown {kind} {got!r}."]
    if near:
        parts.append("Did you mean " + " or ".join(repr(s) for s in near) + "?")
    if options:
        parts.append(f"Valid: {', '.join(options)}.")
    if hint:
        parts.append(hint)
    return error(" ".join(parts))

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.

Source code in src/qmlkit/utils/errors.py
def 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.
    """
    plural = unit if expected == 1 else f"{unit}s"
    parts = [f"{what} expects {expected} {plural}, got {got}."]
    if hint:
        parts.append(hint)
    return error(" ".join(parts))