evalsuite-python 0.1.2__py3-none-any.whl → 0.2.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.
- evalsuite/__init__.py +61 -1
- evalsuite/calibration.py +386 -0
- evalsuite/cli/main.py +85 -6
- evalsuite/clinical/__init__.py +18 -0
- evalsuite/clinical/metrics.py +330 -0
- evalsuite/clinical/report.py +360 -0
- evalsuite/plot.py +46 -1
- evalsuite/stats/__init__.py +22 -1
- evalsuite/stats/compare.py +1 -1
- evalsuite/stats/effect.py +45 -7
- evalsuite/stats/hypothesis.py +321 -0
- evalsuite/version.py +1 -1
- {evalsuite_python-0.1.2.dist-info → evalsuite_python-0.2.0.dist-info}/METADATA +68 -5
- {evalsuite_python-0.1.2.dist-info → evalsuite_python-0.2.0.dist-info}/RECORD +17 -12
- {evalsuite_python-0.1.2.dist-info → evalsuite_python-0.2.0.dist-info}/WHEEL +0 -0
- {evalsuite_python-0.1.2.dist-info → evalsuite_python-0.2.0.dist-info}/entry_points.txt +0 -0
- {evalsuite_python-0.1.2.dist-info → evalsuite_python-0.2.0.dist-info}/licenses/LICENSE +0 -0
evalsuite/__init__.py
CHANGED
|
@@ -5,8 +5,16 @@
|
|
|
5
5
|
>>> print(result.summary()) # doctest: +SKIP
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from . import classification, plot, regression, stats
|
|
8
|
+
from . import calibration, classification, clinical, plot, regression, stats
|
|
9
9
|
from .api import evaluate
|
|
10
|
+
from .calibration import (
|
|
11
|
+
CalibrationReport,
|
|
12
|
+
calibration_intercept,
|
|
13
|
+
calibration_report,
|
|
14
|
+
calibration_slope,
|
|
15
|
+
hosmer_lemeshow,
|
|
16
|
+
maximum_calibration_error,
|
|
17
|
+
)
|
|
10
18
|
from .classification import (
|
|
11
19
|
accuracy,
|
|
12
20
|
average_precision,
|
|
@@ -31,6 +39,19 @@ from .classification import (
|
|
|
31
39
|
specificity,
|
|
32
40
|
top_k_accuracy,
|
|
33
41
|
)
|
|
42
|
+
from .clinical import (
|
|
43
|
+
DecisionCurve,
|
|
44
|
+
DiagnosticReport,
|
|
45
|
+
decision_curve,
|
|
46
|
+
diagnostic_odds_ratio,
|
|
47
|
+
diagnostic_report,
|
|
48
|
+
lr_negative,
|
|
49
|
+
lr_positive,
|
|
50
|
+
net_benefit,
|
|
51
|
+
ppv,
|
|
52
|
+
sensitivity,
|
|
53
|
+
youden_j,
|
|
54
|
+
)
|
|
34
55
|
from .core.exceptions import (
|
|
35
56
|
EvalSuiteError,
|
|
36
57
|
InputValidationError,
|
|
@@ -69,19 +90,58 @@ from .stats import (
|
|
|
69
90
|
accuracy_ci,
|
|
70
91
|
adjust_pvalues,
|
|
71
92
|
bootstrap_ci,
|
|
93
|
+
chi_square_test,
|
|
72
94
|
cliffs_delta,
|
|
73
95
|
cohens_d,
|
|
74
96
|
compare,
|
|
97
|
+
cramers_v,
|
|
75
98
|
delong_test,
|
|
99
|
+
fisher_exact_test,
|
|
100
|
+
friedman_test,
|
|
76
101
|
hedges_g,
|
|
102
|
+
kruskal_wallis_test,
|
|
103
|
+
mann_whitney_test,
|
|
77
104
|
mcnemar_test,
|
|
78
105
|
paired_bootstrap_test,
|
|
106
|
+
paired_t_test,
|
|
79
107
|
proportion_ci,
|
|
80
108
|
roc_auc_ci,
|
|
109
|
+
shapiro_wilk_test,
|
|
110
|
+
t_test,
|
|
111
|
+
wilcoxon_test,
|
|
81
112
|
)
|
|
82
113
|
from .version import __version__
|
|
83
114
|
|
|
84
115
|
__all__ = [
|
|
116
|
+
"CalibrationReport",
|
|
117
|
+
"calibration_report",
|
|
118
|
+
"calibration",
|
|
119
|
+
"clinical",
|
|
120
|
+
"calibration_intercept",
|
|
121
|
+
"calibration_slope",
|
|
122
|
+
"hosmer_lemeshow",
|
|
123
|
+
"maximum_calibration_error",
|
|
124
|
+
"DecisionCurve",
|
|
125
|
+
"DiagnosticReport",
|
|
126
|
+
"decision_curve",
|
|
127
|
+
"diagnostic_odds_ratio",
|
|
128
|
+
"diagnostic_report",
|
|
129
|
+
"lr_negative",
|
|
130
|
+
"lr_positive",
|
|
131
|
+
"net_benefit",
|
|
132
|
+
"ppv",
|
|
133
|
+
"sensitivity",
|
|
134
|
+
"youden_j",
|
|
135
|
+
"chi_square_test",
|
|
136
|
+
"cramers_v",
|
|
137
|
+
"fisher_exact_test",
|
|
138
|
+
"friedman_test",
|
|
139
|
+
"kruskal_wallis_test",
|
|
140
|
+
"mann_whitney_test",
|
|
141
|
+
"paired_t_test",
|
|
142
|
+
"shapiro_wilk_test",
|
|
143
|
+
"t_test",
|
|
144
|
+
"wilcoxon_test",
|
|
85
145
|
"plot",
|
|
86
146
|
"expected_calibration_error",
|
|
87
147
|
"calibration_curve",
|
evalsuite/calibration.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Calibration of predicted probabilities: maximum calibration error, calibration slope and intercept
|
|
2
|
+
(logistic recalibration) and the Hosmer–Lemeshow goodness-of-fit test.
|
|
3
|
+
|
|
4
|
+
The calibration curve, expected calibration error and Brier score live in :mod:`evalsuite.classification`
|
|
5
|
+
and are re-exported here for convenience.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from numpy.typing import NDArray
|
|
17
|
+
from scipy import stats
|
|
18
|
+
|
|
19
|
+
from .classification.metrics import (
|
|
20
|
+
_binary_target,
|
|
21
|
+
_ctx,
|
|
22
|
+
brier_score,
|
|
23
|
+
calibration_curve,
|
|
24
|
+
expected_calibration_error,
|
|
25
|
+
)
|
|
26
|
+
from .core.exceptions import InputValidationError, StatisticalTestError, UnsupportedTaskError
|
|
27
|
+
from .core.export import PathLike, csv_text, html_document, html_table, save_as
|
|
28
|
+
from .core.registry import register
|
|
29
|
+
from .core.result import MetricResult, _fmt, _json_safe, _latex_escape, _latex_table
|
|
30
|
+
from .core.types import ArrayLike
|
|
31
|
+
from .stats.results import TestResult
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
import pandas as pd
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CalibrationReport",
|
|
38
|
+
"brier_score",
|
|
39
|
+
"calibration_report",
|
|
40
|
+
"calibration_curve",
|
|
41
|
+
"calibration_intercept",
|
|
42
|
+
"calibration_slope",
|
|
43
|
+
"expected_calibration_error",
|
|
44
|
+
"hosmer_lemeshow",
|
|
45
|
+
"maximum_calibration_error",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
_C = "calibration"
|
|
49
|
+
_EPS = 1e-15
|
|
50
|
+
_REF_VC = (
|
|
51
|
+
"Van Calster B, McLernon DJ, van Smeden M, Wynants L, Steyerberg EW. Calibration: the Achilles heel of "
|
|
52
|
+
"predictive analytics. BMC Med. 2019;17(1):230."
|
|
53
|
+
)
|
|
54
|
+
_REF_COX = "Cox DR. Two further applications of a model for binary regression. Biometrika. 1958;45(3-4):562-565."
|
|
55
|
+
_REF_HL = (
|
|
56
|
+
"Hosmer DW, Lemeshow S. Goodness of fit tests for the multiple logistic regression model. "
|
|
57
|
+
"Commun Stat Theory Methods. 1980;9(10):1043-1069."
|
|
58
|
+
)
|
|
59
|
+
_REF_NAEINI = (
|
|
60
|
+
"Naeini MP, Cooper GF, Hauskrecht M. Obtaining well calibrated probabilities using Bayesian binning. "
|
|
61
|
+
"AAAI 2015:2901-2907."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _binary(
|
|
66
|
+
y_true: ArrayLike, y_prob: ArrayLike, pos_label: Any, sample_weight: Optional[ArrayLike]
|
|
67
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
|
|
68
|
+
ctx = _ctx(y_true, None, y_prob=y_prob, sample_weight=sample_weight)
|
|
69
|
+
if ctx.target_type != "binary":
|
|
70
|
+
raise UnsupportedTaskError("Calibration measures here are for binary outcomes; use one-vs-rest per class.")
|
|
71
|
+
y = _binary_target(ctx, pos_label)
|
|
72
|
+
return y, np.asarray(ctx.y_prob, dtype=np.float64), np.asarray(ctx.weights, dtype=np.float64)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@register(
|
|
76
|
+
category=_C,
|
|
77
|
+
task="binary",
|
|
78
|
+
name="Maximum calibration error",
|
|
79
|
+
definition="Largest gap between observed frequency and mean predicted probability over probability bins.",
|
|
80
|
+
formula="MCE = max_b |acc_b − conf_b|",
|
|
81
|
+
range="[0, 1]",
|
|
82
|
+
input_requirements=("y_true", "y_prob"),
|
|
83
|
+
references=(_REF_NAEINI,),
|
|
84
|
+
higher_is_better=False,
|
|
85
|
+
)
|
|
86
|
+
def maximum_calibration_error(
|
|
87
|
+
y_true: ArrayLike,
|
|
88
|
+
y_prob: ArrayLike,
|
|
89
|
+
*,
|
|
90
|
+
n_bins: int = 10,
|
|
91
|
+
strategy: Literal["uniform", "quantile"] = "uniform",
|
|
92
|
+
pos_label: Any = None,
|
|
93
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
94
|
+
) -> MetricResult:
|
|
95
|
+
"""Maximum calibration error over the non-empty bins of :func:`calibration_curve`."""
|
|
96
|
+
prob_true, prob_pred, _ = calibration_curve(
|
|
97
|
+
y_true, y_prob, n_bins=n_bins, strategy=strategy, pos_label=pos_label, sample_weight=sample_weight
|
|
98
|
+
)
|
|
99
|
+
return MetricResult(
|
|
100
|
+
"maximum_calibration_error",
|
|
101
|
+
"MCE",
|
|
102
|
+
float(np.max(np.abs(prob_true - prob_pred))),
|
|
103
|
+
{"n_bins": int(n_bins), "strategy": strategy},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _logit(p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
108
|
+
q = np.clip(p, _EPS, 1 - _EPS)
|
|
109
|
+
return np.asarray(np.log(q / (1 - q)), dtype=np.float64)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def logistic_fit(
|
|
113
|
+
y: NDArray[np.float64],
|
|
114
|
+
x: NDArray[np.float64],
|
|
115
|
+
w: NDArray[np.float64],
|
|
116
|
+
*,
|
|
117
|
+
offset: bool,
|
|
118
|
+
max_iter: int = 100,
|
|
119
|
+
tol: float = 1e-12,
|
|
120
|
+
) -> NDArray[np.float64]:
|
|
121
|
+
"""Weighted maximum-likelihood logistic regression by Newton–Raphson.
|
|
122
|
+
|
|
123
|
+
``offset=True`` fits ``logit P(y=1) = a + x`` (returns ``[a]``); otherwise ``logit P(y=1) = a + b·x``
|
|
124
|
+
(returns ``[a, b]``).
|
|
125
|
+
"""
|
|
126
|
+
design = np.ones((x.shape[0], 1)) if offset else np.column_stack([np.ones_like(x), x])
|
|
127
|
+
off = x if offset else np.zeros_like(x)
|
|
128
|
+
beta = np.zeros(design.shape[1])
|
|
129
|
+
for _ in range(max_iter):
|
|
130
|
+
eta = design @ beta + off
|
|
131
|
+
mu = 1 / (1 + np.exp(-eta))
|
|
132
|
+
grad = design.T @ (w * (y - mu))
|
|
133
|
+
hess = (design * (w * mu * (1 - mu))[:, None]).T @ design
|
|
134
|
+
try:
|
|
135
|
+
step = np.linalg.solve(hess, grad)
|
|
136
|
+
except np.linalg.LinAlgError:
|
|
137
|
+
raise StatisticalTestError(
|
|
138
|
+
"The logistic recalibration model could not be fitted (singular information matrix); "
|
|
139
|
+
"the predictions may be constant or the outcome may be perfectly separated."
|
|
140
|
+
) from None
|
|
141
|
+
beta = beta + step
|
|
142
|
+
if np.max(np.abs(step)) < tol:
|
|
143
|
+
return beta
|
|
144
|
+
raise StatisticalTestError(
|
|
145
|
+
"The logistic recalibration model did not converge; the outcome may be perfectly separated by the "
|
|
146
|
+
"predictions."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _need_both(y: NDArray[np.float64], what: str) -> None:
|
|
151
|
+
if np.min(y) == np.max(y):
|
|
152
|
+
raise InputValidationError(f"{what} needs both outcomes (0 and 1) in y_true.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@register(
|
|
156
|
+
category=_C,
|
|
157
|
+
task="binary",
|
|
158
|
+
name="Calibration slope",
|
|
159
|
+
definition="Slope b of the logistic recalibration model logit P(y=1) = a + b · logit(p). 1 is ideal; below "
|
|
160
|
+
"1 means predictions are too extreme (overfitting), above 1 too moderate.",
|
|
161
|
+
formula="logit P(y=1) = a + b · logit(p̂)",
|
|
162
|
+
range="(−∞, ∞), ideal 1",
|
|
163
|
+
input_requirements=("y_true", "y_prob"),
|
|
164
|
+
references=(_REF_COX, _REF_VC),
|
|
165
|
+
higher_is_better=None,
|
|
166
|
+
)
|
|
167
|
+
def calibration_slope(
|
|
168
|
+
y_true: ArrayLike,
|
|
169
|
+
y_prob: ArrayLike,
|
|
170
|
+
*,
|
|
171
|
+
pos_label: Any = None,
|
|
172
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
173
|
+
) -> MetricResult:
|
|
174
|
+
"""Calibration slope (Cox 1958). Probabilities are clipped to [1e-15, 1 − 1e-15] before the logit."""
|
|
175
|
+
y, p, w = _binary(y_true, y_prob, pos_label, sample_weight)
|
|
176
|
+
_need_both(y, "The calibration slope")
|
|
177
|
+
a, b = logistic_fit(y, _logit(p), w, offset=False)
|
|
178
|
+
return MetricResult("calibration_slope", "Calibration slope", float(b), {"intercept": float(a)})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@register(
|
|
182
|
+
category=_C,
|
|
183
|
+
task="binary",
|
|
184
|
+
name="Calibration intercept",
|
|
185
|
+
definition="Calibration-in-the-large: intercept a of logit P(y=1) = a + logit(p) with the slope fixed at 1. "
|
|
186
|
+
"0 is ideal; negative means risks are overestimated on average, positive underestimated.",
|
|
187
|
+
formula="logit P(y=1) = a + logit(p̂) (offset)",
|
|
188
|
+
range="(−∞, ∞), ideal 0",
|
|
189
|
+
input_requirements=("y_true", "y_prob"),
|
|
190
|
+
references=(_REF_COX, _REF_VC),
|
|
191
|
+
higher_is_better=None,
|
|
192
|
+
)
|
|
193
|
+
def calibration_intercept(
|
|
194
|
+
y_true: ArrayLike,
|
|
195
|
+
y_prob: ArrayLike,
|
|
196
|
+
*,
|
|
197
|
+
pos_label: Any = None,
|
|
198
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
199
|
+
) -> MetricResult:
|
|
200
|
+
"""Calibration intercept (calibration-in-the-large) with the logit of the predictions as an offset."""
|
|
201
|
+
y, p, w = _binary(y_true, y_prob, pos_label, sample_weight)
|
|
202
|
+
_need_both(y, "The calibration intercept")
|
|
203
|
+
(a,) = logistic_fit(y, _logit(p), w, offset=True)
|
|
204
|
+
return MetricResult("calibration_intercept", "Calibration intercept", float(a))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def hosmer_lemeshow(
|
|
208
|
+
y_true: ArrayLike,
|
|
209
|
+
y_prob: ArrayLike,
|
|
210
|
+
*,
|
|
211
|
+
n_groups: int = 10,
|
|
212
|
+
pos_label: Any = None,
|
|
213
|
+
) -> TestResult:
|
|
214
|
+
"""Hosmer–Lemeshow goodness-of-fit test for predicted risks.
|
|
215
|
+
|
|
216
|
+
Observations are sorted by predicted risk and split into ``n_groups`` groups of (nearly) equal size
|
|
217
|
+
("deciles of risk"). The statistic Σ (O − E)² / (E (1 − E/nₖ)) is compared with χ² on ``n_groups − 2``
|
|
218
|
+
degrees of freedom (use ``n_groups`` degrees of freedom for an external validation set by reading
|
|
219
|
+
``params["statistic"]``). A large p-value does not show that calibration is good, especially in small
|
|
220
|
+
samples; report the calibration curve, slope and intercept as well.
|
|
221
|
+
"""
|
|
222
|
+
y, p, _ = _binary(y_true, y_prob, pos_label, None)
|
|
223
|
+
if not (isinstance(n_groups, (int, np.integer)) and n_groups >= 3):
|
|
224
|
+
raise InputValidationError("n_groups must be an integer of at least 3.")
|
|
225
|
+
if y.shape[0] < n_groups:
|
|
226
|
+
raise InputValidationError(f"Need at least n_groups={n_groups} observations; got {y.shape[0]}.")
|
|
227
|
+
order = np.argsort(p, kind="mergesort")
|
|
228
|
+
groups = np.array_split(order, n_groups)
|
|
229
|
+
observed = np.array([y[g].sum() for g in groups])
|
|
230
|
+
expected = np.array([p[g].sum() for g in groups])
|
|
231
|
+
sizes = np.array([g.shape[0] for g in groups], dtype=np.float64)
|
|
232
|
+
var = expected * (1 - expected / sizes)
|
|
233
|
+
if np.any(var <= 0):
|
|
234
|
+
raise StatisticalTestError(
|
|
235
|
+
"Hosmer–Lemeshow is undefined: a risk group has every predicted risk at 0 or 1. Use fewer groups."
|
|
236
|
+
)
|
|
237
|
+
stat = float(np.sum((observed - expected) ** 2 / var))
|
|
238
|
+
df = n_groups - 2
|
|
239
|
+
return TestResult(
|
|
240
|
+
"hosmer-lemeshow",
|
|
241
|
+
stat,
|
|
242
|
+
float(stats.chi2.sf(stat, df)),
|
|
243
|
+
params={
|
|
244
|
+
"df": df,
|
|
245
|
+
"n_groups": int(n_groups),
|
|
246
|
+
"observed": observed.tolist(),
|
|
247
|
+
"expected": expected.tolist(),
|
|
248
|
+
"group_sizes": sizes.astype(int).tolist(),
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
_REPORT_ROWS = (
|
|
254
|
+
("brier_score", "Brier score", "0"),
|
|
255
|
+
("expected_calibration_error", "ECE", "0"),
|
|
256
|
+
("maximum_calibration_error", "MCE", "0"),
|
|
257
|
+
("calibration_intercept", "Calibration intercept", "0"),
|
|
258
|
+
("calibration_slope", "Calibration slope", "1"),
|
|
259
|
+
("hosmer_lemeshow_statistic", "Hosmer–Lemeshow χ²", "–"),
|
|
260
|
+
("hosmer_lemeshow_p", "Hosmer–Lemeshow p", "–"),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True, eq=False)
|
|
265
|
+
class CalibrationReport:
|
|
266
|
+
"""Calibration of one model's predicted risks: Brier score, ECE, MCE, intercept, slope and Hosmer–Lemeshow,
|
|
267
|
+
plus the binned calibration curve (``curve``: prob_true, prob_pred, bin_weight)."""
|
|
268
|
+
|
|
269
|
+
values: Any
|
|
270
|
+
curve: Any
|
|
271
|
+
params: Any = field(default_factory=dict)
|
|
272
|
+
|
|
273
|
+
def __post_init__(self) -> None:
|
|
274
|
+
object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
|
|
275
|
+
object.__setattr__(self, "params", MappingProxyType(dict(self.params)))
|
|
276
|
+
|
|
277
|
+
def __getitem__(self, name: str) -> float:
|
|
278
|
+
return float(self.values[name])
|
|
279
|
+
|
|
280
|
+
def _rows(self, digits: int) -> list[list[str]]:
|
|
281
|
+
return [[label, _fmt(self.values[k], digits), ideal] for k, label, ideal in _REPORT_ROWS]
|
|
282
|
+
|
|
283
|
+
def summary(self, *, digits: int = 4) -> str:
|
|
284
|
+
p = self.params
|
|
285
|
+
lines = [
|
|
286
|
+
f"EvalSuite calibration (n={p['n']}, {p['n_bins']} {p['strategy']} bins, HL {p['n_groups']} groups)"
|
|
287
|
+
]
|
|
288
|
+
width = max(len(label) for _, label, _ in _REPORT_ROWS)
|
|
289
|
+
for k, label, ideal in _REPORT_ROWS:
|
|
290
|
+
lines.append(f" {label:<{width}} {_fmt(self.values[k], digits)} (ideal {ideal})")
|
|
291
|
+
return "\n".join(lines)
|
|
292
|
+
|
|
293
|
+
def __repr__(self) -> str:
|
|
294
|
+
return self.summary()
|
|
295
|
+
|
|
296
|
+
def to_dict(self) -> dict[str, Any]:
|
|
297
|
+
prob_true, prob_pred, weight = self.curve
|
|
298
|
+
return cast(
|
|
299
|
+
"dict[str, Any]",
|
|
300
|
+
_json_safe(
|
|
301
|
+
{
|
|
302
|
+
"values": dict(self.values),
|
|
303
|
+
"curve": {"prob_true": prob_true, "prob_pred": prob_pred, "bin_weight": weight},
|
|
304
|
+
"params": dict(self.params),
|
|
305
|
+
}
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def to_json(self, *, indent: Optional[int] = 2) -> str:
|
|
310
|
+
return json.dumps(self.to_dict(), indent=indent, allow_nan=False)
|
|
311
|
+
|
|
312
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
313
|
+
import pandas as pd
|
|
314
|
+
|
|
315
|
+
frame: pd.DataFrame = pd.DataFrame({"value": dict(self.values)})
|
|
316
|
+
return frame
|
|
317
|
+
|
|
318
|
+
def to_markdown(self, *, digits: int = 4) -> str:
|
|
319
|
+
lines = ["| Measure | Value | Ideal |", "| --- | ---: | ---: |"]
|
|
320
|
+
lines += ["| " + " | ".join(r) + " |" for r in self._rows(digits)]
|
|
321
|
+
return "\n".join(lines)
|
|
322
|
+
|
|
323
|
+
def to_latex(self, *, digits: int = 4, caption: Optional[str] = None, label: Optional[str] = None) -> str:
|
|
324
|
+
rows = [[_latex_escape(c) for c in r] for r in self._rows(digits)]
|
|
325
|
+
return _latex_table(["Measure", "Value", "Ideal"], rows, caption, label)
|
|
326
|
+
|
|
327
|
+
def to_csv(self, path: Optional[PathLike] = None) -> str:
|
|
328
|
+
text = csv_text(["measure", "value"], [[k, float(v)] for k, v in self.values.items()])
|
|
329
|
+
if path is not None:
|
|
330
|
+
with open(path, "w", encoding="utf-8", newline="") as fh:
|
|
331
|
+
fh.write(text)
|
|
332
|
+
return text
|
|
333
|
+
|
|
334
|
+
def to_html(self, *, digits: int = 4, full: bool = True) -> str:
|
|
335
|
+
body = html_table(["Measure", "Value", "Ideal"], self._rows(digits), caption="Calibration")
|
|
336
|
+
prob_true, prob_pred, weight = self.curve
|
|
337
|
+
body += "\n" + html_table(
|
|
338
|
+
["Mean predicted", "Observed", "Weight"],
|
|
339
|
+
[[_fmt(a, digits), _fmt(b, digits), f"{w:g}"] for a, b, w in zip(prob_pred, prob_true, weight)],
|
|
340
|
+
caption="Calibration curve",
|
|
341
|
+
numeric=[True, True, True],
|
|
342
|
+
)
|
|
343
|
+
return html_document("EvalSuite calibration", body) if full else body
|
|
344
|
+
|
|
345
|
+
def save(self, path: PathLike) -> str:
|
|
346
|
+
return save_as(
|
|
347
|
+
path,
|
|
348
|
+
{
|
|
349
|
+
"json": self.to_json,
|
|
350
|
+
"csv": self.to_csv,
|
|
351
|
+
"markdown": self.to_markdown,
|
|
352
|
+
"latex": self.to_latex,
|
|
353
|
+
"html": self.to_html,
|
|
354
|
+
"text": self.summary,
|
|
355
|
+
},
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def calibration_report(
|
|
360
|
+
y_true: ArrayLike,
|
|
361
|
+
y_prob: ArrayLike,
|
|
362
|
+
*,
|
|
363
|
+
n_bins: int = 10,
|
|
364
|
+
strategy: Literal["uniform", "quantile"] = "uniform",
|
|
365
|
+
n_groups: int = 10,
|
|
366
|
+
pos_label: Any = None,
|
|
367
|
+
) -> CalibrationReport:
|
|
368
|
+
"""Every calibration measure for binary predicted risks in one object (see :class:`CalibrationReport`)."""
|
|
369
|
+
kw: dict[str, Any] = {"n_bins": n_bins, "strategy": strategy, "pos_label": pos_label}
|
|
370
|
+
hl = hosmer_lemeshow(y_true, y_prob, n_groups=n_groups, pos_label=pos_label)
|
|
371
|
+
values = {
|
|
372
|
+
"brier_score": float(brier_score(y_true, y_prob, pos_label=pos_label)),
|
|
373
|
+
"expected_calibration_error": float(expected_calibration_error(y_true, y_prob, **kw)),
|
|
374
|
+
"maximum_calibration_error": float(maximum_calibration_error(y_true, y_prob, **kw)),
|
|
375
|
+
"calibration_intercept": float(calibration_intercept(y_true, y_prob, pos_label=pos_label)),
|
|
376
|
+
"calibration_slope": float(calibration_slope(y_true, y_prob, pos_label=pos_label)),
|
|
377
|
+
"hosmer_lemeshow_statistic": hl.statistic,
|
|
378
|
+
"hosmer_lemeshow_p": hl.p_value,
|
|
379
|
+
}
|
|
380
|
+
curve = calibration_curve(y_true, y_prob, **kw)
|
|
381
|
+
n = int(np.asarray(y_true).shape[0])
|
|
382
|
+
return CalibrationReport(
|
|
383
|
+
values,
|
|
384
|
+
curve,
|
|
385
|
+
{"n": n, "n_bins": n_bins, "strategy": strategy, "n_groups": n_groups, "hl_df": hl.params["df"]},
|
|
386
|
+
)
|
evalsuite/cli/main.py
CHANGED
|
@@ -194,6 +194,38 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
194
194
|
return 0
|
|
195
195
|
|
|
196
196
|
|
|
197
|
+
def cmd_diagnostic(args: argparse.Namespace) -> int:
|
|
198
|
+
import evalsuite as es
|
|
199
|
+
|
|
200
|
+
df = read_table(args.file)
|
|
201
|
+
report = es.diagnostic_report(
|
|
202
|
+
column(df, args.y_true, "--y-true"),
|
|
203
|
+
column(df, args.y_pred, "--y-pred"),
|
|
204
|
+
pos_label=label_value(args.pos_label),
|
|
205
|
+
level=args.level,
|
|
206
|
+
)
|
|
207
|
+
emit(report, args)
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cmd_calibration(args: argparse.Namespace) -> int:
|
|
212
|
+
import evalsuite as es
|
|
213
|
+
|
|
214
|
+
df = read_table(args.file)
|
|
215
|
+
if not args.y_prob or len(args.y_prob) != 1:
|
|
216
|
+
raise CLIError("calibration needs exactly one --y-prob column (predicted risk of the positive class).")
|
|
217
|
+
report = es.calibration_report(
|
|
218
|
+
column(df, args.y_true, "--y-true"),
|
|
219
|
+
column(df, args.y_prob[0], "--y-prob"),
|
|
220
|
+
n_bins=args.bins,
|
|
221
|
+
strategy=args.strategy,
|
|
222
|
+
n_groups=args.groups,
|
|
223
|
+
pos_label=label_value(args.pos_label),
|
|
224
|
+
)
|
|
225
|
+
emit(report, args)
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
197
229
|
def cmd_compare(args: argparse.Namespace) -> int:
|
|
198
230
|
import evalsuite as es
|
|
199
231
|
|
|
@@ -239,11 +271,16 @@ def cmd_plot(args: argparse.Namespace) -> int:
|
|
|
239
271
|
df = read_table(args.file)
|
|
240
272
|
y = column(df, args.y_true, "--y-true")
|
|
241
273
|
kind = args.kind
|
|
242
|
-
if kind in ("roc", "pr", "calibration"):
|
|
274
|
+
if kind in ("roc", "pr", "calibration", "decision"):
|
|
243
275
|
if not args.y_prob:
|
|
244
276
|
raise CLIError(f"'{kind}' needs --y-prob.")
|
|
245
277
|
prob = columns(df, args.y_prob, "--y-prob")
|
|
246
|
-
fn: Any = {
|
|
278
|
+
fn: Any = {
|
|
279
|
+
"roc": es.plot.roc,
|
|
280
|
+
"pr": es.plot.pr,
|
|
281
|
+
"calibration": es.plot.calibration,
|
|
282
|
+
"decision": es.plot.decision_curve,
|
|
283
|
+
}[kind]
|
|
247
284
|
_save_figure(lambda ax: fn(y, prob, ax=ax), args.output)
|
|
248
285
|
else:
|
|
249
286
|
if not args.y_pred:
|
|
@@ -342,21 +379,44 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
342
379
|
p.add_argument("--level", type=float, default=0.95, help="confidence level (default 0.95)")
|
|
343
380
|
p.add_argument("--alpha", type=float, default=0.05, help="significance level (default 0.05)")
|
|
344
381
|
p.add_argument("--resamples", type=int, default=1000, help="bootstrap resamples (default 1000)")
|
|
345
|
-
p.add_argument("--correction", default="holm", choices=("holm", "bonferroni", "bh", "by"))
|
|
382
|
+
p.add_argument("--correction", default="holm", choices=("holm", "bonferroni", "hochberg", "bh", "by"))
|
|
346
383
|
p.add_argument("--seed", type=int, default=0, help="random seed (default 0, for reproducibility)")
|
|
347
384
|
p.add_argument("--plot", metavar="PATH", help="also save a forest plot (needs matplotlib)")
|
|
348
385
|
add_output(p, digits=3)
|
|
349
386
|
p.set_defaults(func=cmd_compare)
|
|
350
387
|
|
|
351
|
-
p = sub.add_parser(
|
|
352
|
-
|
|
388
|
+
p = sub.add_parser(
|
|
389
|
+
"diagnostic",
|
|
390
|
+
help="diagnostic accuracy of a binary test: sensitivity, specificity, PPV, NPV, LR+, LR−, DOR with CIs",
|
|
391
|
+
)
|
|
392
|
+
add_targets(p, pred_required=True)
|
|
393
|
+
p.add_argument("--pos-label", help="positive class (default: 1)")
|
|
394
|
+
p.add_argument("--level", type=float, default=0.95, help="confidence level (default 0.95)")
|
|
395
|
+
add_output(p, digits=3)
|
|
396
|
+
p.set_defaults(func=cmd_diagnostic)
|
|
397
|
+
|
|
398
|
+
p = sub.add_parser(
|
|
399
|
+
"calibration", help="calibration of predicted risks: Brier, ECE, MCE, intercept, slope, Hosmer–Lemeshow"
|
|
400
|
+
)
|
|
401
|
+
add_targets(p)
|
|
402
|
+
p.add_argument("--bins", type=int, default=10, help="bins for the calibration curve, ECE and MCE")
|
|
403
|
+
p.add_argument("--strategy", choices=("uniform", "quantile"), default="uniform")
|
|
404
|
+
p.add_argument("--groups", type=int, default=10, help="Hosmer–Lemeshow risk groups (default 10)")
|
|
405
|
+
p.add_argument("--pos-label", help="positive class (default: 1)")
|
|
406
|
+
add_output(p)
|
|
407
|
+
p.set_defaults(func=cmd_calibration)
|
|
408
|
+
|
|
409
|
+
p = sub.add_parser(
|
|
410
|
+
"plot", help="save a ROC, PR, calibration, decision-curve, confusion-matrix or residual plot"
|
|
411
|
+
)
|
|
412
|
+
p.add_argument("kind", choices=("roc", "pr", "calibration", "decision", "confusion", "residuals", "predicted"))
|
|
353
413
|
add_targets(p)
|
|
354
414
|
p.add_argument("--normalize", choices=("true", "pred", "all"), help="confusion matrix normalisation")
|
|
355
415
|
p.add_argument("--output", "-o", required=True, help="image file (.png, .pdf, .svg)")
|
|
356
416
|
p.set_defaults(func=cmd_plot)
|
|
357
417
|
|
|
358
418
|
p = sub.add_parser("metrics", help="list available metrics")
|
|
359
|
-
p.add_argument("--category", choices=("classification", "regression"))
|
|
419
|
+
p.add_argument("--category", choices=("classification", "regression", "clinical", "calibration"))
|
|
360
420
|
p.set_defaults(func=cmd_metrics)
|
|
361
421
|
|
|
362
422
|
p = sub.add_parser("info", help="show a metric's definition, formula, range and references")
|
|
@@ -374,7 +434,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
374
434
|
return parser
|
|
375
435
|
|
|
376
436
|
|
|
437
|
+
def _unicode_safe_streams() -> None:
|
|
438
|
+
"""Results contain characters such as −, χ², ≥ and ×. On a stream whose encoding cannot represent them
|
|
439
|
+
(for example cp1252 when output is piped on Windows), write UTF-8 to files and pipes, and replace
|
|
440
|
+
unrepresentable characters on interactive consoles, instead of failing."""
|
|
441
|
+
for stream in (sys.stdout, sys.stderr):
|
|
442
|
+
encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "").replace("_", "")
|
|
443
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
444
|
+
if encoding == "utf8" or reconfigure is None:
|
|
445
|
+
continue
|
|
446
|
+
try:
|
|
447
|
+
if stream.isatty():
|
|
448
|
+
reconfigure(errors="replace")
|
|
449
|
+
else:
|
|
450
|
+
reconfigure(encoding="utf-8")
|
|
451
|
+
except (OSError, ValueError): # pragma: no cover - exotic streams
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
|
|
377
455
|
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
456
|
+
_unicode_safe_streams()
|
|
378
457
|
parser = build_parser()
|
|
379
458
|
args = parser.parse_args(argv)
|
|
380
459
|
if not getattr(args, "command", None):
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Clinical evaluation: diagnostic accuracy measures with confidence intervals, and decision curve analysis."""
|
|
2
|
+
|
|
3
|
+
from .metrics import diagnostic_odds_ratio, lr_negative, lr_positive, net_benefit, ppv, sensitivity, youden_j
|
|
4
|
+
from .report import DecisionCurve, DiagnosticReport, decision_curve, diagnostic_report
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"DecisionCurve",
|
|
8
|
+
"DiagnosticReport",
|
|
9
|
+
"decision_curve",
|
|
10
|
+
"diagnostic_odds_ratio",
|
|
11
|
+
"diagnostic_report",
|
|
12
|
+
"lr_negative",
|
|
13
|
+
"lr_positive",
|
|
14
|
+
"net_benefit",
|
|
15
|
+
"ppv",
|
|
16
|
+
"sensitivity",
|
|
17
|
+
"youden_j",
|
|
18
|
+
]
|