File size: 9,074 Bytes
54e9405
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
#!/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