File size: 4,966 Bytes
3510f1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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)}