| """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 |
|
|
|
|
| |
| 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] |
| |
| 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") |
| 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} |
|
|
|
|
| |
| 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)} |
|
|
|
|
| |
| 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)} |
|
|