pystatsbio 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pystatsbio/__init__.py +24 -0
- pystatsbio/diagnostic/__init__.py +27 -0
- pystatsbio/diagnostic/_accuracy.py +193 -0
- pystatsbio/diagnostic/_batch.py +291 -0
- pystatsbio/diagnostic/_common.py +110 -0
- pystatsbio/diagnostic/_cutoff.py +114 -0
- pystatsbio/diagnostic/_roc.py +386 -0
- pystatsbio/doseresponse/__init__.py +50 -0
- pystatsbio/doseresponse/_batch.py +332 -0
- pystatsbio/doseresponse/_bmd.py +256 -0
- pystatsbio/doseresponse/_common.py +123 -0
- pystatsbio/doseresponse/_fit.py +334 -0
- pystatsbio/doseresponse/_models.py +242 -0
- pystatsbio/doseresponse/_potency.py +172 -0
- pystatsbio/pk/__init__.py +19 -0
- pystatsbio/pk/_common.py +57 -0
- pystatsbio/pk/_nca.py +480 -0
- pystatsbio/power/__init__.py +39 -0
- pystatsbio/power/_anova.py +281 -0
- pystatsbio/power/_cluster.py +145 -0
- pystatsbio/power/_common.py +149 -0
- pystatsbio/power/_crossover.py +192 -0
- pystatsbio/power/_means.py +243 -0
- pystatsbio/power/_noninferiority.py +433 -0
- pystatsbio/power/_proportions.py +211 -0
- pystatsbio/power/_survival.py +242 -0
- pystatsbio/py.typed +0 -0
- pystatsbio-1.0.0.dist-info/METADATA +287 -0
- pystatsbio-1.0.0.dist-info/RECORD +31 -0
- pystatsbio-1.0.0.dist-info/WHEEL +4 -0
- pystatsbio-1.0.0.dist-info/licenses/LICENSE +21 -0
pystatsbio/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyStatsBio: Biotech and pharmaceutical statistical computing for Python.
|
|
3
|
+
|
|
4
|
+
Built on top of pystatistics for the general statistical computing layer.
|
|
5
|
+
PyStatsBio provides domain-specific methods for the drug development pipeline:
|
|
6
|
+
dose-response modeling, sample size/power, diagnostic accuracy, and pharmacokinetics.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from pystatsbio import power, doseresponse, diagnostic, pk
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "1.0.0"
|
|
13
|
+
__author__ = "Hai-Shuo"
|
|
14
|
+
__email__ = "contact@sgcx.org"
|
|
15
|
+
|
|
16
|
+
from pystatsbio import diagnostic, doseresponse, pk, power
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"__version__",
|
|
20
|
+
"power",
|
|
21
|
+
"doseresponse",
|
|
22
|
+
"diagnostic",
|
|
23
|
+
"pk",
|
|
24
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diagnostic accuracy analysis for biomarker evaluation.
|
|
3
|
+
|
|
4
|
+
ROC analysis, sensitivity/specificity, predictive values, likelihood ratios,
|
|
5
|
+
and high-throughput batch AUC computation for biomarker panel screening.
|
|
6
|
+
|
|
7
|
+
Validates against: R packages pROC, OptimalCutpoints, epiR.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pystatsbio.diagnostic._accuracy import diagnostic_accuracy
|
|
11
|
+
from pystatsbio.diagnostic._batch import BatchAUCResult, batch_auc
|
|
12
|
+
from pystatsbio.diagnostic._common import DiagnosticResult, ROCResult
|
|
13
|
+
from pystatsbio.diagnostic._cutoff import CutoffResult, optimal_cutoff
|
|
14
|
+
from pystatsbio.diagnostic._roc import ROCTestResult, roc, roc_test
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ROCResult",
|
|
18
|
+
"DiagnosticResult",
|
|
19
|
+
"ROCTestResult",
|
|
20
|
+
"CutoffResult",
|
|
21
|
+
"BatchAUCResult",
|
|
22
|
+
"roc",
|
|
23
|
+
"roc_test",
|
|
24
|
+
"diagnostic_accuracy",
|
|
25
|
+
"optimal_cutoff",
|
|
26
|
+
"batch_auc",
|
|
27
|
+
]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Sensitivity, specificity, predictive values, and likelihood ratios.
|
|
2
|
+
|
|
3
|
+
Computes a comprehensive set of diagnostic accuracy metrics at a fixed
|
|
4
|
+
cutoff: sensitivity/specificity with exact (Clopper-Pearson) or Wilson
|
|
5
|
+
CIs, PPV/NPV with optional prevalence adjustment, likelihood ratios
|
|
6
|
+
(LR+/LR−), and diagnostic odds ratio (DOR) with log-scale CI.
|
|
7
|
+
|
|
8
|
+
Validates against: R ``epiR::epi.tests()``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from numpy.typing import NDArray
|
|
15
|
+
from scipy import stats
|
|
16
|
+
|
|
17
|
+
from pystatsbio.diagnostic._common import DiagnosticResult
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# CI helpers for binomial proportions
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def _clopper_pearson_ci(
|
|
24
|
+
k: int, n: int, conf_level: float,
|
|
25
|
+
) -> tuple[float, float]:
|
|
26
|
+
"""Exact Clopper-Pearson CI for binomial proportion k/n."""
|
|
27
|
+
alpha = 1 - conf_level
|
|
28
|
+
if k == 0:
|
|
29
|
+
lo = 0.0
|
|
30
|
+
hi = 1.0 - (alpha / 2) ** (1.0 / n)
|
|
31
|
+
elif k == n:
|
|
32
|
+
lo = (alpha / 2) ** (1.0 / n)
|
|
33
|
+
hi = 1.0
|
|
34
|
+
else:
|
|
35
|
+
lo = float(stats.beta.ppf(alpha / 2, k, n - k + 1))
|
|
36
|
+
hi = float(stats.beta.ppf(1 - alpha / 2, k + 1, n - k))
|
|
37
|
+
return lo, hi
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _wilson_ci(
|
|
41
|
+
k: int, n: int, conf_level: float,
|
|
42
|
+
) -> tuple[float, float]:
|
|
43
|
+
"""Wilson score CI for binomial proportion k/n."""
|
|
44
|
+
p_hat = k / n
|
|
45
|
+
z = stats.norm.ppf((1 + conf_level) / 2)
|
|
46
|
+
z2 = z ** 2
|
|
47
|
+
denom = 1 + z2 / n
|
|
48
|
+
centre = (p_hat + z2 / (2 * n)) / denom
|
|
49
|
+
margin = z / denom * np.sqrt(p_hat * (1 - p_hat) / n + z2 / (4 * n ** 2))
|
|
50
|
+
return float(centre - margin), float(centre + margin)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _binomial_ci(
|
|
54
|
+
k: int, n: int, conf_level: float, method: str,
|
|
55
|
+
) -> tuple[float, float]:
|
|
56
|
+
"""Dispatch to the requested CI method."""
|
|
57
|
+
if method == "clopper-pearson":
|
|
58
|
+
return _clopper_pearson_ci(k, n, conf_level)
|
|
59
|
+
elif method == "wilson":
|
|
60
|
+
return _wilson_ci(k, n, conf_level)
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"ci_method must be 'clopper-pearson' or 'wilson', got {method!r}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Public API
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def diagnostic_accuracy(
|
|
72
|
+
response: NDArray[np.integer],
|
|
73
|
+
predictor: NDArray[np.floating],
|
|
74
|
+
*,
|
|
75
|
+
cutoff: float,
|
|
76
|
+
direction: str = "<",
|
|
77
|
+
prevalence: float | None = None,
|
|
78
|
+
conf_level: float = 0.95,
|
|
79
|
+
ci_method: str = "clopper-pearson",
|
|
80
|
+
) -> DiagnosticResult:
|
|
81
|
+
"""Compute diagnostic accuracy metrics at a fixed cutoff.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
response : array of int
|
|
86
|
+
Binary outcome (0/1).
|
|
87
|
+
predictor : array of float
|
|
88
|
+
Continuous predictor.
|
|
89
|
+
cutoff : float
|
|
90
|
+
Classification threshold.
|
|
91
|
+
direction : str
|
|
92
|
+
``'<'`` means predictor ≥ cutoff is classified positive
|
|
93
|
+
(controls < cases, higher values = disease).
|
|
94
|
+
``'>'`` means predictor ≤ cutoff is classified positive
|
|
95
|
+
(controls > cases, lower values = disease).
|
|
96
|
+
prevalence : float or None
|
|
97
|
+
Disease prevalence for PPV/NPV adjustment via Bayes' theorem.
|
|
98
|
+
If ``None``, uses sample prevalence.
|
|
99
|
+
conf_level : float
|
|
100
|
+
Confidence level.
|
|
101
|
+
ci_method : str
|
|
102
|
+
``'clopper-pearson'`` (exact) or ``'wilson'``.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
DiagnosticResult
|
|
107
|
+
|
|
108
|
+
Validates against: R ``epiR::epi.tests()``
|
|
109
|
+
"""
|
|
110
|
+
response = np.asarray(response, dtype=np.intp)
|
|
111
|
+
predictor = np.asarray(predictor, dtype=np.float64)
|
|
112
|
+
|
|
113
|
+
if response.ndim != 1 or predictor.ndim != 1:
|
|
114
|
+
raise ValueError("response and predictor must be 1-D")
|
|
115
|
+
if len(response) != len(predictor):
|
|
116
|
+
raise ValueError("response and predictor must have equal length")
|
|
117
|
+
if direction not in ("<", ">"):
|
|
118
|
+
raise ValueError(f"direction must be '<' or '>', got {direction!r}")
|
|
119
|
+
if not 0 < conf_level < 1:
|
|
120
|
+
raise ValueError(f"conf_level must be in (0, 1), got {conf_level}")
|
|
121
|
+
|
|
122
|
+
# Classify
|
|
123
|
+
predicted_pos = predictor >= cutoff if direction == "<" else predictor <= cutoff
|
|
124
|
+
|
|
125
|
+
actual_pos = response == 1
|
|
126
|
+
|
|
127
|
+
TP = int(np.sum(predicted_pos & actual_pos))
|
|
128
|
+
FP = int(np.sum(predicted_pos & ~actual_pos))
|
|
129
|
+
FN = int(np.sum(~predicted_pos & actual_pos))
|
|
130
|
+
TN = int(np.sum(~predicted_pos & ~actual_pos))
|
|
131
|
+
|
|
132
|
+
n1 = TP + FN # total positives
|
|
133
|
+
n0 = TN + FP # total negatives
|
|
134
|
+
|
|
135
|
+
if n1 == 0 or n0 == 0:
|
|
136
|
+
raise ValueError("Need at least one case and one control")
|
|
137
|
+
|
|
138
|
+
# Sensitivity and specificity
|
|
139
|
+
sens = TP / n1
|
|
140
|
+
spec = TN / n0
|
|
141
|
+
|
|
142
|
+
sens_ci = _binomial_ci(TP, n1, conf_level, ci_method)
|
|
143
|
+
spec_ci = _binomial_ci(TN, n0, conf_level, ci_method)
|
|
144
|
+
|
|
145
|
+
# Prevalence
|
|
146
|
+
if prevalence is None:
|
|
147
|
+
prev = n1 / (n1 + n0)
|
|
148
|
+
else:
|
|
149
|
+
if not 0 < prevalence < 1:
|
|
150
|
+
raise ValueError(f"prevalence must be in (0, 1), got {prevalence}")
|
|
151
|
+
prev = prevalence
|
|
152
|
+
|
|
153
|
+
# PPV / NPV (with optional prevalence adjustment via Bayes)
|
|
154
|
+
ppv_denom = sens * prev + (1 - spec) * (1 - prev)
|
|
155
|
+
npv_denom = (1 - sens) * prev + spec * (1 - prev)
|
|
156
|
+
ppv = (sens * prev / ppv_denom) if ppv_denom > 0 else float("nan")
|
|
157
|
+
npv = (spec * (1 - prev) / npv_denom) if npv_denom > 0 else float("nan")
|
|
158
|
+
|
|
159
|
+
# Likelihood ratios
|
|
160
|
+
lr_pos = sens / (1 - spec) if (1 - spec) > 0 else float("inf")
|
|
161
|
+
lr_neg = (1 - sens) / spec if spec > 0 else float("inf")
|
|
162
|
+
|
|
163
|
+
# Diagnostic odds ratio (with 0.5 Haldane correction if any cell is 0)
|
|
164
|
+
z = stats.norm.ppf((1 + conf_level) / 2)
|
|
165
|
+
if TP == 0 or FP == 0 or FN == 0 or TN == 0:
|
|
166
|
+
TPc = TP + 0.5
|
|
167
|
+
FPc = FP + 0.5
|
|
168
|
+
FNc = FN + 0.5
|
|
169
|
+
TNc = TN + 0.5
|
|
170
|
+
else:
|
|
171
|
+
TPc, FPc, FNc, TNc = float(TP), float(FP), float(FN), float(TN)
|
|
172
|
+
|
|
173
|
+
dor = (TPc * TNc) / (FPc * FNc)
|
|
174
|
+
log_dor_se = np.sqrt(1 / TPc + 1 / FPc + 1 / FNc + 1 / TNc)
|
|
175
|
+
dor_lo = np.exp(np.log(dor) - z * log_dor_se)
|
|
176
|
+
dor_hi = np.exp(np.log(dor) + z * log_dor_se)
|
|
177
|
+
|
|
178
|
+
return DiagnosticResult(
|
|
179
|
+
cutoff=float(cutoff),
|
|
180
|
+
sensitivity=float(sens),
|
|
181
|
+
sensitivity_ci=sens_ci,
|
|
182
|
+
specificity=float(spec),
|
|
183
|
+
specificity_ci=spec_ci,
|
|
184
|
+
ppv=float(ppv),
|
|
185
|
+
npv=float(npv),
|
|
186
|
+
lr_positive=float(lr_pos),
|
|
187
|
+
lr_negative=float(lr_neg),
|
|
188
|
+
dor=float(dor),
|
|
189
|
+
dor_ci=(float(dor_lo), float(dor_hi)),
|
|
190
|
+
prevalence=float(prev),
|
|
191
|
+
conf_level=conf_level,
|
|
192
|
+
method=ci_method,
|
|
193
|
+
)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Batch AUC computation for high-throughput biomarker panels.
|
|
2
|
+
|
|
3
|
+
Computes AUC (Mann-Whitney U / (n1*n0)) and DeLong standard errors for
|
|
4
|
+
many biomarker candidates simultaneously. The CPU path uses
|
|
5
|
+
``scipy.stats.rankdata`` column-wise. The GPU path uses PyTorch's
|
|
6
|
+
batched ``argsort`` for ranking and a vectorised masked sum.
|
|
7
|
+
|
|
8
|
+
GPU is beneficial when ``n_markers > 100``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
import torch
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class BatchAUCResult:
|
|
25
|
+
"""Result of batch AUC computation across multiple biomarkers."""
|
|
26
|
+
|
|
27
|
+
auc: NDArray[np.floating] # shape (n_markers,)
|
|
28
|
+
se: NDArray[np.floating] # DeLong SE for each
|
|
29
|
+
n_markers: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# CPU path
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def _batch_auc_cpu(
|
|
37
|
+
response: NDArray, predictors: NDArray,
|
|
38
|
+
) -> BatchAUCResult:
|
|
39
|
+
"""Column-wise AUC + DeLong SE on CPU."""
|
|
40
|
+
from scipy import stats as sp_stats
|
|
41
|
+
|
|
42
|
+
N, M = predictors.shape
|
|
43
|
+
case_mask = response == 1
|
|
44
|
+
n1 = int(case_mask.sum())
|
|
45
|
+
n0 = N - n1
|
|
46
|
+
|
|
47
|
+
auc_arr = np.empty(M)
|
|
48
|
+
se_arr = np.empty(M)
|
|
49
|
+
|
|
50
|
+
for m in range(M):
|
|
51
|
+
col = predictors[:, m]
|
|
52
|
+
|
|
53
|
+
# Pooled ranks (midranks for ties)
|
|
54
|
+
pooled_ranks = sp_stats.rankdata(col, method="average")
|
|
55
|
+
|
|
56
|
+
# AUC via Mann-Whitney
|
|
57
|
+
sum_case_ranks = pooled_ranks[case_mask].sum()
|
|
58
|
+
auc_m = (sum_case_ranks - n1 * (n1 + 1) / 2) / (n1 * n0)
|
|
59
|
+
|
|
60
|
+
# DeLong placement values
|
|
61
|
+
case_ranks_within = sp_stats.rankdata(col[case_mask], method="average")
|
|
62
|
+
ctrl_ranks_within = sp_stats.rankdata(col[~case_mask], method="average")
|
|
63
|
+
|
|
64
|
+
V10 = (pooled_ranks[case_mask] - case_ranks_within) / n0
|
|
65
|
+
V01 = 1.0 - (pooled_ranks[~case_mask] - ctrl_ranks_within) / n1
|
|
66
|
+
|
|
67
|
+
S10 = np.var(V10, ddof=1) if n1 > 1 else 0.0
|
|
68
|
+
S01 = np.var(V01, ddof=1) if n0 > 1 else 0.0
|
|
69
|
+
var_auc = S10 / n1 + S01 / n0
|
|
70
|
+
|
|
71
|
+
auc_arr[m] = auc_m
|
|
72
|
+
se_arr[m] = np.sqrt(var_auc)
|
|
73
|
+
|
|
74
|
+
return BatchAUCResult(auc=auc_arr, se=se_arr, n_markers=M)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# GPU path
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def _batch_auc_gpu(
|
|
82
|
+
response: NDArray, predictors: NDArray,
|
|
83
|
+
) -> BatchAUCResult:
|
|
84
|
+
"""Batched AUC + DeLong SE on GPU via PyTorch.
|
|
85
|
+
|
|
86
|
+
Uses batched argsort for column-wise ranking, then a single
|
|
87
|
+
matrix-vector product for the Mann-Whitney sum across all markers.
|
|
88
|
+
"""
|
|
89
|
+
import torch
|
|
90
|
+
|
|
91
|
+
# Select device
|
|
92
|
+
if torch.cuda.is_available():
|
|
93
|
+
device = torch.device("cuda")
|
|
94
|
+
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
|
95
|
+
device = torch.device("mps")
|
|
96
|
+
else:
|
|
97
|
+
device = torch.device("cpu")
|
|
98
|
+
|
|
99
|
+
# MPS uses float32, others float64
|
|
100
|
+
dtype = torch.float32 if device.type == "mps" else torch.float64
|
|
101
|
+
|
|
102
|
+
N, M = predictors.shape
|
|
103
|
+
case_mask_np = response == 1
|
|
104
|
+
n1 = int(case_mask_np.sum())
|
|
105
|
+
n0 = N - n1
|
|
106
|
+
|
|
107
|
+
pred_t = torch.from_numpy(predictors).to(device=device, dtype=dtype)
|
|
108
|
+
|
|
109
|
+
# Column-wise ranking via argsort-of-argsort (handles ties with average)
|
|
110
|
+
# For GPU efficiency, use the argsort approach:
|
|
111
|
+
# rank[i] = position of element i in sorted order
|
|
112
|
+
# For ties, we need midranks which requires more work.
|
|
113
|
+
# Use argsort twice: argsort gives sorted indices, inverting gives ranks.
|
|
114
|
+
sorted_indices = pred_t.argsort(dim=0) # (N, M)
|
|
115
|
+
ranks = torch.empty_like(pred_t)
|
|
116
|
+
base_ranks = torch.arange(1, N + 1, device=device, dtype=dtype).unsqueeze(1).expand(N, M)
|
|
117
|
+
ranks.scatter_(0, sorted_indices, base_ranks)
|
|
118
|
+
|
|
119
|
+
# Midrank correction for ties
|
|
120
|
+
# Group equal values and assign their mean rank (per-column loop)
|
|
121
|
+
sorted_vals = pred_t.gather(0, sorted_indices) # (N, M) sorted
|
|
122
|
+
sorted_ranks = (
|
|
123
|
+
torch.arange(1, N + 1, device=device, dtype=dtype).unsqueeze(1).expand(N, M).clone()
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Group boundaries in sorted order
|
|
127
|
+
for m_idx in range(M):
|
|
128
|
+
col_sorted = sorted_vals[:, m_idx]
|
|
129
|
+
col_ranks = sorted_ranks[:, m_idx]
|
|
130
|
+
# Find runs of equal values
|
|
131
|
+
i = 0
|
|
132
|
+
while i < N:
|
|
133
|
+
j = i + 1
|
|
134
|
+
while j < N and col_sorted[j] == col_sorted[i]:
|
|
135
|
+
j += 1
|
|
136
|
+
if j > i + 1:
|
|
137
|
+
# Tied block [i, j): assign midrank
|
|
138
|
+
midrank = (i + 1 + j) / 2.0
|
|
139
|
+
col_ranks[i:j] = midrank
|
|
140
|
+
i = j
|
|
141
|
+
|
|
142
|
+
# Scatter midranks back to original positions
|
|
143
|
+
pooled_ranks_gpu = torch.empty_like(pred_t)
|
|
144
|
+
pooled_ranks_gpu.scatter_(0, sorted_indices, sorted_ranks)
|
|
145
|
+
|
|
146
|
+
# AUC: sum of case ranks - n1*(n1+1)/2, divided by n1*n0
|
|
147
|
+
case_mask_t = torch.from_numpy(case_mask_np).to(device=device)
|
|
148
|
+
case_ranks_pooled = pooled_ranks_gpu[case_mask_t] # (n1, M)
|
|
149
|
+
sum_case_ranks = case_ranks_pooled.sum(dim=0) # (M,)
|
|
150
|
+
auc_t = (sum_case_ranks - n1 * (n1 + 1) / 2) / (n1 * n0)
|
|
151
|
+
|
|
152
|
+
# DeLong SE: need within-group ranks for cases and controls separately
|
|
153
|
+
# Rank cases among cases only
|
|
154
|
+
case_pred = pred_t[case_mask_t] # (n1, M)
|
|
155
|
+
ctrl_pred = pred_t[~case_mask_t] # (n0, M)
|
|
156
|
+
|
|
157
|
+
case_within_ranks = _midranks_gpu(case_pred, device, dtype) # (n1, M)
|
|
158
|
+
ctrl_within_ranks = _midranks_gpu(ctrl_pred, device, dtype) # (n0, M)
|
|
159
|
+
|
|
160
|
+
# Placement values
|
|
161
|
+
V10 = (pooled_ranks_gpu[case_mask_t] - case_within_ranks) / n0 # (n1, M)
|
|
162
|
+
V01 = 1.0 - (pooled_ranks_gpu[~case_mask_t] - ctrl_within_ranks) / n1 # (n0, M)
|
|
163
|
+
|
|
164
|
+
# Variance per marker
|
|
165
|
+
auc_expanded = auc_t.unsqueeze(0) # (1, M)
|
|
166
|
+
if n1 > 1:
|
|
167
|
+
S10 = ((V10 - auc_expanded) ** 2).sum(dim=0) / (n1 - 1) # (M,)
|
|
168
|
+
else:
|
|
169
|
+
S10 = torch.zeros(M, device=device, dtype=dtype)
|
|
170
|
+
if n0 > 1:
|
|
171
|
+
S01 = ((V01 - auc_expanded) ** 2).sum(dim=0) / (n0 - 1) # (M,)
|
|
172
|
+
else:
|
|
173
|
+
S01 = torch.zeros(M, device=device, dtype=dtype)
|
|
174
|
+
|
|
175
|
+
var_auc = S10 / n1 + S01 / n0
|
|
176
|
+
se_t = torch.sqrt(var_auc)
|
|
177
|
+
|
|
178
|
+
return BatchAUCResult(
|
|
179
|
+
auc=auc_t.cpu().numpy().astype(np.float64),
|
|
180
|
+
se=se_t.cpu().numpy().astype(np.float64),
|
|
181
|
+
n_markers=M,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _midranks_gpu(
|
|
186
|
+
data: torch.Tensor,
|
|
187
|
+
device: torch.device,
|
|
188
|
+
dtype: torch.dtype,
|
|
189
|
+
) -> torch.Tensor:
|
|
190
|
+
"""Compute midranks column-wise for a (N, M) tensor on GPU."""
|
|
191
|
+
import torch
|
|
192
|
+
|
|
193
|
+
N, M = data.shape
|
|
194
|
+
sorted_indices = data.argsort(dim=0)
|
|
195
|
+
sorted_vals = data.gather(0, sorted_indices)
|
|
196
|
+
ranks = torch.arange(1, N + 1, device=device, dtype=dtype).unsqueeze(1).expand(N, M).clone()
|
|
197
|
+
|
|
198
|
+
# Fix ties to midranks
|
|
199
|
+
for m_idx in range(M):
|
|
200
|
+
col_sorted = sorted_vals[:, m_idx]
|
|
201
|
+
col_ranks = ranks[:, m_idx]
|
|
202
|
+
i = 0
|
|
203
|
+
while i < N:
|
|
204
|
+
j = i + 1
|
|
205
|
+
while j < N and col_sorted[j] == col_sorted[i]:
|
|
206
|
+
j += 1
|
|
207
|
+
if j > i + 1:
|
|
208
|
+
col_ranks[i:j] = (i + 1 + j) / 2.0
|
|
209
|
+
i = j
|
|
210
|
+
|
|
211
|
+
result = torch.empty_like(data)
|
|
212
|
+
result.scatter_(0, sorted_indices, ranks)
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Public API
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def batch_auc(
|
|
221
|
+
response: NDArray[np.integer],
|
|
222
|
+
predictors: NDArray[np.floating],
|
|
223
|
+
*,
|
|
224
|
+
backend: str = "auto",
|
|
225
|
+
) -> BatchAUCResult:
|
|
226
|
+
"""Compute AUC for many biomarker candidates simultaneously.
|
|
227
|
+
|
|
228
|
+
Parameters
|
|
229
|
+
----------
|
|
230
|
+
response : array of int, shape ``(n_samples,)``
|
|
231
|
+
Shared binary outcome (0/1).
|
|
232
|
+
predictors : array of float, shape ``(n_samples, n_markers)``
|
|
233
|
+
Matrix of biomarker values (one column per candidate marker).
|
|
234
|
+
backend : str
|
|
235
|
+
``'cpu'``, ``'gpu'``, or ``'auto'``.
|
|
236
|
+
|
|
237
|
+
Returns
|
|
238
|
+
-------
|
|
239
|
+
BatchAUCResult
|
|
240
|
+
|
|
241
|
+
Notes
|
|
242
|
+
-----
|
|
243
|
+
GPU backend is beneficial when ``n_markers > 100``. Uses rank-based
|
|
244
|
+
AUC computation which is embarrassingly parallel across markers.
|
|
245
|
+
DeLong standard errors are computed for each marker.
|
|
246
|
+
"""
|
|
247
|
+
response = np.asarray(response, dtype=np.intp)
|
|
248
|
+
predictors = np.asarray(predictors, dtype=np.float64)
|
|
249
|
+
|
|
250
|
+
if response.ndim != 1:
|
|
251
|
+
raise ValueError(f"response must be 1-D, got shape {response.shape}")
|
|
252
|
+
if predictors.ndim != 2:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"predictors must be 2-D (n_samples, n_markers), got shape {predictors.shape}"
|
|
255
|
+
)
|
|
256
|
+
if response.shape[0] != predictors.shape[0]:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
f"response length {response.shape[0]} != predictors rows {predictors.shape[0]}"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
unique_labels = np.unique(response)
|
|
262
|
+
if not np.array_equal(unique_labels, np.array([0, 1])):
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f"response must be binary (0/1), got unique values {unique_labels}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if not np.all(np.isfinite(predictors)):
|
|
268
|
+
raise ValueError(
|
|
269
|
+
"predictors contains NaN or infinite values. "
|
|
270
|
+
"Remove or impute missing values before calling batch_auc."
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if backend == "cpu":
|
|
274
|
+
return _batch_auc_cpu(response, predictors)
|
|
275
|
+
|
|
276
|
+
if backend == "gpu":
|
|
277
|
+
return _batch_auc_gpu(response, predictors)
|
|
278
|
+
|
|
279
|
+
# auto — try GPU, fall back to CPU
|
|
280
|
+
try:
|
|
281
|
+
import torch
|
|
282
|
+
|
|
283
|
+
has_gpu = torch.cuda.is_available() or (
|
|
284
|
+
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
|
|
285
|
+
)
|
|
286
|
+
if has_gpu:
|
|
287
|
+
return _batch_auc_gpu(response, predictors)
|
|
288
|
+
except ImportError:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
return _batch_auc_cpu(response, predictors)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Shared result types for diagnostic accuracy analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ROCResult:
|
|
13
|
+
"""Result of ROC analysis.
|
|
14
|
+
|
|
15
|
+
Attributes
|
|
16
|
+
----------
|
|
17
|
+
thresholds : array
|
|
18
|
+
Thresholds at which TPR/FPR are evaluated. Includes ``-inf``
|
|
19
|
+
and ``+inf`` so the curve always passes through (0,0) and (1,1).
|
|
20
|
+
tpr : array
|
|
21
|
+
True positive rate (sensitivity) at each threshold.
|
|
22
|
+
fpr : array
|
|
23
|
+
False positive rate (1 − specificity) at each threshold.
|
|
24
|
+
auc : float
|
|
25
|
+
Area under the ROC curve (Mann-Whitney U / (n1*n0)).
|
|
26
|
+
auc_se : float
|
|
27
|
+
DeLong standard error of the AUC.
|
|
28
|
+
auc_ci_lower, auc_ci_upper : float
|
|
29
|
+
Confidence interval for AUC (logit-transformed DeLong).
|
|
30
|
+
conf_level : float
|
|
31
|
+
Confidence level used for CI.
|
|
32
|
+
n_positive, n_negative : int
|
|
33
|
+
Number of positive (case) and negative (control) observations.
|
|
34
|
+
direction : str
|
|
35
|
+
``'<'`` (controls < cases) or ``'>'`` (controls > cases).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
thresholds: NDArray[np.floating]
|
|
39
|
+
tpr: NDArray[np.floating] # sensitivity / true positive rate
|
|
40
|
+
fpr: NDArray[np.floating] # 1 - specificity / false positive rate
|
|
41
|
+
auc: float
|
|
42
|
+
auc_se: float # DeLong standard error
|
|
43
|
+
auc_ci_lower: float
|
|
44
|
+
auc_ci_upper: float
|
|
45
|
+
conf_level: float
|
|
46
|
+
n_positive: int
|
|
47
|
+
n_negative: int
|
|
48
|
+
direction: str # '<' or '>'
|
|
49
|
+
|
|
50
|
+
def summary(self) -> str:
|
|
51
|
+
"""Human-readable summary."""
|
|
52
|
+
lines = [
|
|
53
|
+
"ROC Analysis",
|
|
54
|
+
"=" * 40,
|
|
55
|
+
f"Direction : controls {self.direction} cases",
|
|
56
|
+
f"AUC : {self.auc:.4f}",
|
|
57
|
+
f"DeLong SE : {self.auc_se:.4f}",
|
|
58
|
+
f"{self.conf_level:.0%} CI : [{self.auc_ci_lower:.4f}, {self.auc_ci_upper:.4f}]",
|
|
59
|
+
f"n positive : {self.n_positive}",
|
|
60
|
+
f"n negative : {self.n_negative}",
|
|
61
|
+
f"n thresholds: {len(self.thresholds)}",
|
|
62
|
+
]
|
|
63
|
+
return "\n".join(lines)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class DiagnosticResult:
|
|
68
|
+
"""Result of diagnostic accuracy evaluation at a fixed cutoff.
|
|
69
|
+
|
|
70
|
+
All CIs use the method specified in ``method`` (e.g.
|
|
71
|
+
``'clopper-pearson'`` for exact binomial CIs).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
cutoff: float
|
|
75
|
+
sensitivity: float
|
|
76
|
+
sensitivity_ci: tuple[float, float]
|
|
77
|
+
specificity: float
|
|
78
|
+
specificity_ci: tuple[float, float]
|
|
79
|
+
ppv: float
|
|
80
|
+
npv: float
|
|
81
|
+
lr_positive: float
|
|
82
|
+
lr_negative: float
|
|
83
|
+
dor: float # diagnostic odds ratio
|
|
84
|
+
dor_ci: tuple[float, float]
|
|
85
|
+
prevalence: float
|
|
86
|
+
conf_level: float
|
|
87
|
+
method: str # CI method, e.g. 'clopper-pearson'
|
|
88
|
+
|
|
89
|
+
def summary(self) -> str:
|
|
90
|
+
"""Human-readable summary."""
|
|
91
|
+
lines = [
|
|
92
|
+
"Diagnostic Accuracy",
|
|
93
|
+
"=" * 40,
|
|
94
|
+
f"Cutoff : {self.cutoff:.4g}",
|
|
95
|
+
f"Sensitivity : {self.sensitivity:.4f} "
|
|
96
|
+
f"({self.conf_level:.0%} CI: "
|
|
97
|
+
f"{self.sensitivity_ci[0]:.4f}–{self.sensitivity_ci[1]:.4f})",
|
|
98
|
+
f"Specificity : {self.specificity:.4f} "
|
|
99
|
+
f"({self.conf_level:.0%} CI: "
|
|
100
|
+
f"{self.specificity_ci[0]:.4f}–{self.specificity_ci[1]:.4f})",
|
|
101
|
+
f"PPV : {self.ppv:.4f}",
|
|
102
|
+
f"NPV : {self.npv:.4f}",
|
|
103
|
+
f"LR+ : {self.lr_positive:.4f}",
|
|
104
|
+
f"LR− : {self.lr_negative:.4f}",
|
|
105
|
+
f"DOR : {self.dor:.4f} "
|
|
106
|
+
f"({self.conf_level:.0%} CI: {self.dor_ci[0]:.4f}–{self.dor_ci[1]:.4f})",
|
|
107
|
+
f"Prevalence : {self.prevalence:.4f}",
|
|
108
|
+
f"CI method : {self.method}",
|
|
109
|
+
]
|
|
110
|
+
return "\n".join(lines)
|