eval-toolkit 0.27.1__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.
@@ -0,0 +1,1720 @@
1
+ """Binary classification metrics: PR-AUC, ROC-AUC, F1 at threshold, ECE, prior-shift projection.
2
+
3
+ Wraps sklearn references with input validation, single-class slice handling,
4
+ and threshold-free score-distribution summaries. All functions accept raw
5
+ numpy arrays (binary y_true in {0, 1}, real-valued y_score) and return
6
+ JSON-serializable values.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Final, Literal, overload
14
+
15
+ import numpy as np
16
+
17
+ if TYPE_CHECKING:
18
+ from eval_toolkit.thresholds import ThresholdSelector
19
+ from sklearn.metrics import (
20
+ average_precision_score,
21
+ confusion_matrix,
22
+ f1_score,
23
+ precision_score,
24
+ recall_score,
25
+ roc_auc_score,
26
+ )
27
+
28
+ from eval_toolkit.artifacts import skipped_metric
29
+
30
+ EmptyStrategy = Literal["raise", "return_none", "skipped_metric"]
31
+
32
+
33
+ class _SentinelOk:
34
+ """Marker returned by :func:`_empty_strategy_guard` for non-degenerate input.
35
+
36
+ Distinct type so mypy can narrow the union via ``isinstance``; using
37
+ ``None`` would conflict with ``empty_strategy="return_none"`` returning
38
+ ``None`` as a real value.
39
+ """
40
+
41
+
42
+ _SENTINEL_OK: Final[_SentinelOk] = _SentinelOk()
43
+
44
+
45
+ def _empty_strategy_guard(
46
+ y_true: np.ndarray,
47
+ strategy: EmptyStrategy,
48
+ metric_name: str,
49
+ *,
50
+ check_single_class: bool = True,
51
+ ) -> float | None | dict[str, object] | _SentinelOk:
52
+ """Short-circuit return value for degenerate inputs under empty_strategy.
53
+
54
+ Returns ``_SENTINEL_OK`` if the input is non-degenerate (proceed to
55
+ metric computation). Otherwise returns ``None`` or a structured
56
+ :func:`~eval_toolkit.artifacts.skipped_metric` dict per the strategy.
57
+
58
+ Parameters
59
+ ----------
60
+ y_true : np.ndarray
61
+ Labels to inspect.
62
+ strategy : EmptyStrategy
63
+ How to handle empty / single-class input. ``"raise"`` is rejected here
64
+ — callers should fall through to the validator and sklearn call.
65
+ metric_name : str
66
+ Used in the structured reason string for ``"skipped_metric"``.
67
+ check_single_class : bool, optional
68
+ If ``True`` (default), single-class ``y_true`` is also treated as
69
+ degenerate. Required for AUC metrics; ``False`` for ``brier_score``
70
+ which is valid on single-class.
71
+ """
72
+ if strategy not in {"return_none", "skipped_metric"}:
73
+ raise ValueError(
74
+ f"empty_strategy must be one of 'raise', 'return_none', "
75
+ f"'skipped_metric'; got {strategy!r}"
76
+ )
77
+ y_arr = np.asarray(y_true)
78
+ n = int(y_arr.size)
79
+ if n == 0:
80
+ if strategy == "return_none":
81
+ return None
82
+ return skipped_metric(f"{metric_name}: empty input (n=0)", n=0)
83
+ if check_single_class:
84
+ unique = np.unique(y_arr)
85
+ if unique.size < 2:
86
+ if strategy == "return_none":
87
+ return None
88
+ return skipped_metric(
89
+ f"{metric_name}: single-class y_true (only {unique.tolist()})",
90
+ n=n,
91
+ unique=unique.tolist(),
92
+ )
93
+ return _SENTINEL_OK
94
+
95
+
96
+ __all__ = [
97
+ "DEFAULT_ASSUMED_PRIORS",
98
+ "ThresholdResult",
99
+ "brier_decomposition",
100
+ "brier_score",
101
+ "expected_calibration_error",
102
+ "expected_calibration_error_debiased",
103
+ "expected_calibration_error_equal_mass",
104
+ "expected_calibration_error_l2",
105
+ "expected_calibration_error_l2_debiased",
106
+ "headline_metrics",
107
+ "metrics_at_threshold",
108
+ "pr_auc",
109
+ "precision_at_prior",
110
+ "quantile_stratified_pr_auc",
111
+ "quantile_stratified_report",
112
+ "roc_auc",
113
+ "score_distribution_summary",
114
+ "single_class_threshold_metrics",
115
+ "stratified_recall",
116
+ ]
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class ThresholdResult:
121
+ """Outcome of operating-point selection at a given criterion.
122
+
123
+ Parameters
124
+ ----------
125
+ threshold : float
126
+ Decision boundary on the score; predictions are positive when
127
+ ``y_score >= threshold``.
128
+ f1 : float
129
+ F1 score at this threshold ∈ [0, 1].
130
+ precision : float
131
+ Precision at this threshold ∈ [0, 1].
132
+ recall : float
133
+ Recall (true-positive rate) at this threshold ∈ [0, 1].
134
+ criterion : str
135
+ Selection-rule label (e.g. ``"max_f1"``, ``"recall_0.90"``).
136
+
137
+ Examples
138
+ --------
139
+ >>> tr = ThresholdResult(
140
+ ... threshold=0.5, f1=0.8, precision=0.9, recall=0.72, criterion="max_f1"
141
+ ... )
142
+ >>> tr.threshold, tr.criterion
143
+ (0.5, 'max_f1')
144
+
145
+ Notes
146
+ -----
147
+ Frozen value-type; equality is field-wise. The selection rule is recorded
148
+ in ``criterion`` so downstream consumers (plots, reports) can label
149
+ operating points without re-deriving the rule.
150
+
151
+ References
152
+ ----------
153
+ .. [1] Davis, J. & Goadrich, M. "The relationship between precision-recall
154
+ and ROC curves." ICML 2006.
155
+ """
156
+
157
+ threshold: float
158
+ f1: float
159
+ precision: float
160
+ recall: float
161
+ criterion: str
162
+
163
+
164
+ @overload
165
+ def pr_auc(
166
+ y_true: np.ndarray, y_score: np.ndarray, *, empty_strategy: Literal["raise"] = ...
167
+ ) -> float: ...
168
+ @overload
169
+ def pr_auc(
170
+ y_true: np.ndarray, y_score: np.ndarray, *, empty_strategy: Literal["return_none"]
171
+ ) -> float | None: ...
172
+ @overload
173
+ def pr_auc(
174
+ y_true: np.ndarray, y_score: np.ndarray, *, empty_strategy: Literal["skipped_metric"]
175
+ ) -> float | dict[str, object]: ...
176
+ def pr_auc(
177
+ y_true: np.ndarray,
178
+ y_score: np.ndarray,
179
+ *,
180
+ empty_strategy: EmptyStrategy = "raise",
181
+ ) -> float | None | dict[str, object]:
182
+ """Average precision (PR-AUC).
183
+
184
+ Primary metric for rare-positive binary classification because it is
185
+ sensitive to changes in the positive class, unlike ROC-AUC which can be
186
+ inflated by abundant true negatives.
187
+
188
+ Parameters
189
+ ----------
190
+ y_true : np.ndarray, shape (n,)
191
+ Binary labels in {0, 1}.
192
+ y_score : np.ndarray, shape (n,)
193
+ Real-valued scores; higher means more positive.
194
+ empty_strategy : {"raise", "return_none", "skipped_metric"}, optional
195
+ How to handle degenerate input (empty array or single-class ``y_true``).
196
+ Default ``"raise"`` preserves pre-v0.13 behavior — the underlying
197
+ sklearn call raises. ``"return_none"`` short-circuits with ``None``
198
+ (useful for callers that emit ``None`` columns for degenerate slices).
199
+ ``"skipped_metric"`` returns a structured
200
+ :func:`~eval_toolkit.artifacts.skipped_metric` dict so callers can
201
+ thread the reason through JSON artifacts. Closes F1.2 from the V4
202
+ consumer feedback log.
203
+
204
+ Returns
205
+ -------
206
+ float | None | dict[str, object]
207
+ PR-AUC ∈ [prevalence, 1]. Wraps ``sklearn.metrics.average_precision_score``.
208
+ With non-default ``empty_strategy``, the return type widens — see
209
+ overload variants.
210
+
211
+ Raises
212
+ ------
213
+ ValueError
214
+ If shapes mismatch, dimensions are wrong, labels are not binary, or
215
+ the input is empty / single-class AND ``empty_strategy="raise"``.
216
+
217
+ Examples
218
+ --------
219
+ >>> import numpy as np
220
+ >>> y = np.array([0, 1, 0, 1])
221
+ >>> s = np.array([0.1, 0.9, 0.2, 0.8])
222
+ >>> pr_auc(y, s)
223
+ 1.0
224
+
225
+ Notes
226
+ -----
227
+ PR-AUC is the area under the precision-recall curve, computed by sklearn
228
+ as the weighted mean of precisions at each threshold:
229
+
230
+ .. math:: \\mathrm{AP} = \\sum_n (R_n - R_{n-1}) P_n
231
+
232
+ where :math:`P_n, R_n` are precision and recall at the :math:`n`-th threshold.
233
+
234
+ See Also
235
+ --------
236
+ eval_toolkit.metrics.roc_auc : ROC-AUC; less informative under imbalance.
237
+ eval_toolkit.metrics.headline_metrics : Bundle including PR-AUC.
238
+ sklearn.metrics.average_precision_score : Underlying sklearn implementation.
239
+
240
+ References
241
+ ----------
242
+ .. [1] Davis, J. & Goadrich, M. "The relationship between precision-recall
243
+ and ROC curves." ICML 2006.
244
+ .. [2] Saito, T. & Rehmsmeier, M. "The precision-recall plot is more
245
+ informative than the ROC plot when evaluating binary classifiers
246
+ on imbalanced datasets." PLOS ONE 10(3), 2015.
247
+ """
248
+ if empty_strategy != "raise":
249
+ guarded = _empty_strategy_guard(y_true, empty_strategy, "pr_auc")
250
+ if not isinstance(guarded, _SentinelOk):
251
+ return guarded
252
+ _validate_inputs(y_true, y_score)
253
+ return float(average_precision_score(y_true, y_score))
254
+
255
+
256
+ @overload
257
+ def roc_auc(
258
+ y_true: np.ndarray, y_score: np.ndarray, *, empty_strategy: Literal["raise"] = ...
259
+ ) -> float: ...
260
+ @overload
261
+ def roc_auc(
262
+ y_true: np.ndarray, y_score: np.ndarray, *, empty_strategy: Literal["return_none"]
263
+ ) -> float | None: ...
264
+ @overload
265
+ def roc_auc(
266
+ y_true: np.ndarray, y_score: np.ndarray, *, empty_strategy: Literal["skipped_metric"]
267
+ ) -> float | dict[str, object]: ...
268
+ def roc_auc(
269
+ y_true: np.ndarray,
270
+ y_score: np.ndarray,
271
+ *,
272
+ empty_strategy: EmptyStrategy = "raise",
273
+ ) -> float | None | dict[str, object]:
274
+ """ROC-AUC.
275
+
276
+ Parameters
277
+ ----------
278
+ y_true : np.ndarray, shape (n,)
279
+ Binary labels in {0, 1}.
280
+ y_score : np.ndarray, shape (n,)
281
+ Real-valued scores; higher means more positive.
282
+ empty_strategy : {"raise", "return_none", "skipped_metric"}, optional
283
+ Degenerate-input handling — see :func:`pr_auc` for full semantics.
284
+ Default ``"raise"`` preserves pre-v0.13 behavior.
285
+
286
+ Returns
287
+ -------
288
+ float | None | dict[str, object]
289
+ ROC-AUC ∈ [0, 1]. 0.5 = random; 1.0 = perfect ranking. With non-default
290
+ ``empty_strategy``, the return type widens — see overload variants.
291
+
292
+ Raises
293
+ ------
294
+ ValueError
295
+ If shapes mismatch, dimensions are wrong, labels are not binary, or
296
+ input is empty / single-class AND ``empty_strategy="raise"``.
297
+
298
+ Examples
299
+ --------
300
+ >>> import numpy as np
301
+ >>> y = np.array([0, 1, 0, 1])
302
+ >>> s = np.array([0.1, 0.9, 0.2, 0.8])
303
+ >>> roc_auc(y, s)
304
+ 1.0
305
+
306
+ Notes
307
+ -----
308
+ ROC-AUC is invariant to monotone transforms of ``y_score``: for any
309
+ strictly monotone :math:`f`, :math:`\\mathrm{ROC-AUC}(y, s) = \\mathrm{ROC-AUC}(y, f(s))`.
310
+ Inversion: :math:`\\mathrm{ROC-AUC}(y, -s) = 1 - \\mathrm{ROC-AUC}(y, s)`.
311
+
312
+ See Also
313
+ --------
314
+ eval_toolkit.metrics.pr_auc : Prefer PR-AUC under class imbalance.
315
+ sklearn.metrics.roc_auc_score : Underlying sklearn implementation.
316
+
317
+ References
318
+ ----------
319
+ .. [1] Hanley, J. A. & McNeil, B. J. "The meaning and use of the area under
320
+ a receiver operating characteristic (ROC) curve." Radiology 143(1),
321
+ 1982.
322
+ """
323
+ if empty_strategy != "raise":
324
+ guarded = _empty_strategy_guard(y_true, empty_strategy, "roc_auc")
325
+ if not isinstance(guarded, _SentinelOk):
326
+ return guarded
327
+ _validate_inputs(y_true, y_score)
328
+ return float(roc_auc_score(y_true, y_score))
329
+
330
+
331
+ def metrics_at_threshold(
332
+ y_true: np.ndarray,
333
+ y_score: np.ndarray,
334
+ threshold: float,
335
+ ) -> dict[str, float | int]:
336
+ """Precision / recall / F1 / accuracy / TN/FP/FN/TP at a fixed threshold.
337
+
338
+ Parameters
339
+ ----------
340
+ y_true : np.ndarray, shape (n,)
341
+ Binary labels.
342
+ y_score : np.ndarray, shape (n,)
343
+ Scores.
344
+ threshold : float
345
+ Decision boundary; ``y_score >= threshold`` is the positive prediction.
346
+
347
+ Returns
348
+ -------
349
+ dict
350
+ Keys: ``threshold``, ``f1``, ``precision``, ``recall``, ``accuracy``,
351
+ ``fpr`` (false-positive rate), ``fnr`` (false-negative rate),
352
+ ``tn``, ``fp``, ``fn``, ``tp``.
353
+
354
+ Examples
355
+ --------
356
+ >>> import numpy as np
357
+ >>> y = np.array([0, 1, 0, 1])
358
+ >>> s = np.array([0.1, 0.9, 0.2, 0.8])
359
+ >>> result = metrics_at_threshold(y, s, threshold=0.5)
360
+ >>> result["f1"], result["tp"], result["fp"]
361
+ (1.0, 2, 0)
362
+ >>> result["fpr"], result["fnr"]
363
+ (0.0, 0.0)
364
+ """
365
+ _validate_inputs(y_true, y_score)
366
+ y_pred = (np.asarray(y_score) >= threshold).astype(int)
367
+ tn, fp, fn, tp = confusion_matrix(y_true, y_pred, labels=[0, 1]).ravel()
368
+ n = len(y_true)
369
+ n_pos = tp + fn
370
+ n_neg = tn + fp
371
+ fpr = float(fp) / max(int(n_neg), 1)
372
+ fnr = float(fn) / max(int(n_pos), 1)
373
+ return {
374
+ "threshold": float(threshold),
375
+ "f1": float(f1_score(y_true, y_pred, zero_division=0)),
376
+ "precision": float(precision_score(y_true, y_pred, zero_division=0)),
377
+ "recall": float(recall_score(y_true, y_pred, zero_division=0)),
378
+ "accuracy": float((tp + tn) / max(n, 1)),
379
+ "fpr": fpr,
380
+ "fnr": fnr,
381
+ "tn": int(tn),
382
+ "fp": int(fp),
383
+ "fn": int(fn),
384
+ "tp": int(tp),
385
+ }
386
+
387
+
388
+ def single_class_threshold_metrics(
389
+ y_true: np.ndarray,
390
+ y_score: np.ndarray,
391
+ threshold: float,
392
+ ) -> dict[str, float | int | str]:
393
+ """Operating metrics for all-positive or all-negative slices.
394
+
395
+ PR-AUC, ROC-AUC, and max-F1 are not meaningful when one class is absent.
396
+ This helper reports the operating-point quantity that still answers a
397
+ deployment question:
398
+
399
+ - all-positive slices: recall at an externally selected threshold
400
+ - all-negative slices: false-positive rate and specificity
401
+
402
+ Parameters
403
+ ----------
404
+ y_true : np.ndarray, shape (n,)
405
+ Binary labels (must all be 0 or all be 1).
406
+ y_score : np.ndarray, shape (n,)
407
+ Scores.
408
+ threshold : float
409
+ Decision boundary supplied externally (e.g., from a mixed-class slice).
410
+
411
+ Returns
412
+ -------
413
+ dict
414
+ Includes ``slice_class`` ∈ {``all_positive``, ``all_negative``} and
415
+ the corresponding operating-point metric (``recall@threshold`` or
416
+ ``fpr@threshold``).
417
+
418
+ Raises
419
+ ------
420
+ ValueError
421
+ If the slice is not single-class.
422
+ """
423
+ _validate_inputs(y_true, y_score)
424
+ unique = set(np.unique(np.asarray(y_true)).tolist())
425
+ if len(unique) != 1:
426
+ raise ValueError(f"expected a single-class slice, got labels {sorted(unique)}")
427
+
428
+ threshold_metrics = metrics_at_threshold(y_true, y_score, threshold)
429
+ n = int(len(y_true))
430
+ n_positive = int(np.sum(y_true))
431
+ n_negative = n - n_positive
432
+ out: dict[str, float | int | str] = {
433
+ "threshold": float(threshold),
434
+ "n": n,
435
+ "n_positive": n_positive,
436
+ "n_negative": n_negative,
437
+ "tp": int(threshold_metrics["tp"]),
438
+ "fp": int(threshold_metrics["fp"]),
439
+ "tn": int(threshold_metrics["tn"]),
440
+ "fn": int(threshold_metrics["fn"]),
441
+ }
442
+ if n_positive == n:
443
+ out["slice_class"] = "all_positive"
444
+ out["recall@threshold"] = float(threshold_metrics["recall"])
445
+ else:
446
+ out["slice_class"] = "all_negative"
447
+ fpr = float(threshold_metrics["fp"] / max(n_negative, 1))
448
+ out["fpr@threshold"] = fpr
449
+ out["specificity@threshold"] = 1.0 - fpr
450
+ return out
451
+
452
+
453
+ def stratified_recall(
454
+ y_true: np.ndarray,
455
+ y_score: np.ndarray,
456
+ threshold: float,
457
+ strata: np.ndarray,
458
+ *,
459
+ with_ci: bool = False,
460
+ confidence: float = 0.95,
461
+ ) -> dict[str, dict[str, float | int]]:
462
+ """Recall (TPR) per categorical stratum.
463
+
464
+ Generalization of "per-family recall" — useful any time you want to check
465
+ whether a detector's recall holds up uniformly across subgroups.
466
+
467
+ Parameters
468
+ ----------
469
+ y_true : np.ndarray, shape (n,)
470
+ Binary labels.
471
+ y_score : np.ndarray, shape (n,)
472
+ Scores.
473
+ threshold : float
474
+ Decision boundary.
475
+ strata : np.ndarray, shape (n,)
476
+ Categorical labels (any hashable type; coerced to string for grouping).
477
+ ``None`` / NaN values are coerced to the string ``"unlabeled"``.
478
+ with_ci : bool, optional
479
+ If ``True``, attach a Wilson scoring confidence interval per stratum
480
+ on the recall (binomial proportion). Default ``False``.
481
+ confidence : float, optional
482
+ Two-sided confidence level (default 0.95). Ignored unless
483
+ ``with_ci=True``.
484
+
485
+ Returns
486
+ -------
487
+ dict
488
+ ``{stratum_label: {"recall", "n", "tp", "fn"}}``. Strata with zero
489
+ positives are reported with ``recall=NaN`` and ``n=0``. When
490
+ ``with_ci=True`` each stratum dict also contains ``"ci_low"`` and
491
+ ``"ci_high"`` (Wilson scoring CI).
492
+
493
+ Raises
494
+ ------
495
+ ValueError
496
+ If ``strata`` length differs from ``y_true`` length.
497
+
498
+ Examples
499
+ --------
500
+ >>> import numpy as np
501
+ >>> y = np.array([1, 1, 1, 0, 0])
502
+ >>> s = np.array([0.9, 0.4, 0.7, 0.2, 0.1])
503
+ >>> strata = np.array(["A", "B", "A", "B", "A"])
504
+ >>> result = stratified_recall(y, s, threshold=0.5, strata=strata)
505
+ >>> sorted(result.keys())
506
+ ['A', 'B']
507
+ >>> result["A"]["recall"], result["A"]["n"]
508
+ (1.0, 2)
509
+
510
+ See Also
511
+ --------
512
+ eval_toolkit.metrics.quantile_stratified_pr_auc :
513
+ PR-AUC on a numeric stratifier window (vs categorical strata here).
514
+
515
+ References
516
+ ----------
517
+ .. [1] Hardt, M., Price, E., & Srebro, N. "Equality of opportunity in
518
+ supervised learning." NIPS 2016. (Per-group recall is the
519
+ "equal opportunity" fairness criterion.)
520
+ """
521
+ _validate_inputs(y_true, y_score)
522
+ strata_arr = np.asarray(strata)
523
+ if strata_arr.shape != np.asarray(y_true).shape:
524
+ raise ValueError(
525
+ f"strata shape {strata_arr.shape} != y_true shape {np.asarray(y_true).shape}"
526
+ )
527
+
528
+ # Coerce to string for groupby; treat None/NaN as "unlabeled". The
529
+ # isinstance(v, float) guard ensures np.isnan only sees floats (incl.
530
+ # numpy.float64), which never raises TypeError — no try/except needed.
531
+ def _coerce(v: object) -> str:
532
+ if v is None:
533
+ return "unlabeled"
534
+ if isinstance(v, float) and np.isnan(v):
535
+ return "unlabeled"
536
+ return str(v)
537
+
538
+ strata_str = np.array([_coerce(v) for v in strata_arr])
539
+ y_true_arr = np.asarray(y_true).astype(int)
540
+ y_pred = (np.asarray(y_score) >= threshold).astype(int)
541
+
542
+ if with_ci and not 0.0 < confidence < 1.0:
543
+ raise ValueError(f"confidence must be in (0, 1), got {confidence}")
544
+
545
+ out: dict[str, dict[str, float | int]] = {}
546
+ for stratum_np in np.unique(strata_str):
547
+ stratum = str(stratum_np) # coerce numpy str to plain Python str
548
+ mask = strata_str == stratum_np
549
+ positives_mask = mask & (y_true_arr == 1)
550
+ n_pos = int(positives_mask.sum())
551
+ if n_pos == 0:
552
+ entry: dict[str, float | int] = {
553
+ "recall": float("nan"),
554
+ "n": 0,
555
+ "tp": 0,
556
+ "fn": 0,
557
+ }
558
+ if with_ci:
559
+ entry["ci_low"] = float("nan")
560
+ entry["ci_high"] = float("nan")
561
+ out[stratum] = entry
562
+ continue
563
+ tp = int((y_pred[positives_mask] == 1).sum())
564
+ entry = {
565
+ "recall": tp / n_pos,
566
+ "n": n_pos,
567
+ "tp": tp,
568
+ "fn": n_pos - tp,
569
+ }
570
+ if with_ci:
571
+ ci_low, ci_high = _wilson_ci(tp, n_pos, confidence=confidence)
572
+ entry["ci_low"] = ci_low
573
+ entry["ci_high"] = ci_high
574
+ out[stratum] = entry
575
+ return out
576
+
577
+
578
+ def _wilson_ci(k: int, n: int, *, confidence: float = 0.95) -> tuple[float, float]:
579
+ """Wilson scoring confidence interval for a binomial proportion.
580
+
581
+ Closed-form 2-sided CI for ``p̂ = k/n`` at the given confidence level.
582
+ Wilson is preferred over the normal-approximation Wald interval for
583
+ small ``n`` or extreme ``p̂`` (closer to 0 or 1) — the toolkit choice
584
+ matches sklearn / statsmodels defaults for ``method='wilson'``.
585
+
586
+ Parameters
587
+ ----------
588
+ k : int
589
+ Number of successes.
590
+ n : int
591
+ Number of trials.
592
+ confidence : float, optional
593
+ Two-sided confidence level (default 0.95).
594
+
595
+ Returns
596
+ -------
597
+ tuple[float, float]
598
+ ``(low, high)`` Wilson CI bounds.
599
+
600
+ References
601
+ ----------
602
+ .. [1] Wilson, E. B. "Probable inference, the law of succession, and
603
+ statistical inference." JASA 22(158), 1927.
604
+ """
605
+ from scipy.stats import norm # noqa: PLC0415 (scoped optional import)
606
+
607
+ if n <= 0:
608
+ return float("nan"), float("nan")
609
+ z = float(norm.ppf(0.5 + confidence / 2.0))
610
+ p_hat = k / n
611
+ denom = 1.0 + z * z / n
612
+ centre = (p_hat + z * z / (2 * n)) / denom
613
+ margin = (z * np.sqrt(p_hat * (1.0 - p_hat) / n + z * z / (4 * n * n))) / denom
614
+ return float(centre - margin), float(centre + margin)
615
+
616
+
617
+ def expected_calibration_error(
618
+ y_true: np.ndarray,
619
+ y_score: np.ndarray,
620
+ n_bins: int = 10,
621
+ ) -> float:
622
+ """Expected calibration error on equal-width probability bins.
623
+
624
+ At low base rates, several equal-width bins are mostly empty and the
625
+ estimate becomes dominated by sparse-bin variance.
626
+ :func:`expected_calibration_error_equal_mass` is the audit-recommended
627
+ companion for imbalanced data.
628
+
629
+ Parameters
630
+ ----------
631
+ y_true : np.ndarray, shape (n,)
632
+ Binary labels.
633
+ y_score : np.ndarray, shape (n,)
634
+ Calibrated probabilities ∈ [0, 1].
635
+ n_bins : int, optional
636
+ Number of equal-width bins (default 10). Must be ≥ 2.
637
+
638
+ Returns
639
+ -------
640
+ float
641
+ ECE ∈ [0, 1]. 0 = perfectly calibrated.
642
+
643
+ Raises
644
+ ------
645
+ ValueError
646
+ If ``n_bins < 2`` or input shapes are wrong.
647
+
648
+ Examples
649
+ --------
650
+ >>> import numpy as np
651
+ >>> y = np.array([0, 0, 1, 1])
652
+ >>> s = np.array([0.1, 0.2, 0.8, 0.9])
653
+ >>> round(expected_calibration_error(y, s, n_bins=2), 3)
654
+ 0.15
655
+
656
+ Notes
657
+ -----
658
+ .. math::
659
+
660
+ \\mathrm{ECE} = \\sum_{m=1}^{M} \\frac{|B_m|}{n} |\\mathrm{acc}(B_m) - \\mathrm{conf}(B_m)|
661
+
662
+ where :math:`B_m` is the :math:`m`-th bin, :math:`\\mathrm{acc}` is the
663
+ empirical positive rate in the bin, and :math:`\\mathrm{conf}` is the
664
+ mean predicted score.
665
+
666
+ References
667
+ ----------
668
+ .. [1] DeGroot, M. H. & Fienberg, S. E. "The comparison and evaluation of
669
+ forecasters." The Statistician 32(1-2), 1983.
670
+ .. [2] Naeini, M. P., Cooper, G., & Hauskrecht, M. "Obtaining well
671
+ calibrated probabilities using Bayesian binning." AAAI 2015.
672
+ """
673
+ _validate_inputs(y_true, y_score)
674
+ _validate_calibrated_score(y_score)
675
+ if n_bins < 2:
676
+ raise ValueError(f"n_bins must be ≥ 2, got {n_bins}")
677
+ bin_edges = np.linspace(0.0, 1.0, n_bins + 1)
678
+ bin_idx = np.clip(np.digitize(y_score, bin_edges, right=False) - 1, 0, n_bins - 1)
679
+ n = len(y_true)
680
+ ece = 0.0
681
+ for b in range(n_bins):
682
+ mask = bin_idx == b
683
+ if not mask.any():
684
+ continue
685
+ confidence = float(np.mean(y_score[mask]))
686
+ empirical = float(np.mean(y_true[mask]))
687
+ ece += (mask.sum() / n) * abs(confidence - empirical)
688
+ return float(ece)
689
+
690
+
691
+ def expected_calibration_error_equal_mass(
692
+ y_true: np.ndarray,
693
+ y_score: np.ndarray,
694
+ n_bins: int = 10,
695
+ ) -> float:
696
+ """ECE on equal-mass (quantile) bins.
697
+
698
+ With class imbalance, equal-width bins concentrate most data in 1-2 bins.
699
+ Equal-mass binning gives every bin the same number of examples, so each
700
+ bin contributes a comparable amount of evidence to the ECE estimate.
701
+
702
+ Parameters
703
+ ----------
704
+ y_true : np.ndarray, shape (n,)
705
+ Binary labels.
706
+ y_score : np.ndarray, shape (n,)
707
+ Calibrated probabilities.
708
+ n_bins : int, optional
709
+ Number of quantile bins (default 10). Must be ≥ 2 and ≤ ``n``.
710
+
711
+ Returns
712
+ -------
713
+ float
714
+ ECE ∈ [0, 1].
715
+
716
+ Raises
717
+ ------
718
+ ValueError
719
+ If ``n_bins < 2`` or ``n < n_bins``.
720
+
721
+ Examples
722
+ --------
723
+ >>> import numpy as np
724
+ >>> rng = np.random.default_rng(42)
725
+ >>> y = rng.integers(0, 2, size=200)
726
+ >>> s = (y + rng.normal(0, 0.5, size=200)).clip(0, 1)
727
+ >>> 0.0 <= expected_calibration_error_equal_mass(y, s) <= 1.0
728
+ True
729
+
730
+ Notes
731
+ -----
732
+ Quantile-binned ECE replaces equal-width bins with bin edges placed at
733
+ score quantiles, so each bin holds roughly :math:`n / M` examples. This
734
+ eliminates the sparse-bin variance that dominates equal-width ECE under
735
+ class imbalance.
736
+
737
+ References
738
+ ----------
739
+ .. [1] Nixon, J., et al. "Measuring calibration in deep learning."
740
+ CVPR Workshops 2019. (Discussion of equal-mass binning rationale.)
741
+ .. [2] DeGroot, M. H. & Fienberg, S. E. "The comparison and evaluation of
742
+ forecasters." The Statistician 32(1-2), 1983.
743
+ """
744
+ _validate_inputs(y_true, y_score)
745
+ _validate_calibrated_score(y_score)
746
+ if n_bins < 2:
747
+ raise ValueError(f"n_bins must be ≥ 2, got {n_bins}")
748
+ n = len(y_true)
749
+ if n < n_bins:
750
+ raise ValueError(f"n={n} smaller than n_bins={n_bins}; cannot form quantile bins")
751
+ order = np.argsort(np.asarray(y_score), kind="stable")
752
+ sorted_score = np.asarray(y_score)[order]
753
+ sorted_true = np.asarray(y_true)[order]
754
+ edges = np.linspace(0, n, n_bins + 1, dtype=int)
755
+ ece = 0.0
756
+ for b in range(n_bins):
757
+ lo, hi = edges[b], edges[b + 1]
758
+ if hi <= lo:
759
+ continue
760
+ confidence = float(np.mean(sorted_score[lo:hi]))
761
+ empirical = float(np.mean(sorted_true[lo:hi]))
762
+ ece += ((hi - lo) / n) * abs(confidence - empirical)
763
+ return float(ece)
764
+
765
+
766
+ def expected_calibration_error_l2(
767
+ y_true: np.ndarray, y_score: np.ndarray, n_bins: int = 10
768
+ ) -> float:
769
+ r"""Equal-mass L2 ECE — root mean squared bin-level miscalibration.
770
+
771
+ .. math::
772
+
773
+ \mathrm{ECE}_2 = \sqrt{\sum_k \frac{n_k}{n} (\bar p_k - \bar y_k)^2}
774
+
775
+ Companion to :func:`expected_calibration_error_equal_mass` (which uses L1
776
+ on the absolute difference). The L2 form is what Kumar 2019 [#kumar]_
777
+ proves admits a clean closed-form bias correction —
778
+ see :func:`expected_calibration_error_l2_debiased`.
779
+
780
+ Parameters
781
+ ----------
782
+ y_true : np.ndarray, shape (n,)
783
+ Binary labels.
784
+ y_score : np.ndarray, shape (n,)
785
+ Calibrated probabilities ∈ [0, 1].
786
+ n_bins : int, optional
787
+ Number of equal-mass bins (default 10).
788
+
789
+ Returns
790
+ -------
791
+ float
792
+ L2 ECE in [0, 1]. 0 = perfectly calibrated.
793
+
794
+ Raises
795
+ ------
796
+ ValueError
797
+ If ``y_true``/``y_score`` shape, dtype, or value-range checks fail
798
+ (re-raised from input validators); if ``n_bins < 2``; or if
799
+ ``n < n_bins`` (cannot form quantile bins).
800
+
801
+ Examples
802
+ --------
803
+ >>> import numpy as np
804
+ >>> rng = np.random.default_rng(0)
805
+ >>> y = rng.integers(0, 2, size=500)
806
+ >>> s = (y + rng.normal(0, 0.3, 500)).clip(0, 1)
807
+ >>> 0.0 <= expected_calibration_error_l2(y, s) <= 1.0
808
+ True
809
+
810
+ References
811
+ ----------
812
+ .. [#kumar] Kumar, A., Liang, P. S., & Ma, T. "Verified uncertainty
813
+ calibration." NeurIPS 2019. arXiv:1909.10155.
814
+ """
815
+ _validate_inputs(y_true, y_score)
816
+ _validate_calibrated_score(y_score)
817
+ if n_bins < 2:
818
+ raise ValueError(f"n_bins must be ≥ 2, got {n_bins}")
819
+ n = len(y_true)
820
+ if n < n_bins:
821
+ raise ValueError(f"n={n} smaller than n_bins={n_bins}; cannot form quantile bins")
822
+ order = np.argsort(np.asarray(y_score), kind="stable")
823
+ sorted_score = np.asarray(y_score)[order]
824
+ sorted_true = np.asarray(y_true)[order]
825
+ edges = np.linspace(0, n, n_bins + 1, dtype=int)
826
+ sq_err = 0.0
827
+ for b in range(n_bins):
828
+ lo, hi = int(edges[b]), int(edges[b + 1])
829
+ if hi <= lo:
830
+ continue
831
+ n_k = hi - lo
832
+ p_bar_k = float(np.mean(sorted_score[lo:hi]))
833
+ y_bar_k = float(np.mean(sorted_true[lo:hi]))
834
+ sq_err += (n_k / n) * (p_bar_k - y_bar_k) ** 2
835
+ return float(np.sqrt(sq_err))
836
+
837
+
838
+ def expected_calibration_error_l2_debiased(
839
+ y_true: np.ndarray, y_score: np.ndarray, n_bins: int = 10
840
+ ) -> float:
841
+ r"""Bias-corrected L2 ECE per Kumar 2019 [#kumar]_ §3.3.
842
+
843
+ The plug-in L2 ECE estimator is positively biased: random
844
+ miscalibration "noise" inflates the squared error term by
845
+ :math:`\bar p_k (1 - \bar p_k) / n_k` per bin even when the true
846
+ calibration error is zero. Kumar 2019 proves the bias is
847
+ closed-form-removable for the L2 (squared) ECE:
848
+
849
+ .. math::
850
+
851
+ \widehat{\mathrm{ECE}}_2^{\,2,\, \text{deb}} =
852
+ \widehat{\mathrm{ECE}}_2^{\,2} -
853
+ \sum_k \frac{n_k}{n} \cdot \frac{\bar p_k (1 - \bar p_k)}{n_k}
854
+
855
+ The result is then square-rooted (clipped at 0 if the bias correction
856
+ drives the estimate negative on well-calibrated data).
857
+
858
+ Parameters
859
+ ----------
860
+ y_true : np.ndarray, shape (n,)
861
+ Binary labels.
862
+ y_score : np.ndarray, shape (n,)
863
+ Calibrated probabilities ∈ [0, 1].
864
+ n_bins : int, optional
865
+ Number of equal-mass bins (default 10).
866
+
867
+ Returns
868
+ -------
869
+ float
870
+ Debiased L2 ECE in [0, 1]. The bias correction can drive the
871
+ estimate to exactly 0 on well-calibrated data; this is correct
872
+ Kumar 2019 behavior, not a bug.
873
+
874
+ Raises
875
+ ------
876
+ ValueError
877
+ If ``y_true``/``y_score`` shape, dtype, or value-range checks fail
878
+ (re-raised from input validators); if ``n_bins < 2``; or if
879
+ ``n < n_bins`` (cannot form quantile bins).
880
+
881
+ Examples
882
+ --------
883
+ >>> import numpy as np
884
+ >>> rng = np.random.default_rng(0)
885
+ >>> # Perfectly calibrated synthetic data: y ~ Bernoulli(s)
886
+ >>> n = 5000
887
+ >>> s = rng.uniform(0, 1, size=n)
888
+ >>> y = (rng.uniform(0, 1, size=n) < s).astype(int)
889
+ >>> # Plug-in ECE has positive bias; debiased should be ~0.
890
+ >>> plug_in = expected_calibration_error_l2(y, s)
891
+ >>> debiased = expected_calibration_error_l2_debiased(y, s)
892
+ >>> debiased <= plug_in + 1e-9
893
+ True
894
+
895
+ See Also
896
+ --------
897
+ eval_toolkit.metrics.expected_calibration_error_l2 :
898
+ Plug-in L2 ECE without bias correction.
899
+ eval_toolkit.metrics.expected_calibration_error_equal_mass :
900
+ L1 (absolute-deviation) ECE — no closed-form bias correction.
901
+
902
+ References
903
+ ----------
904
+ .. [#kumar] Kumar, A., Liang, P. S., & Ma, T. "Verified uncertainty
905
+ calibration." NeurIPS 2019. arXiv:1909.10155. (§3.3 closed-form
906
+ debiased L2 ECE.)
907
+ .. [1] Roelofs, R., Cain, N., Shlens, J., & Mozer, M. "Mitigating bias
908
+ in calibration error estimation." AISTATS 2022.
909
+ """
910
+ _validate_inputs(y_true, y_score)
911
+ _validate_calibrated_score(y_score)
912
+ if n_bins < 2:
913
+ raise ValueError(f"n_bins must be ≥ 2, got {n_bins}")
914
+ n = len(y_true)
915
+ if n < n_bins:
916
+ raise ValueError(f"n={n} smaller than n_bins={n_bins}; cannot form quantile bins")
917
+ order = np.argsort(np.asarray(y_score), kind="stable")
918
+ sorted_score = np.asarray(y_score)[order]
919
+ sorted_true = np.asarray(y_true)[order]
920
+ edges = np.linspace(0, n, n_bins + 1, dtype=int)
921
+ sq_err = 0.0
922
+ bias = 0.0
923
+ for b in range(n_bins):
924
+ lo, hi = int(edges[b]), int(edges[b + 1])
925
+ if hi <= lo:
926
+ continue
927
+ n_k = hi - lo
928
+ p_bar_k = float(np.mean(sorted_score[lo:hi]))
929
+ y_bar_k = float(np.mean(sorted_true[lo:hi]))
930
+ sq_err += (n_k / n) * (p_bar_k - y_bar_k) ** 2
931
+ # Kumar 2019 bias correction: per-bin variance of a Bernoulli with
932
+ # mean p_bar_k (≈ true bin mean), divided by n_k samples.
933
+ bias += (n_k / n) * (p_bar_k * (1.0 - p_bar_k) / n_k)
934
+ debiased_sq = max(sq_err - bias, 0.0)
935
+ return float(np.sqrt(debiased_sq))
936
+
937
+
938
+ def expected_calibration_error_debiased(
939
+ y_true: np.ndarray,
940
+ y_score: np.ndarray,
941
+ n_bins: int = 10,
942
+ *,
943
+ n_sweep: int = 200,
944
+ seed: int = 42,
945
+ ) -> float:
946
+ r"""Bias-corrected L1 ECE via simulated-H0 Monte-Carlo (Roelofs 2022 spirit).
947
+
948
+ The plug-in L1 ECE has positive O(M/n) bias — random miscalibration
949
+ "noise" inflates the absolute-deviation term per bin even on
950
+ perfectly-calibrated data. Unlike the L2 form (where Kumar 2019 gives
951
+ a closed-form bias correction in
952
+ :func:`expected_calibration_error_l2_debiased`), L1 has no closed-form
953
+ correction and must be estimated.
954
+
955
+ This estimator uses the **simulated-H0 bootstrap**: resample synthetic
956
+ labels :math:`y_i^* \\sim \\mathrm{Bernoulli}(s_i)` (assuming the
957
+ scores ARE the true probabilities), compute the plug-in L1 ECE on each
958
+ resample, and average to estimate the bias under the null hypothesis
959
+ of perfect calibration. The debiased estimate is then
960
+
961
+ .. math::
962
+
963
+ \widehat{\mathrm{ECE}}^{\,\text{deb}}_1 = \max\!\big(0,\,
964
+ \widehat{\mathrm{ECE}}_1 - \widehat{\mathrm{bias}}_1\big).
965
+
966
+ Same conceptual move as Roelofs 2022's ECE_SWEEP estimator (which
967
+ uses cross-validation rather than simulated-H0), trading a bit of
968
+ fidelity to the literal SWEEP construction for a substantially
969
+ simpler implementation.
970
+
971
+ Parameters
972
+ ----------
973
+ y_true : np.ndarray, shape (n,)
974
+ Binary labels.
975
+ y_score : np.ndarray, shape (n,)
976
+ Calibrated probabilities ∈ [0, 1].
977
+ n_bins : int, optional
978
+ Number of equal-mass bins (default 10).
979
+ n_sweep : int, optional
980
+ Number of simulated-H0 resamples for the bias estimate. Default
981
+ ``200``. Larger → tighter bias estimate, more compute.
982
+ seed : int, optional
983
+ RNG seed for the simulated H0 resamples.
984
+
985
+ Returns
986
+ -------
987
+ float
988
+ Debiased L1 ECE in [0, 1]. The bias correction can drive the
989
+ estimate to exactly 0 on well-calibrated data.
990
+
991
+ Raises
992
+ ------
993
+ ValueError
994
+ Same conditions as :func:`expected_calibration_error_equal_mass`,
995
+ plus ``n_sweep < 10``.
996
+
997
+ Examples
998
+ --------
999
+ >>> import numpy as np
1000
+ >>> rng = np.random.default_rng(0)
1001
+ >>> # Perfectly calibrated synthetic data
1002
+ >>> n = 2000
1003
+ >>> s = rng.uniform(0, 1, size=n)
1004
+ >>> y = (rng.uniform(0, 1, size=n) < s).astype(int)
1005
+ >>> plug_in = expected_calibration_error_equal_mass(y, s)
1006
+ >>> debiased = expected_calibration_error_debiased(y, s, n_sweep=100, seed=0)
1007
+ >>> debiased <= plug_in + 1e-9
1008
+ True
1009
+
1010
+ See Also
1011
+ --------
1012
+ eval_toolkit.metrics.expected_calibration_error_l2_debiased :
1013
+ Closed-form (Kumar 2019) debiased L2 ECE — no Monte-Carlo, faster.
1014
+ eval_toolkit.metrics.expected_calibration_error_equal_mass :
1015
+ Plug-in L1 ECE without bias correction.
1016
+
1017
+ Notes
1018
+ -----
1019
+ Compute cost is :math:`O(n_{\\text{sweep}} \\cdot n)` for the
1020
+ Monte-Carlo bias estimate. With ``n_sweep=200`` and ``n=1000``, this
1021
+ is ~200K Bernoulli draws + ECE computations — fast (~0.5s).
1022
+
1023
+ References
1024
+ ----------
1025
+ .. [1] Roelofs, R., Cain, N., Shlens, J., & Mozer, M. "Mitigating bias
1026
+ in calibration error estimation." AISTATS 2022. (ECE_SWEEP via
1027
+ cross-validation; this implementation uses simulated H0 instead.)
1028
+ .. [2] Kumar, A., Liang, P. S., & Ma, T. "Verified uncertainty
1029
+ calibration." NeurIPS 2019. arXiv:1909.10155.
1030
+ """
1031
+ _validate_inputs(y_true, y_score)
1032
+ _validate_calibrated_score(y_score)
1033
+ if n_bins < 2:
1034
+ raise ValueError(f"n_bins must be ≥ 2, got {n_bins}")
1035
+ if n_sweep < 10:
1036
+ raise ValueError(f"n_sweep must be ≥ 10, got {n_sweep}")
1037
+ n = len(y_true)
1038
+ if n < n_bins:
1039
+ raise ValueError(f"n={n} smaller than n_bins={n_bins}; cannot form quantile bins")
1040
+
1041
+ plug_in = expected_calibration_error_equal_mass(y_true, y_score, n_bins=n_bins)
1042
+
1043
+ # Simulated-H0 bias estimate: under H0 (scores ARE true probabilities),
1044
+ # the expected plug-in ECE on Bernoulli(s) labels gives the bias floor.
1045
+ s_arr = np.asarray(y_score, dtype=float)
1046
+ rng = np.random.default_rng(seed)
1047
+ bias_estimates = np.empty(n_sweep, dtype=np.float64)
1048
+ for i in range(n_sweep):
1049
+ y_synth = (rng.uniform(0, 1, size=n) < s_arr).astype(int)
1050
+ bias_estimates[i] = expected_calibration_error_equal_mass(y_synth, s_arr, n_bins=n_bins)
1051
+ bias = float(bias_estimates.mean())
1052
+ return float(max(0.0, plug_in - bias))
1053
+
1054
+
1055
+ @overload
1056
+ def brier_score(
1057
+ y_true: np.ndarray, y_score: np.ndarray, *, empty_strategy: Literal["raise"] = ...
1058
+ ) -> float: ...
1059
+ @overload
1060
+ def brier_score(
1061
+ y_true: np.ndarray, y_score: np.ndarray, *, empty_strategy: Literal["return_none"]
1062
+ ) -> float | None: ...
1063
+ @overload
1064
+ def brier_score(
1065
+ y_true: np.ndarray, y_score: np.ndarray, *, empty_strategy: Literal["skipped_metric"]
1066
+ ) -> float | dict[str, object]: ...
1067
+ def brier_score(
1068
+ y_true: np.ndarray,
1069
+ y_score: np.ndarray,
1070
+ *,
1071
+ empty_strategy: EmptyStrategy = "raise",
1072
+ ) -> float | None | dict[str, object]:
1073
+ r"""Brier score (mean squared error between predicted probabilities and labels).
1074
+
1075
+ Strictly proper scoring rule: minimized in expectation iff the forecast
1076
+ equals the true conditional probability. Captures both calibration and
1077
+ resolution that ECE alone misses; see :func:`brier_decomposition` for
1078
+ the Murphy 1973 partition.
1079
+
1080
+ Parameters
1081
+ ----------
1082
+ y_true : np.ndarray, shape (n,)
1083
+ Binary labels in {0, 1}.
1084
+ y_score : np.ndarray, shape (n,)
1085
+ Calibrated probabilities in ``[0, 1]``.
1086
+ empty_strategy : {"raise", "return_none", "skipped_metric"}, optional
1087
+ Empty-input handling — see :func:`pr_auc`. Note: unlike PR/ROC-AUC,
1088
+ ``brier_score`` is well-defined on single-class slices, so the guard
1089
+ only short-circuits on ``n=0``. Default ``"raise"`` preserves pre-v0.13
1090
+ behavior.
1091
+
1092
+ Returns
1093
+ -------
1094
+ float | None | dict[str, object]
1095
+ Brier score in ``[0, 1]``. ``0`` is perfect; ``0.25`` is the
1096
+ constant-prevalence forecast; ``1`` is maximally wrong. With
1097
+ non-default ``empty_strategy``, the return type widens — see overload
1098
+ variants.
1099
+
1100
+ Raises
1101
+ ------
1102
+ ValueError
1103
+ On shape mismatch, NaN/Inf scores, non-binary labels, scores outside
1104
+ ``[0, 1]``, or empty input AND ``empty_strategy="raise"``.
1105
+
1106
+ Examples
1107
+ --------
1108
+ >>> import numpy as np
1109
+ >>> y = np.array([0, 1, 0, 1])
1110
+ >>> s = np.array([0.1, 0.9, 0.2, 0.8])
1111
+ >>> round(brier_score(y, s), 4)
1112
+ 0.025
1113
+
1114
+ Notes
1115
+ -----
1116
+ .. math:: \mathrm{BS} = \frac{1}{n} \sum_i (p_i - y_i)^2
1117
+
1118
+ See Also
1119
+ --------
1120
+ eval_toolkit.metrics.brier_decomposition :
1121
+ Murphy 1973 reliability/resolution/uncertainty partition.
1122
+ eval_toolkit.metrics.expected_calibration_error :
1123
+ Calibration-only metric; ECE = 0 does not imply BS = 0.
1124
+
1125
+ References
1126
+ ----------
1127
+ .. [1] Brier, G. W. "Verification of forecasts expressed in terms of
1128
+ probability." Monthly Weather Review 78(1), 1950.
1129
+ """
1130
+ if empty_strategy != "raise":
1131
+ guarded = _empty_strategy_guard(
1132
+ y_true, empty_strategy, "brier_score", check_single_class=False
1133
+ )
1134
+ if not isinstance(guarded, _SentinelOk):
1135
+ return guarded
1136
+ _validate_inputs(y_true, y_score)
1137
+ _validate_calibrated_score(y_score)
1138
+ y = np.asarray(y_true, dtype=float)
1139
+ p = np.asarray(y_score, dtype=float)
1140
+ return float(np.mean((p - y) ** 2))
1141
+
1142
+
1143
+ def brier_decomposition(
1144
+ y_true: np.ndarray, y_score: np.ndarray, n_bins: int = 10
1145
+ ) -> dict[str, float]:
1146
+ r"""Murphy 1973 [#murphy]_ decomposition of the Brier score.
1147
+
1148
+ Partitions the Brier score into three additive components:
1149
+
1150
+ .. math:: \mathrm{BS} = \mathrm{REL} - \mathrm{RES} + \mathrm{UNC}
1151
+
1152
+ where:
1153
+
1154
+ - **REL** (reliability, lower is better) is the expected squared
1155
+ distance between predicted probability and the empirical positive
1156
+ rate within each bin: :math:`\sum_k \frac{n_k}{n} (\bar p_k - \bar y_k)^2`.
1157
+ - **RES** (resolution, higher is better) is the variance of bin
1158
+ empirical rates around the marginal: :math:`\sum_k \frac{n_k}{n} (\bar y_k - \bar y)^2`.
1159
+ - **UNC** (uncertainty, irreducible) is the marginal variance:
1160
+ :math:`\bar y (1 - \bar y)`.
1161
+
1162
+ The decomposition is exact in expectation when bin assignments are
1163
+ independent of the labels; for finite samples the partition holds up to
1164
+ binning approximation.
1165
+
1166
+ Parameters
1167
+ ----------
1168
+ y_true : np.ndarray, shape (n,)
1169
+ Binary labels.
1170
+ y_score : np.ndarray, shape (n,)
1171
+ Calibrated probabilities ``∈ [0, 1]``.
1172
+ n_bins : int, optional
1173
+ Number of equal-mass bins (default 10). Must be ≥ 2 and ≤ ``n``.
1174
+
1175
+ Returns
1176
+ -------
1177
+ dict
1178
+ ``{"brier", "reliability", "resolution", "uncertainty"}``. The
1179
+ identity ``brier ≈ reliability - resolution + uncertainty`` holds
1180
+ to ``≈ 1e-9`` modulo binning approximation.
1181
+
1182
+ Raises
1183
+ ------
1184
+ ValueError
1185
+ Same conditions as :func:`brier_score`, plus invalid ``n_bins``.
1186
+
1187
+ Examples
1188
+ --------
1189
+ >>> import numpy as np
1190
+ >>> rng = np.random.default_rng(0)
1191
+ >>> y = rng.integers(0, 2, size=500)
1192
+ >>> s = (y + rng.normal(0, 0.3, size=500)).clip(0, 1)
1193
+ >>> result = brier_decomposition(y, s, n_bins=10)
1194
+ >>> set(result.keys()) == {"brier", "reliability", "resolution", "uncertainty"}
1195
+ True
1196
+ >>> # The decomposition identity holds to within binning approximation:
1197
+ >>> approx = result["reliability"] - result["resolution"] + result["uncertainty"]
1198
+ >>> abs(result["brier"] - approx) < 0.05
1199
+ True
1200
+
1201
+ Notes
1202
+ -----
1203
+ Equal-mass binning is used (vs equal-width) following Nixon et al.
1204
+ 2019's recommendation; under class imbalance the equal-width variant
1205
+ concentrates most mass in 1-2 bins and the resolution term collapses.
1206
+
1207
+ References
1208
+ ----------
1209
+ .. [#murphy] Murphy, A. H. "A new vector partition of the probability
1210
+ score." Journal of Applied Meteorology 12, 1973.
1211
+ """
1212
+ _validate_inputs(y_true, y_score)
1213
+ _validate_calibrated_score(y_score)
1214
+ if n_bins < 2:
1215
+ raise ValueError(f"n_bins must be ≥ 2, got {n_bins}")
1216
+ n = len(y_true)
1217
+ if n < n_bins:
1218
+ raise ValueError(f"n={n} smaller than n_bins={n_bins}; cannot form quantile bins")
1219
+
1220
+ y = np.asarray(y_true, dtype=float)
1221
+ p = np.asarray(y_score, dtype=float)
1222
+ bs = float(np.mean((p - y) ** 2))
1223
+ y_bar = float(np.mean(y))
1224
+ uncertainty = y_bar * (1.0 - y_bar)
1225
+
1226
+ # Equal-mass binning per Nixon 2019.
1227
+ order = np.argsort(p, kind="stable")
1228
+ sorted_p = p[order]
1229
+ sorted_y = y[order]
1230
+ edges = np.linspace(0, n, n_bins + 1, dtype=int)
1231
+
1232
+ reliability = 0.0
1233
+ resolution = 0.0
1234
+ for b in range(n_bins):
1235
+ lo, hi = int(edges[b]), int(edges[b + 1])
1236
+ if hi <= lo:
1237
+ continue
1238
+ n_k = hi - lo
1239
+ p_bar_k = float(np.mean(sorted_p[lo:hi]))
1240
+ y_bar_k = float(np.mean(sorted_y[lo:hi]))
1241
+ weight = n_k / n
1242
+ reliability += weight * (p_bar_k - y_bar_k) ** 2
1243
+ resolution += weight * (y_bar_k - y_bar) ** 2
1244
+ return {
1245
+ "brier": bs,
1246
+ "reliability": float(reliability),
1247
+ "resolution": float(resolution),
1248
+ "uncertainty": float(uncertainty),
1249
+ }
1250
+
1251
+
1252
+ def quantile_stratified_pr_auc(
1253
+ y_true: np.ndarray,
1254
+ y_score: np.ndarray,
1255
+ stratifier: np.ndarray,
1256
+ q_low: float = 0.25,
1257
+ q_high: float = 0.75,
1258
+ ) -> dict[str, float]:
1259
+ """PR-AUC on the central [q_low, q_high] range of any 1-D stratifier.
1260
+
1261
+ Useful when you suspect a metric has accidentally learned a confounder
1262
+ correlated with the stratifier (e.g., text length, time-of-day, document
1263
+ size). Filter the tails (where one class typically dominates) and recompute
1264
+ PR-AUC on the central window where both classes are represented.
1265
+
1266
+ Parameters
1267
+ ----------
1268
+ y_true : np.ndarray, shape (n,)
1269
+ Binary labels.
1270
+ y_score : np.ndarray, shape (n,)
1271
+ Scores.
1272
+ stratifier : np.ndarray, shape (n,)
1273
+ Numeric values used to define the quantile window.
1274
+ q_low, q_high : float, optional
1275
+ Quantile bounds for the kept window (default 0.25 to 0.75).
1276
+
1277
+ Returns
1278
+ -------
1279
+ dict
1280
+ ``{"pr_auc", "n", "n_positive", "n_negative", "stratifier_low",
1281
+ "stratifier_high", "q_low", "q_high"}``.
1282
+
1283
+ Raises
1284
+ ------
1285
+ ValueError
1286
+ If shapes mismatch, quantile bounds invalid, or the kept window has
1287
+ too few positives/negatives (< 10) for a reliable PR-AUC.
1288
+
1289
+ Notes
1290
+ -----
1291
+ When ``stratifier`` is the score itself, this method becomes a partial
1292
+ AUC over a score-quantile window — the same construct as McClish 1989's
1293
+ "partial area under the ROC curve" (in PR space rather than ROC).
1294
+
1295
+ See Also
1296
+ --------
1297
+ eval_toolkit.metrics.stratified_recall :
1298
+ Categorical-stratum recall.
1299
+
1300
+ References
1301
+ ----------
1302
+ .. [1] McClish, D. K. "Analyzing a portion of the ROC curve."
1303
+ Medical Decision Making 9(3), 1989. (Partial-AUC framework.)
1304
+ """
1305
+ _validate_inputs(y_true, y_score)
1306
+ strat_arr = np.asarray(stratifier)
1307
+ if strat_arr.shape != np.asarray(y_true).shape:
1308
+ raise ValueError(
1309
+ f"stratifier shape {strat_arr.shape} != y_true shape {np.asarray(y_true).shape}"
1310
+ )
1311
+ if not 0.0 <= q_low < q_high <= 1.0:
1312
+ raise ValueError(f"need 0 ≤ q_low < q_high ≤ 1, got {q_low}, {q_high}")
1313
+ lo, hi = np.quantile(strat_arr, [q_low, q_high])
1314
+ mask = (strat_arr >= lo) & (strat_arr <= hi)
1315
+ if not mask.any():
1316
+ raise ValueError("no rows in stratifier window")
1317
+ sub_y = np.asarray(y_true)[mask]
1318
+ sub_s = np.asarray(y_score)[mask]
1319
+ n_pos = int((sub_y == 1).sum())
1320
+ n_neg = int((sub_y == 0).sum())
1321
+ if n_pos < 10 or n_neg < 10:
1322
+ raise ValueError(f"stratified subset too imbalanced for PR-AUC: pos={n_pos}, neg={n_neg}")
1323
+ return {
1324
+ "pr_auc": pr_auc(sub_y, sub_s),
1325
+ "n": int(mask.sum()),
1326
+ "n_positive": n_pos,
1327
+ "n_negative": n_neg,
1328
+ "stratifier_low": float(lo),
1329
+ "stratifier_high": float(hi),
1330
+ "q_low": float(q_low),
1331
+ "q_high": float(q_high),
1332
+ }
1333
+
1334
+
1335
+ def quantile_stratified_report(
1336
+ y_true: np.ndarray,
1337
+ y_score: np.ndarray,
1338
+ stratifier: np.ndarray,
1339
+ *,
1340
+ q_low: float = 0.25,
1341
+ q_high: float = 0.75,
1342
+ gap_threshold: float = 0.05,
1343
+ ) -> dict[str, float | bool]:
1344
+ """Full vs trimmed PR-AUC report with a gap-flag (SDD reporting convention).
1345
+
1346
+ Wraps :func:`pr_auc` (full) + :func:`quantile_stratified_pr_auc` (trimmed)
1347
+ into the four-field report shape used by ``prompt-injection-sdd`` style
1348
+ reports: a *full* PR-AUC over all rows, a *trimmed* PR-AUC over the
1349
+ central ``[q_low, q_high]`` quantile window of ``stratifier``, the
1350
+ arithmetic ``gap = full - trimmed``, and a boolean ``gap_flag`` set when
1351
+ ``gap > gap_threshold``.
1352
+
1353
+ A positive gap means the model exploits a confounder correlated with
1354
+ ``stratifier`` (e.g., text length, time-of-day, document source); the
1355
+ `gap_flag` surfaces this as a single auditable bit alongside the
1356
+ headline metric — useful for at-a-glance reviewer-friendly tables.
1357
+
1358
+ Parameters
1359
+ ----------
1360
+ y_true : np.ndarray, shape (n,)
1361
+ Binary labels.
1362
+ y_score : np.ndarray, shape (n,)
1363
+ Scores.
1364
+ stratifier : np.ndarray, shape (n,)
1365
+ Numeric values defining the quantile window.
1366
+ q_low, q_high : float, optional
1367
+ Quantile bounds for the trimmed window. Default ``(0.25, 0.75)``.
1368
+ gap_threshold : float, optional
1369
+ Threshold above which ``gap_flag`` is True. Default ``0.05``
1370
+ (SDD convention).
1371
+
1372
+ Returns
1373
+ -------
1374
+ dict
1375
+ Keys: ``full``, ``trimmed``, ``gap``, ``gap_flag``.
1376
+
1377
+ Raises
1378
+ ------
1379
+ ValueError
1380
+ Forwarded from :func:`pr_auc` and :func:`quantile_stratified_pr_auc`.
1381
+
1382
+ Examples
1383
+ --------
1384
+ >>> import numpy as np
1385
+ >>> from eval_toolkit import quantile_stratified_report
1386
+ >>> rng = np.random.default_rng(0)
1387
+ >>> n = 500
1388
+ >>> y = rng.binomial(1, 0.3, size=n)
1389
+ >>> s = np.clip(y * 0.6 + rng.normal(0, 0.3, size=n), 0, 1)
1390
+ >>> lengths = rng.integers(10, 200, size=n)
1391
+ >>> report = quantile_stratified_report(y, s, lengths)
1392
+ >>> bool(report["full"] >= report["trimmed"] - 0.5) # sanity
1393
+ True
1394
+ >>> isinstance(report["gap_flag"], bool | np.bool_)
1395
+ True
1396
+
1397
+ See Also
1398
+ --------
1399
+ eval_toolkit.metrics.quantile_stratified_pr_auc :
1400
+ The trimmed-PR-AUC primitive this report wraps.
1401
+ eval_toolkit.metrics.pr_auc :
1402
+ The full-PR-AUC primitive.
1403
+
1404
+ Notes
1405
+ -----
1406
+ See ``docs/methodology/length_stratification.md`` for the methodology
1407
+ motivation (McClish 1989 partial-AUC framing; when stratified vs.
1408
+ global PR-AUC).
1409
+ """
1410
+ full = float(pr_auc(y_true, y_score))
1411
+ trimmed_block = quantile_stratified_pr_auc(
1412
+ y_true, y_score, stratifier, q_low=q_low, q_high=q_high
1413
+ )
1414
+ trimmed = float(trimmed_block["pr_auc"])
1415
+ gap = full - trimmed
1416
+ return {
1417
+ "full": full,
1418
+ "trimmed": trimmed,
1419
+ "gap": gap,
1420
+ "gap_flag": gap > gap_threshold,
1421
+ }
1422
+
1423
+
1424
+ def precision_at_prior(
1425
+ y_true: np.ndarray,
1426
+ y_score: np.ndarray,
1427
+ threshold: float,
1428
+ assumed_prior: float,
1429
+ ) -> dict[str, float]:
1430
+ r"""Project precision under a different positive-class prior.
1431
+
1432
+ Operating points tuned at one prevalence are systematically too permissive
1433
+ or too restrictive at deployment-time prevalence. This applies Bayes' rule
1434
+ to extrapolate precision under a different positive prior, holding TPR
1435
+ and FPR fixed.
1436
+
1437
+ Parameters
1438
+ ----------
1439
+ y_true, y_score : np.ndarray
1440
+ Eval-set labels and scores.
1441
+ threshold : float
1442
+ Decision boundary on ``y_score``.
1443
+ assumed_prior : float
1444
+ Hypothesized deployment prior π ∈ (0, 1).
1445
+
1446
+ Returns
1447
+ -------
1448
+ dict
1449
+ ``{tpr, fpr, eval_prior, assumed_prior, precision_at_eval_prior,
1450
+ precision_at_assumed_prior, threshold}``.
1451
+
1452
+ Raises
1453
+ ------
1454
+ ValueError
1455
+ If ``assumed_prior`` is outside (0, 1) or the eval set lacks both
1456
+ classes (cannot estimate both TPR and FPR).
1457
+
1458
+ Examples
1459
+ --------
1460
+ >>> import numpy as np
1461
+ >>> rng = np.random.default_rng(42)
1462
+ >>> y = rng.integers(0, 2, size=200)
1463
+ >>> s = y + rng.normal(0, 0.3, size=200)
1464
+ >>> result = precision_at_prior(y, s, threshold=0.5, assumed_prior=0.01)
1465
+ >>> 0.0 <= result["precision_at_assumed_prior"] <= 1.0
1466
+ True
1467
+
1468
+ Notes
1469
+ -----
1470
+ Bayes' rule with TPR and FPR fixed:
1471
+
1472
+ .. math::
1473
+
1474
+ P(y=1 | \hat{y}=1) = \frac{\pi \cdot \mathrm{TPR}}{\pi \cdot \mathrm{TPR} + (1-\pi) \cdot \mathrm{FPR}}
1475
+
1476
+ Caveat: assumes the *class-conditional* score distributions on the eval
1477
+ set match deployment. If the input distribution shifts, TPR/FPR move and
1478
+ this projection no longer holds.
1479
+
1480
+ References
1481
+ ----------
1482
+ .. [1] Saerens, M., Latinne, P., & Decaestecker, C. "Adjusting the outputs
1483
+ of a classifier to new a priori probabilities: A simple procedure."
1484
+ Neural Computation 14(1), 2002.
1485
+ """
1486
+ _validate_inputs(y_true, y_score)
1487
+ if not 0.0 < assumed_prior < 1.0:
1488
+ raise ValueError(f"assumed_prior must be in (0, 1), got {assumed_prior}")
1489
+ y_true_arr = np.asarray(y_true).astype(int)
1490
+ y_pred = (np.asarray(y_score) >= threshold).astype(int)
1491
+ n_pos = int(y_true_arr.sum())
1492
+ n_neg = int(len(y_true_arr) - n_pos)
1493
+ if n_pos == 0 or n_neg == 0:
1494
+ raise ValueError(
1495
+ f"need both classes for precision_at_prior; got n_pos={n_pos}, n_neg={n_neg}"
1496
+ )
1497
+ tpr = float(((y_pred == 1) & (y_true_arr == 1)).sum() / n_pos)
1498
+ fpr = float(((y_pred == 1) & (y_true_arr == 0)).sum() / n_neg)
1499
+ eval_prior = n_pos / (n_pos + n_neg)
1500
+ eval_precision = (eval_prior * tpr) / max(eval_prior * tpr + (1.0 - eval_prior) * fpr, 1e-12)
1501
+ assumed_precision = (assumed_prior * tpr) / max(
1502
+ assumed_prior * tpr + (1.0 - assumed_prior) * fpr, 1e-12
1503
+ )
1504
+ return {
1505
+ "tpr": tpr,
1506
+ "fpr": fpr,
1507
+ "eval_prior": float(eval_prior),
1508
+ "assumed_prior": float(assumed_prior),
1509
+ "precision_at_eval_prior": float(eval_precision),
1510
+ "precision_at_assumed_prior": float(assumed_precision),
1511
+ "threshold": float(threshold),
1512
+ }
1513
+
1514
+
1515
+ DEFAULT_ASSUMED_PRIORS: Final[tuple[float, ...]] = (0.001, 0.01, 0.05)
1516
+
1517
+
1518
+ def score_distribution_summary(scores: np.ndarray) -> dict[str, float | int]:
1519
+ """Threshold-free score-distribution summary.
1520
+
1521
+ Reports ``mean / median / std / q25 / q75 / n`` so seed-stability of model
1522
+ confidence on a slice is auditable without committing to a deployment
1523
+ threshold. Especially useful for single-class slices where PR-AUC and
1524
+ threshold-dependent metrics are degenerate.
1525
+
1526
+ Parameters
1527
+ ----------
1528
+ scores : np.ndarray, shape (n,)
1529
+ 1-D array of predicted probabilities or scores.
1530
+
1531
+ Returns
1532
+ -------
1533
+ dict
1534
+ ``{"n", "mean", "median", "std", "q25", "q75"}``.
1535
+
1536
+ Raises
1537
+ ------
1538
+ ValueError
1539
+ If ``scores`` is empty or contains NaN/inf.
1540
+
1541
+ Examples
1542
+ --------
1543
+ >>> import numpy as np
1544
+ >>> rng = np.random.default_rng(42)
1545
+ >>> scores = rng.uniform(0, 1, size=100)
1546
+ >>> result = score_distribution_summary(scores)
1547
+ >>> result["n"]
1548
+ 100
1549
+ """
1550
+ arr = np.asarray(scores).astype(float).ravel()
1551
+ if arr.size == 0:
1552
+ raise ValueError("scores is empty")
1553
+ if not np.isfinite(arr).all():
1554
+ raise ValueError("scores contains NaN or inf")
1555
+ return {
1556
+ "n": int(arr.size),
1557
+ "mean": float(arr.mean()),
1558
+ "median": float(np.median(arr)),
1559
+ "std": float(arr.std(ddof=0)),
1560
+ "q25": float(np.quantile(arr, 0.25)),
1561
+ "q75": float(np.quantile(arr, 0.75)),
1562
+ }
1563
+
1564
+
1565
+ def headline_metrics(
1566
+ y_true: np.ndarray,
1567
+ y_score: np.ndarray,
1568
+ strata: np.ndarray | None = None,
1569
+ assumed_priors: tuple[float, ...] = DEFAULT_ASSUMED_PRIORS,
1570
+ ) -> dict[str, object]:
1571
+ """Bundle PR-AUC + ROC-AUC + 3 operating-point F1s + per-stratum recall (if provided).
1572
+
1573
+ Also reports both equal-width and equal-mass ECE and
1574
+ ``precision_at_prior`` at the max-F1 threshold across ``assumed_priors``
1575
+ for prior-shift sensitivity.
1576
+
1577
+ Parameters
1578
+ ----------
1579
+ y_true, y_score : np.ndarray
1580
+ Labels and scores.
1581
+ strata : np.ndarray or None, optional
1582
+ Categorical groupings for stratified recall. If None, that section is
1583
+ omitted.
1584
+ assumed_priors : tuple of float, optional
1585
+ Priors at which to project precision (default ``(0.001, 0.01, 0.05)``).
1586
+
1587
+ Returns
1588
+ -------
1589
+ dict
1590
+ Headline bundle with keys: ``n``, ``n_positive``, ``pr_auc``,
1591
+ ``roc_auc``, ``operating_points``, ``ece_equal_width``,
1592
+ ``ece_equal_mass``, ``ece`` (alias for equal-width), and optionally
1593
+ ``per_stratum_recall_at_max_f1``, ``precision_at_prior``.
1594
+
1595
+ Notes
1596
+ -----
1597
+ For single-class slices, PR-AUC / ROC-AUC / threshold-selected metrics are
1598
+ set to ``None`` and a ``metric_note`` field explains why; use
1599
+ :func:`single_class_threshold_metrics` for those slices instead.
1600
+ """
1601
+ y_true_arr = np.asarray(y_true)
1602
+ unique_labels = set(np.unique(y_true_arr).tolist())
1603
+ is_single_class = len(unique_labels) == 1
1604
+ out: dict[str, object] = {
1605
+ "n": int(len(y_true)),
1606
+ "n_positive": int(np.sum(y_true)),
1607
+ }
1608
+ if is_single_class:
1609
+ only_label = int(next(iter(unique_labels)))
1610
+ out["pr_auc"] = None
1611
+ out["roc_auc"] = None
1612
+ out["single_class_label"] = only_label
1613
+ out["metric_note"] = (
1614
+ "single-class slice; PR-AUC/ROC-AUC/threshold-selected F1 are not meaningful. "
1615
+ "Use externally selected threshold operating metrics instead."
1616
+ )
1617
+ else:
1618
+ out["pr_auc"] = pr_auc(y_true, y_score)
1619
+ out["roc_auc"] = roc_auc(y_true, y_score)
1620
+ # Late import: thresholds.py imports ThresholdResult / metrics_at_threshold
1621
+ # from this module, so module-level imports would be circular.
1622
+ from eval_toolkit.thresholds import MaxF1Selector, TargetRecallSelector
1623
+
1624
+ operating_points: dict[str, Mapping[str, object]] = {}
1625
+ selectors_for_headline: list[ThresholdSelector] = [
1626
+ MaxF1Selector(),
1627
+ TargetRecallSelector(0.90),
1628
+ TargetRecallSelector(0.95),
1629
+ ]
1630
+ for selector in selectors_for_headline:
1631
+ label = selector.criterion
1632
+ if is_single_class:
1633
+ operating_points[label] = {
1634
+ "skipped": "single-class slice; threshold must be selected on a mixed-class slice"
1635
+ }
1636
+ continue
1637
+ try:
1638
+ tr = selector.select(y_true, y_score)
1639
+ operating_points[label] = metrics_at_threshold(y_true, y_score, tr.threshold)
1640
+ except RuntimeError as exc:
1641
+ operating_points[label] = {"error": str(exc)}
1642
+ out["operating_points"] = operating_points
1643
+
1644
+ # Stratified recall reported at max-F1 threshold (when strata provided)
1645
+ if strata is not None:
1646
+ if is_single_class:
1647
+ out["per_stratum_recall_at_max_f1"] = {
1648
+ "skipped": "single-class slice; threshold selected on mixed-class slice required"
1649
+ }
1650
+ else:
1651
+ try:
1652
+ mf1_thresh = MaxF1Selector().select(y_true, y_score).threshold
1653
+ out["per_stratum_recall_at_max_f1"] = stratified_recall(
1654
+ y_true, y_score, mf1_thresh, strata
1655
+ )
1656
+ except RuntimeError as exc:
1657
+ out["per_stratum_recall_at_max_f1"] = {"error": str(exc)}
1658
+
1659
+ out["ece_equal_width"] = expected_calibration_error(y_true, y_score)
1660
+ try:
1661
+ out["ece_equal_mass"] = expected_calibration_error_equal_mass(y_true, y_score)
1662
+ except ValueError as exc:
1663
+ out["ece_equal_mass"] = float("nan")
1664
+ out["ece_equal_mass_error"] = str(exc)
1665
+ out["ece"] = out["ece_equal_width"]
1666
+
1667
+ if is_single_class:
1668
+ out["precision_at_prior"] = {
1669
+ "skipped": "single-class slice cannot estimate both TPR and FPR"
1670
+ }
1671
+ return out
1672
+
1673
+ n_pos = int(np.sum(y_true))
1674
+ n_neg = int(len(y_true) - n_pos)
1675
+ if n_pos > 0 and n_neg > 0:
1676
+ try:
1677
+ mf1_thresh = MaxF1Selector().select(y_true, y_score).threshold
1678
+ out["precision_at_prior"] = {
1679
+ f"{p:g}": precision_at_prior(y_true, y_score, mf1_thresh, p) for p in assumed_priors
1680
+ }
1681
+ except RuntimeError as exc:
1682
+ out["precision_at_prior"] = {"error": str(exc)}
1683
+ return out
1684
+
1685
+
1686
+ def _validate_inputs(y_true: np.ndarray, y_score: np.ndarray) -> None:
1687
+ """Common input validation. Fail-fast diagnostic errors."""
1688
+ y_true_arr = np.asarray(y_true)
1689
+ y_score_arr = np.asarray(y_score)
1690
+ if y_true_arr.shape != y_score_arr.shape:
1691
+ raise ValueError(f"y_true shape {y_true_arr.shape} != y_score shape {y_score_arr.shape}")
1692
+ if y_true_arr.ndim != 1:
1693
+ raise ValueError(f"y_true must be 1-D, got shape {y_true_arr.shape}")
1694
+ if len(y_true_arr) == 0:
1695
+ raise ValueError("y_true is empty")
1696
+ unique = set(np.unique(y_true_arr).tolist())
1697
+ if not unique.issubset({0, 1}):
1698
+ raise ValueError(f"y_true must be binary (0/1), got values {unique}")
1699
+ # NaN/Inf guard — silent ranking distortions if scores carry non-finite
1700
+ # values; harmonizes with score_distribution_summary's own guard.
1701
+ if not np.isfinite(y_score_arr).all():
1702
+ raise ValueError("y_score contains NaN or inf")
1703
+
1704
+
1705
+ def _validate_calibrated_score(y_score: np.ndarray, name: str = "y_score") -> None:
1706
+ """Probability-range validation for calibration-aware metrics.
1707
+
1708
+ Calibration metrics (ECE variants) are only meaningful when ``y_score``
1709
+ is in ``[0, 1]``. Raw logits silently produce a meaningless ECE; this
1710
+ guard fails loudly with a diagnostic.
1711
+ """
1712
+ arr = np.asarray(y_score)
1713
+ if arr.size == 0:
1714
+ return # _validate_inputs catches empty arrays
1715
+ if arr.min() < 0.0 or arr.max() > 1.0:
1716
+ raise ValueError(
1717
+ f"{name} must be in [0, 1] for calibration metrics; got "
1718
+ f"range [{float(arr.min()):.4g}, {float(arr.max()):.4g}]. "
1719
+ "If you have logits, apply softmax/sigmoid first."
1720
+ )