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 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",
@@ -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 = {"roc": es.plot.roc, "pr": es.plot.pr, "calibration": es.plot.calibration}[kind]
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("plot", help="save a ROC, PR, calibration, confusion-matrix or residual plot")
352
- p.add_argument("kind", choices=("roc", "pr", "calibration", "confusion", "residuals", "predicted"))
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
+ ]