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.
- eval_toolkit/__init__.py +238 -0
- eval_toolkit/__main__.py +156 -0
- eval_toolkit/_version.py +5 -0
- eval_toolkit/analysis.py +196 -0
- eval_toolkit/artifacts.py +376 -0
- eval_toolkit/bootstrap.py +1344 -0
- eval_toolkit/calibration.py +1143 -0
- eval_toolkit/claims.py +670 -0
- eval_toolkit/config.py +112 -0
- eval_toolkit/docs.py +305 -0
- eval_toolkit/evidence.py +90 -0
- eval_toolkit/harness.py +1193 -0
- eval_toolkit/leakage.py +1052 -0
- eval_toolkit/loaders.py +424 -0
- eval_toolkit/manifest.py +622 -0
- eval_toolkit/metrics.py +1720 -0
- eval_toolkit/operating_points.py +192 -0
- eval_toolkit/paths.py +125 -0
- eval_toolkit/plotting.py +991 -0
- eval_toolkit/protocols.py +98 -0
- eval_toolkit/provenance.py +255 -0
- eval_toolkit/py.typed +0 -0
- eval_toolkit/schemas/manifest.v1.json +155 -0
- eval_toolkit/schemas/manifest.v2.json +186 -0
- eval_toolkit/schemas/manifest.v3.json +186 -0
- eval_toolkit/schemas/results.v1.json +87 -0
- eval_toolkit/schemas/results_full.v1.json +83 -0
- eval_toolkit/seeds.py +119 -0
- eval_toolkit/splits.py +520 -0
- eval_toolkit/text_dedup.py +1403 -0
- eval_toolkit/thresholds.py +819 -0
- eval_toolkit-0.27.1.dist-info/METADATA +314 -0
- eval_toolkit-0.27.1.dist-info/RECORD +36 -0
- eval_toolkit-0.27.1.dist-info/WHEEL +4 -0
- eval_toolkit-0.27.1.dist-info/entry_points.txt +2 -0
- eval_toolkit-0.27.1.dist-info/licenses/LICENSE +21 -0
eval_toolkit/metrics.py
ADDED
|
@@ -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
|
+
)
|