covtoken / eval /stats.py
Chucks90's picture
covtoken: label-free lesion-subspace token economy (reframed) + gated eval + paper draft
3510f1d verified
Raw
History Blame Contribute Delete
4.97 kB
"""Statistical methods — single source of truth (IMPLEMENTATION_SPEC §6).
- AUROC + DeLong test for AUROC differences, 95% CI.
- Paired bootstrap over cases (n=2000) for sensitivity/Dice differences; CI must exclude 0.
- Spearman rho with permutation p-value (n=5000) for coverage-vs-detection coupling.
"""
from __future__ import annotations
import numpy as np
from scipy import stats
# ----------------------------- AUROC + DeLong --------------------------------
def _compute_midrank(x: np.ndarray) -> np.ndarray:
J = np.argsort(x)
Z = x[J]
N = len(x)
T = np.zeros(N)
i = 0
while i < N:
j = i
while j < N and Z[j] == Z[i]:
j += 1
T[i:j] = 0.5 * (i + j - 1) + 1
i = j
T2 = np.empty(N)
T2[J] = T
return T2
def _fast_delong(predictions_sorted_transposed: np.ndarray, label_1_count: int):
"""DeLong covariance (Sun & Xu 2014 fast algorithm). Returns (aucs, cov)."""
m = label_1_count
n = predictions_sorted_transposed.shape[1] - m
pos = predictions_sorted_transposed[:, :m]
neg = predictions_sorted_transposed[:, m:]
k = predictions_sorted_transposed.shape[0]
tx = np.empty([k, m]); ty = np.empty([k, n]); tz = np.empty([k, m + n])
for r in range(k):
tx[r] = _compute_midrank(pos[r])
ty[r] = _compute_midrank(neg[r])
tz[r] = _compute_midrank(predictions_sorted_transposed[r])
aucs = tz[:, :m].sum(axis=1) / m / n - (m + 1.0) / 2.0 / n
v01 = (tz[:, :m] - tx) / n
v10 = 1.0 - (tz[:, m:] - ty) / m
sx = np.cov(v01); sy = np.cov(v10)
sx = np.atleast_2d(sx); sy = np.atleast_2d(sy)
cov = sx / m + sy / n
return aucs, cov
def auroc(scores: np.ndarray, labels: np.ndarray) -> float:
"""AUROC of `scores` against binary `labels` (1=positive)."""
scores = np.asarray(scores, float); labels = np.asarray(labels, int)
order = np.argsort(-scores)
s = labels[order]
# rank-based AUC
pos = labels.sum(); neg = len(labels) - pos
if pos == 0 or neg == 0:
return float("nan")
ranks = stats.rankdata(scores)
return float((ranks[labels == 1].sum() - pos * (pos + 1) / 2) / (pos * neg))
def delong_auc_ci(scores: np.ndarray, labels: np.ndarray, alpha: float = 0.05):
"""AUROC with DeLong 95% CI. Returns (auc, (lo, hi))."""
labels = np.asarray(labels, int); scores = np.asarray(scores, float)
order = np.argsort(-labels, kind="mergesort") # positives first
lab = labels[order]; sc = scores[order]
m = int(lab.sum())
aucs, cov = _fast_delong(sc[np.newaxis, :], m)
auc = float(aucs[0]); var = float(cov[0, 0]) if np.ndim(cov) else float(cov)
se = np.sqrt(max(var, 0.0))
z = stats.norm.ppf(1 - alpha / 2)
return auc, (max(0.0, auc - z * se), min(1.0, auc + z * se))
def delong_auc_diff_test(scores_a, scores_b, labels, alpha: float = 0.05):
"""Test AUROC(a) - AUROC(b) via DeLong. Returns dict with diff, ci, p (paired)."""
labels = np.asarray(labels, int)
order = np.argsort(-labels, kind="mergesort")
m = int(labels.sum())
preds = np.vstack([np.asarray(scores_a, float)[order],
np.asarray(scores_b, float)[order]])
aucs, cov = _fast_delong(preds, m)
diff = float(aucs[0] - aucs[1])
var = float(cov[0, 0] + cov[1, 1] - 2 * cov[0, 1])
se = np.sqrt(max(var, 1e-12))
z = diff / se
p = float(2 * (1 - stats.norm.cdf(abs(z))))
zc = stats.norm.ppf(1 - alpha / 2)
return {"auc_a": float(aucs[0]), "auc_b": float(aucs[1]), "diff": diff,
"ci95": [diff - zc * se, diff + zc * se], "p": p}
# ----------------------------- paired bootstrap ------------------------------
def paired_bootstrap_diff(values_a, values_b, n: int = 2000, alpha: float = 0.05,
seed: int = 0):
"""Paired bootstrap over cases for mean(a)-mean(b). Returns dict with diff, ci, excludes0."""
a = np.asarray(values_a, float); b = np.asarray(values_b, float)
assert a.shape == b.shape
rng = np.random.default_rng(seed)
N = len(a); diffs = np.empty(n)
base = float(a.mean() - b.mean())
for i in range(n):
idx = rng.integers(0, N, N)
diffs[i] = a[idx].mean() - b[idx].mean()
lo, hi = np.quantile(diffs, [alpha / 2, 1 - alpha / 2])
return {"diff": base, "ci95": [float(lo), float(hi)],
"excludes_0": bool(lo > 0 or hi < 0)}
# ----------------------------- Spearman permutation --------------------------
def spearman_perm(x, y, n: int = 5000, seed: int = 0):
"""Spearman rho with a permutation p-value. Returns dict rho, p, monotone."""
x = np.asarray(x, float); y = np.asarray(y, float)
rho = float(stats.spearmanr(x, y).statistic)
rng = np.random.default_rng(seed)
count = 0
for _ in range(n):
if abs(stats.spearmanr(x, rng.permutation(y)).statistic) >= abs(rho):
count += 1
p = (count + 1) / (n + 1)
return {"rho": rho, "p": float(p)}