Source code for quantum_mind.evaluation.statistics

"""Paired statistical tests for model-comparison experiments."""
from __future__ import annotations

from itertools import combinations
import numpy as np

from .metrics import _validate_binary, brier_score, log_loss


def _paired_metric(y_true, p_a, p_b, metric):
    y, a = _validate_binary(y_true, p_a)
    _, b = _validate_binary(y_true, p_b)
    return float(metric(y, a) - metric(y, b))


def _per_observation_losses(y, p, metric):
    p = np.clip(np.asarray(p, float), 1e-15, 1 - 1e-15)
    y = np.asarray(y, float)
    if metric is log_loss:
        return -(y * np.log(p) + (1 - y) * np.log1p(-p))
    if metric is brier_score:
        return (p - y) ** 2
    return None


[docs] def paired_bootstrap_difference(y_true, p_a, p_b, metric, n_boot=2000, confidence=0.95, rng=None, chunk_size=256): """Estimate a paired bootstrap CI for metric(A) - metric(B).""" y, a = _validate_binary(y_true, p_a) _, b = _validate_binary(y_true, p_b) if not callable(metric): raise TypeError("metric must be callable") if n_boot < 100: raise ValueError("n_boot must be at least 100") if not 0 < confidence < 1: raise ValueError("confidence must be in (0, 1)") if chunk_size < 1: raise ValueError("chunk_size must be positive") generator = np.random.default_rng(rng) n = len(y) estimates = np.empty(n_boot, dtype=float) offset = 0 while offset < n_boot: size = min(chunk_size, n_boot - offset) indices = generator.integers(0, n, size=(size, n)) estimates[offset:offset + size] = [ _paired_metric(y[idx], a[idx], b[idx], metric) for idx in indices ] offset += size alpha = (1.0 - confidence) / 2.0 return { "difference": _paired_metric(y, a, b, metric), "ci_low": float(np.quantile(estimates, alpha)), "ci_high": float(np.quantile(estimates, 1.0 - alpha)), "confidence": float(confidence), "n_boot": int(n_boot), }
[docs] def paired_permutation_test(y_true, p_a, p_b, metric, n_permutations=5000, rng=None): """Run a paired randomisation test by swapping model labels per observation.""" y, a = _validate_binary(y_true, p_a) _, b = _validate_binary(y_true, p_b) if not callable(metric): raise TypeError("metric must be callable") if n_permutations < 100: raise ValueError("n_permutations must be at least 100") observed = _paired_metric(y, a, b, metric) generator = np.random.default_rng(rng) extreme = 0 for _ in range(n_permutations): swap = generator.integers(0, 2, size=len(y), dtype=np.int8).astype(bool) statistic = _paired_metric(y, np.where(swap, b, a), np.where(swap, a, b), metric) extreme += int(abs(statistic) >= abs(observed)) return { "difference": observed, "p_value": float((1.0 + extreme) / (n_permutations + 1.0)), "n_permutations": int(n_permutations), }
[docs] def paired_effect_size(y_true, p_a, p_b, metric): """Return standardised paired loss difference for log loss or Brier score.""" y, a = _validate_binary(y_true, p_a) _, b = _validate_binary(y_true, p_b) loss_a = _per_observation_losses(y, a, metric) loss_b = _per_observation_losses(y, b, metric) if loss_a is None: return {"effect_size": None} delta = loss_a - loss_b scale = float(np.std(delta, ddof=1)) return {"effect_size": float(np.mean(delta) / scale) if scale > 0 else 0.0}
[docs] def holm_bonferroni(p_values, alpha=0.05): """Apply Holm's step-down correction and return adjusted p-values.""" p = np.asarray(p_values, float) if p.ndim != 1 or not len(p) or np.any(~np.isfinite(p)) or np.any((p < 0) | (p > 1)): raise ValueError("p_values must be a non-empty finite vector in [0, 1]") if not 0 < alpha < 1: raise ValueError("alpha must be in (0, 1)") order = np.argsort(p) adjusted = np.empty(len(p), dtype=float) running = 0.0 for rank, idx in enumerate(order): running = max(running, min(1.0, (len(p) - rank) * p[idx])) adjusted[idx] = running return {"adjusted_p_values": adjusted.tolist(), "alpha": float(alpha), "significant": (adjusted < alpha).tolist()}
[docs] def compare_models(y_true, predictions, metric, n_boot=2000, n_permutations=5000, seed=0): """Compare every pair of named probability predictions with paired tests.""" if not predictions: raise ValueError("predictions must not be empty") names = list(predictions) rng = np.random.default_rng(seed) results = [] for a, b in combinations(names, 2): bootstrap = paired_bootstrap_difference( y_true, predictions[a], predictions[b], metric, n_boot=n_boot, rng=rng ) permutation = paired_permutation_test( y_true, predictions[a], predictions[b], metric, n_permutations=n_permutations, rng=rng ) results.append({ "model_a": a, "model_b": b, **bootstrap, **permutation, **paired_effect_size(y_true, predictions[a], predictions[b], metric) }) correction = holm_bonferroni([r["p_value"] for r in results]) for result, adjusted, significant in zip( results, correction["adjusted_p_values"], correction["significant"] ): result["p_value_holm"] = adjusted result["significant_holm"] = significant return results