tracegrpo / src /eval /stats.py
Dhruv1000's picture
Add files using upload-large-folder tool
5bfc214 verified
Raw History Blame Contribute Delete
5.02 kB
"""Statistical testing (spec §10.1): bootstrap 95% CIs at the pair level,
paired comparisons across seeds (Wilcoxon signed-rank + Cohen's d), Holm
correction across RQ families.
"""
from __future__ import annotations
import numpy as np
from scipy import stats as scipy_stats
from typing import Callable, TypeVar
Pair = TypeVar("Pair")
def bootstrap_metric_ci(
pairs: list[Pair], metric_fn: Callable[[list[Pair]], float],
n_resamples: int = 10_000, ci: float = 0.95, seed: int = 42,
) -> tuple[float, float, float]:
"""Pair-level CI for any metric, including non-additive F1/MCC/PR-AUC.
The same sampled pair indices must be used for both halves of each pair.
"""
if not pairs:
raise ValueError("cannot bootstrap an empty pair set")
if n_resamples <= 0 or not 0 < ci < 1:
raise ValueError("n_resamples and ci must be positive and valid")
rng = np.random.default_rng(seed)
estimate = float(metric_fn(pairs))
n = len(pairs)
values = np.empty(n_resamples)
for i in range(n_resamples):
indices = rng.integers(0, n, size=n)
values[i] = float(metric_fn([pairs[j] for j in indices]))
alpha = (1 - ci) / 2
return estimate, float(np.quantile(values, alpha)), float(np.quantile(values, 1 - alpha))
def mcnemar_label_correct(a: list[bool], b: list[bool]) -> dict:
"""Exact two-sided McNemar test for paired label correctness (OOD, n=134).
Each position must refer to the same pair in both model outputs. Exact
binomial inference remains valid when discordant counts are small.
"""
if len(a) != len(b) or not a:
raise ValueError("paired correctness vectors must have equal nonzero length")
a_only = sum(bool(x) and not bool(y) for x, y in zip(a, b))
b_only = sum(not bool(x) and bool(y) for x, y in zip(a, b))
both = sum(bool(x) and bool(y) for x, y in zip(a, b))
neither = len(a) - a_only - b_only - both
discordant = a_only + b_only
p_value = float(scipy_stats.binomtest(min(a_only, b_only), discordant, 0.5).pvalue) if discordant else 1.0
return {"n_pairs": len(a), "both_correct": both, "a_only_correct": a_only,
"b_only_correct": b_only, "neither_correct": neither,
"p_value": p_value, "method": "exact_two_sided_mcnemar"}
def bootstrap_ci(
values: list[float], n_resamples: int = 10_000, ci: float = 0.95, seed: int = 42
) -> tuple[float, float, float]:
"""Returns (point_estimate, ci_low, ci_high) via the percentile bootstrap
over the pair-level metric values (spec: "bootstrap 95% CIs (10k
resamples) at the pair level")."""
values = np.asarray(values, dtype=float)
if len(values) == 0:
return (0.0, 0.0, 0.0)
rng = np.random.default_rng(seed)
point = float(np.mean(values))
n = len(values)
resample_means = np.empty(n_resamples)
for i in range(n_resamples):
sample = rng.choice(values, size=n, replace=True)
resample_means[i] = sample.mean()
alpha = (1 - ci) / 2
lo = float(np.quantile(resample_means, alpha))
hi = float(np.quantile(resample_means, 1 - alpha))
return (point, lo, hi)
def cohens_d_paired(a: list[float], b: list[float]) -> float:
"""Cohen's d for paired samples (a - b), using the pooled SD of the
differences — the standard formulation for a paired design."""
a, b = np.asarray(a, dtype=float), np.asarray(b, dtype=float)
diffs = a - b
sd = diffs.std(ddof=1)
if sd == 0:
return 0.0
return float(diffs.mean() / sd)
def wilcoxon_signed_rank(a: list[float], b: list[float]) -> dict:
"""Paired comparison across seeds (spec §10.1). Returns the statistic,
p-value, and Cohen's d. Falls back gracefully when all paired differences
are zero (scipy raises in that degenerate case)."""
a, b = np.asarray(a, dtype=float), np.asarray(b, dtype=float)
diffs = a - b
if np.allclose(diffs, 0):
return {"statistic": 0.0, "p_value": 1.0, "cohens_d": 0.0}
stat, p = scipy_stats.wilcoxon(a, b)
return {"statistic": float(stat), "p_value": float(p), "cohens_d": cohens_d_paired(a, b)}
def holm_correction(p_values: dict[str, float], alpha: float = 0.05) -> dict[str, dict]:
"""Holm-Bonferroni step-down correction across a named family of
hypotheses (spec: "Holm correction across RQ families"). Returns, per
name, the adjusted alpha threshold used at its rank and whether it's
significant under the family-wise correction."""
ranked = sorted(p_values.items(), key=lambda kv: kv[1])
m = len(ranked)
out: dict[str, dict] = {}
reject_all_remaining = False
for i, (name, p) in enumerate(ranked):
threshold = alpha / (m - i)
significant = (not reject_all_remaining) and (p <= threshold)
if not significant:
reject_all_remaining = True # Holm: once one fails, all later (larger p) fail too
out[name] = {"p_value": p, "threshold": threshold, "significant": significant, "rank": i + 1}
return out