file2 / lib /stats.py
milkyroad's picture
Upload folder using huggingface_hub
54e9405 verified
Raw History Blame Contribute Delete
9.07 kB
#!/usr/bin/env python3
"""lab.lib.stats β€” reusable statistics for clustered, multi-seed experiments.
Extracted verbatim from sandbox/stage2/stat_hardening_g/aggregate.py (the
30-run hardening study) and generalized. Conventions:
- Class ids follow G_hf_dataset: 0=malignant tumor (MT), 1=non-malignant
lesion (NML), 2=non-ROI. `minority` selects the class of interest.
- Cluster-aware everywhere: frames within one lesion/patient are NOT iid.
- Multi-seed by default: report meanΒ±std over seeds; compare configs with
per-seed McNemar + Holm correction across the family of tests.
Typical use:
from lab.lib.stats import mcnemar_exact, holm, wilson, lesion_cluster_bootstrap
res = mcnemar_exact(pred_a, pred_b, true) # exact two-sided p
adj = holm([r["p"] for r in seed_results]) # family-wise control
ci = wilson(k, n) # proportion CI
bs = lesion_cluster_bootstrap(true, pred, lids) # cluster bootstrap CI
"""
from __future__ import annotations
from collections import defaultdict
import numpy as np
from scipy import stats as st
__all__ = [
"CLASS_NAMES", "mean_std", "wilson",
"lesion_cluster_bootstrap", "mcnemar_exact", "pooled_mcnemar_exact",
"holm", "benjamini_hochberg", "majority_vote",
]
# G_hf_dataset 3-class convention (verify against dataset metadata if a new
# variant reorders classes).
CLASS_NAMES = ["malignant tumor", "non-malignant lesion", "non-ROI"]
def valid_lid(lid) -> bool:
"""True when a frame belongs to an actual lesion cluster (not None/NaN)."""
if lid is None:
return False
if isinstance(lid, float) and np.isnan(lid):
return False
return True
# ── Descriptives ──────────────────────────────────────────────────────────────
def mean_std(vals):
"""(mean, std[ddof=1], n); std=0.0 for single value; None for empty."""
vals = [v for v in vals if v is not None]
if not vals:
return None, None, 0
sd = float(np.std(vals, ddof=1)) if len(vals) > 1 else 0.0
return float(np.mean(vals)), sd, len(vals)
def wilson(k: int, n: int, alpha: float = 0.05):
"""Wilson score interval for a binomial proportion. Returns (lo, hi)."""
if n == 0:
return (0.0, 0.0)
z = st.norm.ppf(1 - alpha / 2)
ph = k / n
den = 1 + z**2 / n
ctr = (ph + z**2 / (2 * n)) / den
hw = z * np.sqrt(ph * (1 - ph) / n + z**2 / (4 * n**2)) / den
return ctr - hw, ctr + hw
# ── Cluster bootstrap ─────────────────────────────────────────────────────────
def lesion_cluster_bootstrap(true, pred, lesion_ids,
metric: str = "nml_recall",
minority: int = 1,
n_boot: int = 10_000, seed: int = 123,
alpha: float = 5.0):
"""Percentile bootstrap CIs resampling LESIONS (not frames).
All frames of a lesion move together β€” this respects the clustering that
makes frame-iid intervals anti-conservative. Frames with invalid
lesion_ids carry no cluster structure and are dropped.
metric:
- "nml_recall" β†’ recall of `minority` class over selected frames
- "mcc" β†’ Matthews corrcoef over selected frames (needs β‰₯2 classes)
- callable(t, p) β†’ any custom statistic
"""
rng = np.random.default_rng(seed)
clusters: dict[str, list[int]] = defaultdict(list)
for i, lid in enumerate(lesion_ids):
if valid_lid(lid):
clusters[str(lid)].append(i)
idx_by_lid = [np.array(v) for v in clusters.values()]
L = len(idx_by_lid)
def stat(sel_idx):
t = np.asarray(true)[sel_idx]
p = np.asarray(pred)[sel_idx]
if callable(metric):
return float(metric(t, p))
if metric == "nml_recall":
denom = (t == minority).sum()
return float((p[t == minority] == minority).sum() / denom) if denom else np.nan
if metric == "mcc":
from sklearn.metrics import matthews_corrcoef
if len(set(t.tolist())) < 2:
return np.nan
return float(matthews_corrcoef(t, p))
raise ValueError(f"unknown metric {metric!r}")
point = stat(np.concatenate(idx_by_lid))
boots = []
for _ in range(n_boot):
pick = rng.integers(0, L, L)
v = stat(np.concatenate([idx_by_lid[j] for j in pick]))
if not np.isnan(v):
boots.append(v)
lo, hi = np.percentile(boots, [alpha / 2, 100 - alpha / 2])
return {"point": float(point), "ci_low": float(lo), "ci_high": float(hi),
"n_lesions": L, "n_boot": len(boots)}
# ── Paired comparisons ────────────────────────────────────────────────────────
def mcnemar_exact(pred_a, pred_b, true):
"""Exact two-sided McNemar on discordant error pairs (same eval set).
b = A wrong & B right, c = A right & B wrong.
p = P(X ≀ min(b,c)) Β· 2 over Binomial(n=b+c, 0.5), capped at 1.
"""
ea = np.asarray(pred_a) != np.asarray(true)
eb = np.asarray(pred_b) != np.asarray(true)
b = int((ea & ~eb).sum())
c = int((~ea & eb).sum())
n = b + c
if n == 0:
return {"b": b, "c": c, "p": 1.0}
k = min(b, c)
p = sum(st.binom.pmf(i, n, 0.5) for i in range(0, k + 1)) * 2
return {"b": b, "c": c, "p": float(min(p, 1.0))}
def pooled_mcnemar_exact(pair_results):
"""Pool (b, c) counts from multiple seeds into one exact test.
Use when the same comparison is repeated across seeds on the same eval
frames and you want the aggregate direction + significance. Report BOTH
pooled and per-seed-Holm results β€” pooling can mask sign flips.
"""
b = sum(r["b"] for r in pair_results)
c = sum(r["c"] for r in pair_results)
n = b + c
if n == 0:
return {"b": b, "c": c, "p": 1.0}
k = min(b, c)
p = sum(st.binom.pmf(i, n, 0.5) for i in range(0, k + 1)) * 2
return {"b": b, "c": c, "p": float(min(p, 1.0))}
# ── Multiple-comparison correction ────────────────────────────────────────────
def holm(pvals):
"""Holm–Bonferroni step-down. Returns adjusted p-values, original order."""
pvals = np.asarray(pvals, dtype=float)
m = len(pvals)
order = np.argsort(pvals)
adj = np.empty(m)
running = 0.0
for rank, idx in enumerate(order):
val = (m - rank) * pvals[idx]
running = max(running, val)
adj[idx] = min(1.0, running)
return adj
def benjamini_hochberg(pvals):
"""BH FDR correction. Returns adjusted q-values, original order."""
pvals = np.asarray(pvals, dtype=float)
m = len(pvals)
order = np.argsort(pvals)[::-1] # descending
adj = np.empty(m)
running = 0.0
for rank, idx in enumerate(order): # rank = m-1 … 0
val = pvals[idx] * m / (rank + 1)
running = max(running, val)
adj[idx] = min(1.0, running)
return adj
# ── Aggregation rules (frames β†’ lesions) ──────────────────────────────────────
def majority_vote(pred, lesion_ids, n_classes: int = 3):
"""Per-lesson hard majority vote broadcast to frames.
WARNING (hardening result, 2026-08-21): majority voting suppresses the
minority class under domain shift (NML recall 0.26β†’0.02 at unchanged
MCC). Do not use when minority recall matters; kept for completeness and
as the negative-control aggregation rule.
"""
l2i = defaultdict(list)
for i, lid in enumerate(lesion_ids):
if valid_lid(lid):
l2i[lid].append(i)
result = np.array(pred, copy=True)
for _lid, idx in l2i.items():
result[idx] = np.bincount(
result[idx], minlength=max(n_classes, len(np.unique(result)))
).argmax()
return result
def mean_confidence_vote(probabilities, lesion_ids, n_classes: int = 3):
"""Per-lesion average of frame probabilities, argmax β†’ label, broadcast.
Minority-friendlier alternative to hard majority vote: soft evidence is
averaged before the decision instead of after, so a confident minority
block inside a lesion is not erased by a slim non-lesion plurality.
Not yet validated in the hardening study β€” treat as experimental.
"""
l2i = defaultdict(list)
for i, lid in enumerate(lesion_ids):
if valid_lid(lid):
l2i[lid].append(i)
probabilities = np.asarray(probabilities, dtype=float)
result = probabilities.argmax(1).copy()
for _lid, idx in l2i.items():
result[idx] = probabilities[idx].mean(0).argmax()
return result