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 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)