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
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
"""Threshold selection: pluggable :class:`ThresholdSelector` Protocol + reference impls.
|
|
2
|
+
|
|
3
|
+
A :class:`ThresholdSelector` is anything implementing
|
|
4
|
+
``select(y_true, y_score) -> ThresholdResult``. The toolkit ships six reference
|
|
5
|
+
impls covering the rules seen in real binary-classification eval workflows:
|
|
6
|
+
|
|
7
|
+
- :class:`MaxF1Selector` — argmax F1 over the PR curve
|
|
8
|
+
- :class:`TargetRecallSelector(recall)` — *highest* threshold meeting recall ≥ target
|
|
9
|
+
- :class:`TargetPrecisionSelector(precision)` — *smallest* threshold meeting precision ≥ target
|
|
10
|
+
- :class:`TargetFPRSelector(fpr)` — *smallest* threshold meeting FPR ≤ target on the ROC curve
|
|
11
|
+
- :class:`YoudenJSelector` — argmax (TPR − FPR) on the ROC curve
|
|
12
|
+
- :class:`CostSensitiveSelector(cost_matrix)` — Bayes-optimal threshold from
|
|
13
|
+
prior + cost matrix (assumes ``y_score`` is a calibrated probability)
|
|
14
|
+
|
|
15
|
+
Replaces the v0.6 string-criterion API in :func:`metrics.select_threshold`.
|
|
16
|
+
See ``CHANGELOG.md`` for the migration mapping.
|
|
17
|
+
|
|
18
|
+
References
|
|
19
|
+
----------
|
|
20
|
+
.. [1] Lipton, Z., Elkan, C., & Naryanaswamy, B. "Optimal thresholding of
|
|
21
|
+
classifiers to maximize F1 measure." ECML PKDD 2014. arXiv:1402.1892.
|
|
22
|
+
.. [2] Youden, W. J. "Index for rating diagnostic tests." Cancer 3(1), 1950.
|
|
23
|
+
.. [3] Elkan, C. "The foundations of cost-sensitive learning." IJCAI 2001.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Mapping
|
|
29
|
+
from dataclasses import asdict, dataclass, field
|
|
30
|
+
from typing import Protocol, runtime_checkable
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
from scipy.stats import norm as _scipy_norm
|
|
34
|
+
from sklearn.metrics import precision_recall_curve, roc_curve
|
|
35
|
+
|
|
36
|
+
from eval_toolkit.calibration import CostMatrix
|
|
37
|
+
from eval_toolkit.metrics import ThresholdResult, _validate_inputs, metrics_at_threshold
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"CISafeThresholdSelector",
|
|
41
|
+
"CostSensitiveSelector",
|
|
42
|
+
"MaxF1Selector",
|
|
43
|
+
"TargetFPRSelector",
|
|
44
|
+
"TargetPrecisionSelector",
|
|
45
|
+
"TargetRecallSelector",
|
|
46
|
+
"ThresholdPolicyMetadata",
|
|
47
|
+
"ThresholdSelector",
|
|
48
|
+
"WilsonInterval",
|
|
49
|
+
"YoudenJSelector",
|
|
50
|
+
"select_threshold",
|
|
51
|
+
"wilson_interval",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@runtime_checkable
|
|
56
|
+
class ThresholdSelector(Protocol):
|
|
57
|
+
"""Selects a decision threshold from ``(y_true, y_score)``.
|
|
58
|
+
|
|
59
|
+
Implementations are typically frozen dataclasses with config in the
|
|
60
|
+
constructor (e.g., ``TargetRecallSelector(recall=0.90)``) and a
|
|
61
|
+
:meth:`select` method that performs the sweep. The returned
|
|
62
|
+
:class:`~eval_toolkit.metrics.ThresholdResult` is the interchange type so
|
|
63
|
+
every selector composes with :func:`metrics.metrics_at_threshold` and the
|
|
64
|
+
paired-bootstrap operating-point machinery.
|
|
65
|
+
|
|
66
|
+
Attributes
|
|
67
|
+
----------
|
|
68
|
+
criterion : str
|
|
69
|
+
Stable label for this selector instance, written into the
|
|
70
|
+
:class:`ThresholdResult` and used as a dict key in headline output.
|
|
71
|
+
Implementations may expose it as a class-level default
|
|
72
|
+
(``MaxF1Selector``) or as a derived property (``TargetRecallSelector``).
|
|
73
|
+
|
|
74
|
+
Notes
|
|
75
|
+
-----
|
|
76
|
+
Runtime-checkable: ``isinstance(obj, ThresholdSelector)`` returns ``True``
|
|
77
|
+
for any object exposing both :meth:`select` and :attr:`criterion`. Mirrors
|
|
78
|
+
the :class:`eval_toolkit.harness.Scorer` Protocol pattern.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def criterion(self) -> str: # pragma: no cover
|
|
83
|
+
"""Stable label written into the resulting :class:`ThresholdResult`."""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
def select( # pragma: no cover
|
|
87
|
+
self, y_true: np.ndarray, y_score: np.ndarray
|
|
88
|
+
) -> ThresholdResult:
|
|
89
|
+
"""Return the chosen threshold and its precision / recall / F1."""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _f1(precision: float, recall: float) -> float:
|
|
94
|
+
"""F1 with safe denominator. Matches the existing ``select_threshold`` body."""
|
|
95
|
+
return float(2 * precision * recall / max(precision + recall, 1e-12))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _pr_curve_trim(
|
|
99
|
+
y_true: np.ndarray, y_score: np.ndarray
|
|
100
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
101
|
+
"""PR curve with the trailing ``recall=0`` row dropped.
|
|
102
|
+
|
|
103
|
+
sklearn returns ``N+1`` precision / recall but ``N`` thresholds; the
|
|
104
|
+
``[:-1]`` trim aligns indices into a single-axis selection.
|
|
105
|
+
"""
|
|
106
|
+
precisions, recalls, thresholds = precision_recall_curve(y_true, y_score)
|
|
107
|
+
if len(thresholds) == 0:
|
|
108
|
+
raise RuntimeError("PR curve has no thresholds — y_score may be constant")
|
|
109
|
+
return precisions[:-1], recalls[:-1], thresholds
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _roc_curve_trim(
|
|
113
|
+
y_true: np.ndarray, y_score: np.ndarray
|
|
114
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
115
|
+
"""ROC curve with the leading "predict all negative" sentinel row dropped.
|
|
116
|
+
|
|
117
|
+
sklearn pads ``thresholds[0]`` with ``max(y_score) + 1`` (or ``+inf`` in
|
|
118
|
+
older versions) representing the trivial "predict everything negative"
|
|
119
|
+
point. It is never an interesting operating point for selection — drop it
|
|
120
|
+
so threshold values returned by ROC-based selectors stay finite and
|
|
121
|
+
bounded by the score range.
|
|
122
|
+
"""
|
|
123
|
+
fprs, tprs, thresholds = roc_curve(y_true, y_score)
|
|
124
|
+
if len(thresholds) == 0:
|
|
125
|
+
raise RuntimeError("ROC curve has no thresholds — y_score may be constant")
|
|
126
|
+
score_max = float(np.asarray(y_score).max())
|
|
127
|
+
if len(thresholds) >= 2 and (np.isinf(thresholds[0]) or thresholds[0] > score_max):
|
|
128
|
+
return fprs[1:], tprs[1:], thresholds[1:]
|
|
129
|
+
return fprs, tprs, thresholds
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _result_at(
|
|
133
|
+
y_true: np.ndarray, y_score: np.ndarray, threshold: float, criterion: str
|
|
134
|
+
) -> ThresholdResult:
|
|
135
|
+
"""Build a :class:`ThresholdResult` by evaluating metrics at ``threshold``."""
|
|
136
|
+
m = metrics_at_threshold(y_true, y_score, threshold)
|
|
137
|
+
return ThresholdResult(
|
|
138
|
+
threshold=float(threshold),
|
|
139
|
+
f1=float(m["f1"]),
|
|
140
|
+
precision=float(m["precision"]),
|
|
141
|
+
recall=float(m["recall"]),
|
|
142
|
+
criterion=criterion,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _record_float(row: Mapping[str, object], key: str) -> float:
|
|
147
|
+
"""Read a numeric candidate-record field as float."""
|
|
148
|
+
value = row[key]
|
|
149
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
150
|
+
raise TypeError(f"candidate record field {key!r} must be numeric")
|
|
151
|
+
return float(value)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _record_float_or_inf(row: Mapping[str, object], key: str) -> float:
|
|
155
|
+
"""Read an optional numeric candidate-record field, defaulting to +inf."""
|
|
156
|
+
value = row[key]
|
|
157
|
+
if value is None:
|
|
158
|
+
return float("inf")
|
|
159
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
160
|
+
raise TypeError(f"candidate record field {key!r} must be numeric or null")
|
|
161
|
+
return float(value)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True, slots=True)
|
|
165
|
+
class WilsonInterval:
|
|
166
|
+
"""Wilson score interval for a binomial rate."""
|
|
167
|
+
|
|
168
|
+
low: float | None
|
|
169
|
+
high: float | None
|
|
170
|
+
confidence: float
|
|
171
|
+
successes: int
|
|
172
|
+
n: int
|
|
173
|
+
|
|
174
|
+
def to_dict(self) -> dict[str, object]:
|
|
175
|
+
"""JSON-serializable representation."""
|
|
176
|
+
return {
|
|
177
|
+
"low": self.low,
|
|
178
|
+
"high": self.high,
|
|
179
|
+
"confidence": self.confidence,
|
|
180
|
+
"successes": self.successes,
|
|
181
|
+
"n": self.n,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def wilson_interval(successes: int, n: int, *, confidence: float = 0.95) -> WilsonInterval:
|
|
186
|
+
"""Wilson score interval for a binomial rate.
|
|
187
|
+
|
|
188
|
+
Raises
|
|
189
|
+
------
|
|
190
|
+
ValueError
|
|
191
|
+
If ``successes`` is negative, ``n`` is negative, ``successes > n``,
|
|
192
|
+
or ``confidence`` is not in (0, 1).
|
|
193
|
+
"""
|
|
194
|
+
if successes < 0:
|
|
195
|
+
raise ValueError("successes must be non-negative")
|
|
196
|
+
if n < 0:
|
|
197
|
+
raise ValueError("n must be non-negative")
|
|
198
|
+
if successes > n:
|
|
199
|
+
raise ValueError("successes cannot exceed n")
|
|
200
|
+
if not 0.0 < confidence < 1.0:
|
|
201
|
+
raise ValueError(f"confidence must be in (0, 1), got {confidence}")
|
|
202
|
+
if n == 0:
|
|
203
|
+
return WilsonInterval(
|
|
204
|
+
low=None,
|
|
205
|
+
high=None,
|
|
206
|
+
confidence=confidence,
|
|
207
|
+
successes=successes,
|
|
208
|
+
n=n,
|
|
209
|
+
)
|
|
210
|
+
z = float(_scipy_norm.ppf(0.5 + confidence / 2.0))
|
|
211
|
+
p_hat = successes / n
|
|
212
|
+
denom = 1.0 + z * z / n
|
|
213
|
+
centre = (p_hat + z * z / (2.0 * n)) / denom
|
|
214
|
+
margin = (z * np.sqrt(p_hat * (1.0 - p_hat) / n + z * z / (4.0 * n * n))) / denom
|
|
215
|
+
return WilsonInterval(
|
|
216
|
+
low=float(max(0.0, centre - margin)),
|
|
217
|
+
high=float(min(1.0, centre + margin)),
|
|
218
|
+
confidence=confidence,
|
|
219
|
+
successes=successes,
|
|
220
|
+
n=n,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True, slots=True)
|
|
225
|
+
class ThresholdPolicyMetadata:
|
|
226
|
+
"""Pre-registration metadata for thresholded operating claims."""
|
|
227
|
+
|
|
228
|
+
calibration_slice: str
|
|
229
|
+
score_column: str
|
|
230
|
+
selector: str
|
|
231
|
+
constraints: dict[str, float]
|
|
232
|
+
claim_enabled: bool = False
|
|
233
|
+
notes: str = ""
|
|
234
|
+
|
|
235
|
+
def __post_init__(self) -> None:
|
|
236
|
+
"""Validate the policy metadata."""
|
|
237
|
+
if not self.calibration_slice:
|
|
238
|
+
raise ValueError("calibration_slice must be non-empty")
|
|
239
|
+
if not self.score_column:
|
|
240
|
+
raise ValueError("score_column must be non-empty")
|
|
241
|
+
if not self.selector:
|
|
242
|
+
raise ValueError("selector must be non-empty")
|
|
243
|
+
if not self.constraints:
|
|
244
|
+
raise ValueError("constraints must be non-empty")
|
|
245
|
+
|
|
246
|
+
def to_dict(self) -> dict[str, object]:
|
|
247
|
+
"""JSON-serializable representation."""
|
|
248
|
+
return {
|
|
249
|
+
"calibration_slice": self.calibration_slice,
|
|
250
|
+
"score_column": self.score_column,
|
|
251
|
+
"selector": self.selector,
|
|
252
|
+
"constraints": dict(self.constraints),
|
|
253
|
+
"claim_enabled": self.claim_enabled,
|
|
254
|
+
"notes": self.notes,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(frozen=True, slots=True)
|
|
259
|
+
class CISafeThresholdSelector:
|
|
260
|
+
"""Select a threshold whose rate confidence bounds satisfy constraints.
|
|
261
|
+
|
|
262
|
+
The selector does not ship target defaults. Callers must provide one or
|
|
263
|
+
more explicit constraints, for example ``max_fpr`` and
|
|
264
|
+
``max_fpr_ci_upper`` for a low-FPR claim.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
max_fpr: float | None = None
|
|
268
|
+
max_fpr_ci_upper: float | None = None
|
|
269
|
+
min_recall: float | None = None
|
|
270
|
+
min_recall_ci_lower: float | None = None
|
|
271
|
+
confidence: float = 0.95
|
|
272
|
+
criterion: str = "ci_safe"
|
|
273
|
+
metadata: Mapping[str, object] = field(default_factory=dict)
|
|
274
|
+
|
|
275
|
+
def __post_init__(self) -> None:
|
|
276
|
+
"""Validate constraint shape."""
|
|
277
|
+
if not 0.0 < self.confidence < 1.0:
|
|
278
|
+
raise ValueError(f"confidence must be in (0, 1), got {self.confidence}")
|
|
279
|
+
constraints = (
|
|
280
|
+
self.max_fpr,
|
|
281
|
+
self.max_fpr_ci_upper,
|
|
282
|
+
self.min_recall,
|
|
283
|
+
self.min_recall_ci_lower,
|
|
284
|
+
)
|
|
285
|
+
if all(value is None for value in constraints):
|
|
286
|
+
raise ValueError("at least one CI-safe threshold constraint is required")
|
|
287
|
+
for name, value in (
|
|
288
|
+
("max_fpr", self.max_fpr),
|
|
289
|
+
("max_fpr_ci_upper", self.max_fpr_ci_upper),
|
|
290
|
+
("min_recall", self.min_recall),
|
|
291
|
+
("min_recall_ci_lower", self.min_recall_ci_lower),
|
|
292
|
+
):
|
|
293
|
+
if value is not None and not 0.0 <= value <= 1.0:
|
|
294
|
+
raise ValueError(f"{name} must be in [0, 1], got {value}")
|
|
295
|
+
|
|
296
|
+
def select(self, y_true: np.ndarray, y_score: np.ndarray) -> ThresholdResult:
|
|
297
|
+
"""Return the best accepted threshold.
|
|
298
|
+
|
|
299
|
+
Raises
|
|
300
|
+
------
|
|
301
|
+
RuntimeError
|
|
302
|
+
If no candidate threshold satisfies all the configured CI-safe
|
|
303
|
+
constraints (``max_fpr_ci_upper`` / ``min_recall_ci_lower``).
|
|
304
|
+
"""
|
|
305
|
+
accepted = [row for row in self.candidate_records(y_true, y_score) if row["accepted"]]
|
|
306
|
+
if not accepted:
|
|
307
|
+
raise RuntimeError("no threshold satisfies the configured CI-safe constraints")
|
|
308
|
+
best = min(
|
|
309
|
+
accepted,
|
|
310
|
+
key=lambda row: (
|
|
311
|
+
-_record_float(row, "recall"),
|
|
312
|
+
_record_float_or_inf(row, "fpr_ci_high"),
|
|
313
|
+
_record_float(row, "fpr"),
|
|
314
|
+
-_record_float(row, "threshold"),
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
return _result_at(y_true, y_score, _record_float(best, "threshold"), self.criterion)
|
|
318
|
+
|
|
319
|
+
def candidate_records(self, y_true: np.ndarray, y_score: np.ndarray) -> list[dict[str, object]]:
|
|
320
|
+
"""Return every candidate threshold with Wilson rate intervals."""
|
|
321
|
+
_validate_inputs(y_true, y_score)
|
|
322
|
+
y_true_arr = np.asarray(y_true, dtype=int).ravel()
|
|
323
|
+
y_score_arr = np.asarray(y_score, dtype=float).ravel()
|
|
324
|
+
thresholds = np.unique(y_score_arr[np.isfinite(y_score_arr)])
|
|
325
|
+
if thresholds.size == 0:
|
|
326
|
+
return []
|
|
327
|
+
neg_scores = np.sort(y_score_arr[y_true_arr == 0])
|
|
328
|
+
pos_scores = np.sort(y_score_arr[y_true_arr == 1])
|
|
329
|
+
n_neg = int(neg_scores.size)
|
|
330
|
+
n_pos = int(pos_scores.size)
|
|
331
|
+
rows: list[dict[str, object]] = []
|
|
332
|
+
for threshold in thresholds:
|
|
333
|
+
fp = int(n_neg - np.searchsorted(neg_scores, threshold, side="left"))
|
|
334
|
+
tp = int(n_pos - np.searchsorted(pos_scores, threshold, side="left"))
|
|
335
|
+
tn = n_neg - fp
|
|
336
|
+
fn = n_pos - tp
|
|
337
|
+
rows.append(self._candidate_record(float(threshold), tp=tp, fp=fp, tn=tn, fn=fn))
|
|
338
|
+
return rows
|
|
339
|
+
|
|
340
|
+
def selected_operating_point(
|
|
341
|
+
self,
|
|
342
|
+
y_true: np.ndarray,
|
|
343
|
+
y_score: np.ndarray,
|
|
344
|
+
*,
|
|
345
|
+
bootstrap_selected: bool = False,
|
|
346
|
+
n_resamples: int = 1000,
|
|
347
|
+
seed: int = 42,
|
|
348
|
+
) -> dict[str, object]:
|
|
349
|
+
"""Return selected threshold metadata and optional bootstrap CIs."""
|
|
350
|
+
selected = self.select(y_true, y_score)
|
|
351
|
+
records = self.candidate_records(y_true, y_score)
|
|
352
|
+
selected_record = next(
|
|
353
|
+
row for row in records if _record_float(row, "threshold") == float(selected.threshold)
|
|
354
|
+
)
|
|
355
|
+
out: dict[str, object] = {
|
|
356
|
+
"threshold_result": asdict(selected),
|
|
357
|
+
"selected_record": selected_record,
|
|
358
|
+
"n_candidates": len(records),
|
|
359
|
+
"n_accepted": sum(1 for row in records if row["accepted"]),
|
|
360
|
+
"constraints": self.constraints,
|
|
361
|
+
"metadata": dict(self.metadata),
|
|
362
|
+
}
|
|
363
|
+
if bootstrap_selected:
|
|
364
|
+
out["bootstrap_selected"] = _bootstrap_threshold_metric_cis(
|
|
365
|
+
y_true,
|
|
366
|
+
y_score,
|
|
367
|
+
selected.threshold,
|
|
368
|
+
n_resamples=n_resamples,
|
|
369
|
+
seed=seed,
|
|
370
|
+
)
|
|
371
|
+
return out
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def constraints(self) -> dict[str, float]:
|
|
375
|
+
"""Configured threshold constraints."""
|
|
376
|
+
out: dict[str, float] = {}
|
|
377
|
+
for key in ("max_fpr", "max_fpr_ci_upper", "min_recall", "min_recall_ci_lower"):
|
|
378
|
+
value = getattr(self, key)
|
|
379
|
+
if value is not None:
|
|
380
|
+
out[key] = float(value)
|
|
381
|
+
return out
|
|
382
|
+
|
|
383
|
+
def _candidate_record(
|
|
384
|
+
self,
|
|
385
|
+
threshold: float,
|
|
386
|
+
*,
|
|
387
|
+
tp: int,
|
|
388
|
+
fp: int,
|
|
389
|
+
tn: int,
|
|
390
|
+
fn: int,
|
|
391
|
+
) -> dict[str, object]:
|
|
392
|
+
n_pos = tp + fn
|
|
393
|
+
n_neg = fp + tn
|
|
394
|
+
fpr = float(fp / n_neg) if n_neg else 0.0
|
|
395
|
+
recall = float(tp / n_pos) if n_pos else 0.0
|
|
396
|
+
precision = float(tp / (tp + fp)) if (tp + fp) else 0.0
|
|
397
|
+
f1 = _f1(precision, recall)
|
|
398
|
+
fpr_ci = wilson_interval(fp, n_neg, confidence=self.confidence)
|
|
399
|
+
recall_ci = wilson_interval(tp, n_pos, confidence=self.confidence)
|
|
400
|
+
rejection_reasons: list[str] = []
|
|
401
|
+
if self.max_fpr is not None and fpr > self.max_fpr:
|
|
402
|
+
rejection_reasons.append("fpr_exceeds_max")
|
|
403
|
+
if self.max_fpr_ci_upper is not None and (
|
|
404
|
+
fpr_ci.high is None or fpr_ci.high > self.max_fpr_ci_upper
|
|
405
|
+
):
|
|
406
|
+
rejection_reasons.append("fpr_ci_upper_exceeds_max")
|
|
407
|
+
if self.min_recall is not None and recall < self.min_recall:
|
|
408
|
+
rejection_reasons.append("recall_below_min")
|
|
409
|
+
if self.min_recall_ci_lower is not None and (
|
|
410
|
+
recall_ci.low is None or recall_ci.low < self.min_recall_ci_lower
|
|
411
|
+
):
|
|
412
|
+
rejection_reasons.append("recall_ci_lower_below_min")
|
|
413
|
+
return {
|
|
414
|
+
"threshold": threshold,
|
|
415
|
+
"fpr": fpr,
|
|
416
|
+
"recall": recall,
|
|
417
|
+
"precision": precision,
|
|
418
|
+
"f1": f1,
|
|
419
|
+
"tp": tp,
|
|
420
|
+
"fp": fp,
|
|
421
|
+
"tn": tn,
|
|
422
|
+
"fn": fn,
|
|
423
|
+
"fpr_ci_low": fpr_ci.low,
|
|
424
|
+
"fpr_ci_high": fpr_ci.high,
|
|
425
|
+
"recall_ci_low": recall_ci.low,
|
|
426
|
+
"recall_ci_high": recall_ci.high,
|
|
427
|
+
"confidence": self.confidence,
|
|
428
|
+
"accepted": not rejection_reasons,
|
|
429
|
+
"rejection_reasons": rejection_reasons,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _bootstrap_threshold_metric_cis(
|
|
434
|
+
y_true: np.ndarray,
|
|
435
|
+
y_score: np.ndarray,
|
|
436
|
+
threshold: float,
|
|
437
|
+
*,
|
|
438
|
+
n_resamples: int,
|
|
439
|
+
seed: int,
|
|
440
|
+
) -> dict[str, object]:
|
|
441
|
+
"""Percentile bootstrap CIs for selected-threshold operating metrics."""
|
|
442
|
+
y_true_arr = np.asarray(y_true, dtype=int).ravel()
|
|
443
|
+
y_score_arr = np.asarray(y_score, dtype=float).ravel()
|
|
444
|
+
if y_true_arr.shape != y_score_arr.shape:
|
|
445
|
+
raise ValueError("y_true and y_score must have identical shape")
|
|
446
|
+
rng = np.random.default_rng(seed)
|
|
447
|
+
samples: dict[str, list[float]] = {"fpr": [], "recall": [], "precision": [], "f1": []}
|
|
448
|
+
n = len(y_true_arr)
|
|
449
|
+
for _ in range(n_resamples):
|
|
450
|
+
idx = rng.integers(0, n, size=n)
|
|
451
|
+
metrics = metrics_at_threshold(y_true_arr[idx], y_score_arr[idx], threshold)
|
|
452
|
+
for key in samples:
|
|
453
|
+
samples[key].append(float(metrics[key]))
|
|
454
|
+
return {
|
|
455
|
+
key: {
|
|
456
|
+
"ci_low": float(np.quantile(values, 0.025)),
|
|
457
|
+
"ci_high": float(np.quantile(values, 0.975)),
|
|
458
|
+
"n_resamples": n_resamples,
|
|
459
|
+
"method": "percentile",
|
|
460
|
+
}
|
|
461
|
+
for key, values in samples.items()
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@dataclass(frozen=True, slots=True)
|
|
466
|
+
class MaxF1Selector:
|
|
467
|
+
"""Argmax F1 over the PR curve. Replaces the v0.6 ``criterion="max_f1"``.
|
|
468
|
+
|
|
469
|
+
Examples
|
|
470
|
+
--------
|
|
471
|
+
>>> import numpy as np
|
|
472
|
+
>>> from eval_toolkit.thresholds import MaxF1Selector
|
|
473
|
+
>>> y = np.array([0, 0, 1, 1, 0, 1])
|
|
474
|
+
>>> s = np.array([0.1, 0.2, 0.7, 0.9, 0.3, 0.8])
|
|
475
|
+
>>> tr = MaxF1Selector().select(y, s)
|
|
476
|
+
>>> tr.f1
|
|
477
|
+
1.0
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
criterion: str = "max_f1"
|
|
481
|
+
|
|
482
|
+
def select(self, y_true: np.ndarray, y_score: np.ndarray) -> ThresholdResult:
|
|
483
|
+
"""Return the threshold maximizing F1."""
|
|
484
|
+
_validate_inputs(y_true, y_score)
|
|
485
|
+
precisions, recalls, thresholds = _pr_curve_trim(y_true, y_score)
|
|
486
|
+
f1s = 2 * precisions * recalls / np.clip(precisions + recalls, 1e-12, None)
|
|
487
|
+
idx = int(np.argmax(f1s))
|
|
488
|
+
return ThresholdResult(
|
|
489
|
+
threshold=float(thresholds[idx]),
|
|
490
|
+
f1=_f1(float(precisions[idx]), float(recalls[idx])),
|
|
491
|
+
precision=float(precisions[idx]),
|
|
492
|
+
recall=float(recalls[idx]),
|
|
493
|
+
criterion=self.criterion,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@dataclass(frozen=True, slots=True)
|
|
498
|
+
class TargetRecallSelector:
|
|
499
|
+
"""Highest threshold meeting recall ≥ target — most-precise feasible point.
|
|
500
|
+
|
|
501
|
+
Replaces ``criterion="recall_0.90"`` / ``"recall_0.95"`` (and
|
|
502
|
+
``"recall@0.90"`` from prompt-injection-sdd's local fork).
|
|
503
|
+
|
|
504
|
+
Parameters
|
|
505
|
+
----------
|
|
506
|
+
recall : float
|
|
507
|
+
Target recall floor in ``(0, 1]``.
|
|
508
|
+
|
|
509
|
+
Raises
|
|
510
|
+
------
|
|
511
|
+
ValueError
|
|
512
|
+
If ``recall`` is outside ``(0, 1]``.
|
|
513
|
+
RuntimeError
|
|
514
|
+
At ``select`` time, if no threshold achieves recall ≥ target.
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
recall: float
|
|
518
|
+
|
|
519
|
+
def __post_init__(self) -> None:
|
|
520
|
+
"""Validate the recall target."""
|
|
521
|
+
if not 0.0 < self.recall <= 1.0:
|
|
522
|
+
raise ValueError(f"recall must be in (0, 1], got {self.recall}")
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def criterion(self) -> str:
|
|
526
|
+
"""Stable label embedding the target recall."""
|
|
527
|
+
return f"recall_{self.recall:.2f}"
|
|
528
|
+
|
|
529
|
+
def select(self, y_true: np.ndarray, y_score: np.ndarray) -> ThresholdResult:
|
|
530
|
+
"""Return the highest threshold meeting recall ≥ ``self.recall``.
|
|
531
|
+
|
|
532
|
+
Raises
|
|
533
|
+
------
|
|
534
|
+
RuntimeError
|
|
535
|
+
If no threshold achieves the recall target (``max(recalls) <
|
|
536
|
+
self.recall``).
|
|
537
|
+
"""
|
|
538
|
+
_validate_inputs(y_true, y_score)
|
|
539
|
+
precisions, recalls, thresholds = _pr_curve_trim(y_true, y_score)
|
|
540
|
+
# Recall is monotonically non-increasing in threshold; eligible is
|
|
541
|
+
# contiguous starting at index 0; eligible[-1] is the highest
|
|
542
|
+
# threshold (most-precise) still satisfying the recall floor.
|
|
543
|
+
eligible = np.where(recalls >= self.recall)[0]
|
|
544
|
+
if len(eligible) == 0:
|
|
545
|
+
raise RuntimeError(
|
|
546
|
+
f"No threshold achieves recall ≥ {self.recall}; "
|
|
547
|
+
f"max recall = {recalls.max():.3f}"
|
|
548
|
+
)
|
|
549
|
+
idx = int(eligible[-1])
|
|
550
|
+
return ThresholdResult(
|
|
551
|
+
threshold=float(thresholds[idx]),
|
|
552
|
+
f1=_f1(float(precisions[idx]), float(recalls[idx])),
|
|
553
|
+
precision=float(precisions[idx]),
|
|
554
|
+
recall=float(recalls[idx]),
|
|
555
|
+
criterion=self.criterion,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@dataclass(frozen=True, slots=True)
|
|
560
|
+
class TargetPrecisionSelector:
|
|
561
|
+
"""Smallest threshold meeting precision ≥ target — highest-recall feasible point.
|
|
562
|
+
|
|
563
|
+
NEW in v0.7.0: required by ``prompt-injection-sdd``'s ``"precision@0.90"``
|
|
564
|
+
workflow, which previously forced a local re-implementation of
|
|
565
|
+
``select_threshold``.
|
|
566
|
+
|
|
567
|
+
Parameters
|
|
568
|
+
----------
|
|
569
|
+
precision : float
|
|
570
|
+
Target precision floor in ``(0, 1]``.
|
|
571
|
+
|
|
572
|
+
Raises
|
|
573
|
+
------
|
|
574
|
+
ValueError
|
|
575
|
+
If ``precision`` is outside ``(0, 1]``.
|
|
576
|
+
RuntimeError
|
|
577
|
+
At ``select`` time, if no threshold achieves precision ≥ target.
|
|
578
|
+
|
|
579
|
+
Notes
|
|
580
|
+
-----
|
|
581
|
+
Precision is *not* monotonic in threshold (it can oscillate as positives
|
|
582
|
+
vanish), so we cannot exit early on the first non-eligible point — the
|
|
583
|
+
full PR curve is scanned.
|
|
584
|
+
"""
|
|
585
|
+
|
|
586
|
+
precision: float
|
|
587
|
+
|
|
588
|
+
def __post_init__(self) -> None:
|
|
589
|
+
"""Validate the precision target."""
|
|
590
|
+
if not 0.0 < self.precision <= 1.0:
|
|
591
|
+
raise ValueError(f"precision must be in (0, 1], got {self.precision}")
|
|
592
|
+
|
|
593
|
+
@property
|
|
594
|
+
def criterion(self) -> str:
|
|
595
|
+
"""Stable label embedding the target precision."""
|
|
596
|
+
return f"precision_{self.precision:.2f}"
|
|
597
|
+
|
|
598
|
+
def select(self, y_true: np.ndarray, y_score: np.ndarray) -> ThresholdResult:
|
|
599
|
+
"""Return the smallest threshold meeting precision ≥ ``self.precision``.
|
|
600
|
+
|
|
601
|
+
Raises
|
|
602
|
+
------
|
|
603
|
+
RuntimeError
|
|
604
|
+
If no threshold achieves the precision target.
|
|
605
|
+
"""
|
|
606
|
+
_validate_inputs(y_true, y_score)
|
|
607
|
+
precisions, recalls, thresholds = _pr_curve_trim(y_true, y_score)
|
|
608
|
+
eligible = np.where(precisions >= self.precision)[0]
|
|
609
|
+
if len(eligible) == 0:
|
|
610
|
+
raise RuntimeError(
|
|
611
|
+
f"No threshold achieves precision ≥ {self.precision}; "
|
|
612
|
+
f"max precision = {precisions.max():.3f}"
|
|
613
|
+
)
|
|
614
|
+
# Smallest-threshold-meeting-target = first eligible index.
|
|
615
|
+
# PR-curve thresholds are sorted ascending.
|
|
616
|
+
idx = int(eligible[0])
|
|
617
|
+
return ThresholdResult(
|
|
618
|
+
threshold=float(thresholds[idx]),
|
|
619
|
+
f1=_f1(float(precisions[idx]), float(recalls[idx])),
|
|
620
|
+
precision=float(precisions[idx]),
|
|
621
|
+
recall=float(recalls[idx]),
|
|
622
|
+
criterion=self.criterion,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
@dataclass(frozen=True, slots=True)
|
|
627
|
+
class TargetFPRSelector:
|
|
628
|
+
"""Smallest threshold meeting FPR ≤ target — highest-TPR feasible ROC point.
|
|
629
|
+
|
|
630
|
+
Useful for "screening" workflows where downstream cost is dominated by
|
|
631
|
+
false positives (e.g., classifier flagging a queue for human review).
|
|
632
|
+
|
|
633
|
+
Parameters
|
|
634
|
+
----------
|
|
635
|
+
fpr : float
|
|
636
|
+
Target false-positive-rate ceiling in ``[0, 1]``.
|
|
637
|
+
|
|
638
|
+
Raises
|
|
639
|
+
------
|
|
640
|
+
ValueError
|
|
641
|
+
If ``fpr`` is outside ``[0, 1]``.
|
|
642
|
+
RuntimeError
|
|
643
|
+
At ``select`` time, if no threshold achieves FPR ≤ target.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
fpr: float
|
|
647
|
+
|
|
648
|
+
def __post_init__(self) -> None:
|
|
649
|
+
"""Validate the FPR ceiling."""
|
|
650
|
+
if not 0.0 <= self.fpr <= 1.0:
|
|
651
|
+
raise ValueError(f"fpr must be in [0, 1], got {self.fpr}")
|
|
652
|
+
|
|
653
|
+
@property
|
|
654
|
+
def criterion(self) -> str:
|
|
655
|
+
"""Stable label embedding the target FPR."""
|
|
656
|
+
return f"fpr_{self.fpr:.2f}"
|
|
657
|
+
|
|
658
|
+
def select(self, y_true: np.ndarray, y_score: np.ndarray) -> ThresholdResult:
|
|
659
|
+
"""Return the smallest threshold (highest TPR) meeting FPR ≤ target.
|
|
660
|
+
|
|
661
|
+
Raises
|
|
662
|
+
------
|
|
663
|
+
RuntimeError
|
|
664
|
+
If no threshold achieves the FPR ceiling.
|
|
665
|
+
"""
|
|
666
|
+
_validate_inputs(y_true, y_score)
|
|
667
|
+
fprs, _tprs, thresholds = _roc_curve_trim(y_true, y_score)
|
|
668
|
+
eligible = np.where(fprs <= self.fpr)[0]
|
|
669
|
+
if len(eligible) == 0:
|
|
670
|
+
raise RuntimeError(
|
|
671
|
+
f"No threshold achieves FPR ≤ {self.fpr}; " f"min fpr = {fprs.min():.3f}"
|
|
672
|
+
)
|
|
673
|
+
# Trimmed ROC: thresholds are sorted descending, FPR is non-decreasing
|
|
674
|
+
# as threshold decreases. Eligible is contiguous from index 0;
|
|
675
|
+
# eligible[-1] is the largest FPR still meeting the ceiling = highest
|
|
676
|
+
# TPR (most-recall) feasible point.
|
|
677
|
+
idx = int(eligible[-1])
|
|
678
|
+
return _result_at(y_true, y_score, float(thresholds[idx]), self.criterion)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@dataclass(frozen=True, slots=True)
|
|
682
|
+
class YoudenJSelector:
|
|
683
|
+
"""Argmax of Youden's J statistic ``J = TPR − FPR`` over the ROC curve.
|
|
684
|
+
|
|
685
|
+
Threshold-free, balanced criterion that does not require choosing a
|
|
686
|
+
target rate. Often the right default when costs are unknown or roughly
|
|
687
|
+
symmetric and the consumer wants a single "best" operating point.
|
|
688
|
+
|
|
689
|
+
References
|
|
690
|
+
----------
|
|
691
|
+
.. [1] Youden, W. J. "Index for rating diagnostic tests." Cancer 3(1),
|
|
692
|
+
1950.
|
|
693
|
+
"""
|
|
694
|
+
|
|
695
|
+
criterion: str = "youden_j"
|
|
696
|
+
|
|
697
|
+
def select(self, y_true: np.ndarray, y_score: np.ndarray) -> ThresholdResult:
|
|
698
|
+
"""Return the threshold maximizing Youden's J."""
|
|
699
|
+
_validate_inputs(y_true, y_score)
|
|
700
|
+
fprs, tprs, thresholds = _roc_curve_trim(y_true, y_score)
|
|
701
|
+
j = tprs - fprs
|
|
702
|
+
idx = int(np.argmax(j))
|
|
703
|
+
return _result_at(y_true, y_score, float(thresholds[idx]), self.criterion)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@dataclass(frozen=True, slots=True)
|
|
707
|
+
class CostSensitiveSelector:
|
|
708
|
+
"""Bayes-optimal threshold from a :class:`~eval_toolkit.calibration.CostMatrix`.
|
|
709
|
+
|
|
710
|
+
Closed-form Elkan 2001: ``t* = c_fp · (1 − π) / (c_fp · (1 − π) + c_fn · π)``.
|
|
711
|
+
Assumes ``y_score`` is a calibrated probability in ``[0, 1]``; for raw
|
|
712
|
+
scores, apply Platt / temperature scaling first
|
|
713
|
+
(:func:`eval_toolkit.calibration.fit_platt_calibrator` etc.).
|
|
714
|
+
|
|
715
|
+
Parameters
|
|
716
|
+
----------
|
|
717
|
+
cost_matrix : CostMatrix
|
|
718
|
+
Carries ``prior``, ``fp_cost``, ``fn_cost``. Threshold derives from
|
|
719
|
+
:attr:`CostMatrix.bayes_threshold`.
|
|
720
|
+
|
|
721
|
+
Examples
|
|
722
|
+
--------
|
|
723
|
+
>>> import numpy as np
|
|
724
|
+
>>> from eval_toolkit.calibration import CostMatrix
|
|
725
|
+
>>> from eval_toolkit.thresholds import CostSensitiveSelector
|
|
726
|
+
>>> y = np.array([0, 0, 1, 1, 0, 1])
|
|
727
|
+
>>> s = np.array([0.1, 0.2, 0.7, 0.9, 0.3, 0.8])
|
|
728
|
+
>>> cm = CostMatrix(prior=0.5, fp_cost=1.0, fn_cost=1.0)
|
|
729
|
+
>>> tr = CostSensitiveSelector(cm).select(y, s)
|
|
730
|
+
>>> tr.threshold
|
|
731
|
+
0.5
|
|
732
|
+
"""
|
|
733
|
+
|
|
734
|
+
cost_matrix: CostMatrix
|
|
735
|
+
|
|
736
|
+
@property
|
|
737
|
+
def criterion(self) -> str:
|
|
738
|
+
"""Stable label embedding the cost-matrix triple."""
|
|
739
|
+
cm = self.cost_matrix
|
|
740
|
+
return f"cost_sensitive_prior={cm.prior:.3f}" f"_fp={cm.fp_cost:.2f}_fn={cm.fn_cost:.2f}"
|
|
741
|
+
|
|
742
|
+
def select(self, y_true: np.ndarray, y_score: np.ndarray) -> ThresholdResult:
|
|
743
|
+
"""Return the Bayes-optimal threshold for this cost matrix."""
|
|
744
|
+
_validate_inputs(y_true, y_score)
|
|
745
|
+
threshold = self.cost_matrix.bayes_threshold
|
|
746
|
+
return _result_at(y_true, y_score, threshold, self.criterion)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def select_threshold(
|
|
750
|
+
y_true: np.ndarray,
|
|
751
|
+
y_score: np.ndarray,
|
|
752
|
+
criterion: ThresholdSelector,
|
|
753
|
+
) -> ThresholdResult:
|
|
754
|
+
"""Dispatch to a :class:`ThresholdSelector` instance.
|
|
755
|
+
|
|
756
|
+
Convenience wrapper that's equivalent to ``criterion.select(y_true, y_score)``;
|
|
757
|
+
its only job is to enforce the v0.7.0 contract and produce a helpful
|
|
758
|
+
migration error if a v0.6 string is passed.
|
|
759
|
+
|
|
760
|
+
Parameters
|
|
761
|
+
----------
|
|
762
|
+
y_true : np.ndarray
|
|
763
|
+
Binary labels in ``{0, 1}``.
|
|
764
|
+
y_score : np.ndarray
|
|
765
|
+
Real-valued scores.
|
|
766
|
+
criterion : ThresholdSelector
|
|
767
|
+
Any object implementing ``select(y_true, y_score) -> ThresholdResult``.
|
|
768
|
+
|
|
769
|
+
Returns
|
|
770
|
+
-------
|
|
771
|
+
ThresholdResult
|
|
772
|
+
|
|
773
|
+
Raises
|
|
774
|
+
------
|
|
775
|
+
TypeError
|
|
776
|
+
If ``criterion`` is not a :class:`ThresholdSelector` (e.g., a v0.6
|
|
777
|
+
string is passed). The error message includes the migration mapping.
|
|
778
|
+
|
|
779
|
+
Examples
|
|
780
|
+
--------
|
|
781
|
+
>>> import numpy as np
|
|
782
|
+
>>> from eval_toolkit.thresholds import MaxF1Selector, select_threshold
|
|
783
|
+
>>> y = np.array([0, 0, 1, 1, 0, 1])
|
|
784
|
+
>>> s = np.array([0.1, 0.2, 0.7, 0.9, 0.3, 0.8])
|
|
785
|
+
>>> tr = select_threshold(y, s, criterion=MaxF1Selector())
|
|
786
|
+
>>> tr.f1
|
|
787
|
+
1.0
|
|
788
|
+
|
|
789
|
+
.. versionchanged:: 0.7.0
|
|
790
|
+
BREAKING — ``criterion`` now requires a :class:`ThresholdSelector`
|
|
791
|
+
instance. The v0.6 string form (``"max_f1"`` / ``"recall_0.90"`` /
|
|
792
|
+
``"recall_0.95"``) is removed. Migration:
|
|
793
|
+
|
|
794
|
+
==================== ===========================================
|
|
795
|
+
v0.6 v0.7
|
|
796
|
+
==================== ===========================================
|
|
797
|
+
``"max_f1"`` :class:`MaxF1Selector()`
|
|
798
|
+
``"recall_0.90"`` :class:`TargetRecallSelector(0.90)`
|
|
799
|
+
``"recall_0.95"`` :class:`TargetRecallSelector(0.95)`
|
|
800
|
+
``"precision@p"`` :class:`TargetPrecisionSelector(p)` *(new)*
|
|
801
|
+
``"recall@p"`` :class:`TargetRecallSelector(p)`
|
|
802
|
+
==================== ===========================================
|
|
803
|
+
|
|
804
|
+
Passing a string raises :class:`TypeError` whose message includes
|
|
805
|
+
this mapping.
|
|
806
|
+
"""
|
|
807
|
+
if not isinstance(criterion, ThresholdSelector):
|
|
808
|
+
raise TypeError(
|
|
809
|
+
"select_threshold requires a ThresholdSelector instance "
|
|
810
|
+
f"(v0.7.0+); got {type(criterion).__name__}={criterion!r}.\n"
|
|
811
|
+
"Migration:\n"
|
|
812
|
+
" 'max_f1' -> MaxF1Selector()\n"
|
|
813
|
+
" 'recall_0.90' -> TargetRecallSelector(0.90)\n"
|
|
814
|
+
" 'recall_0.95' -> TargetRecallSelector(0.95)\n"
|
|
815
|
+
" 'precision@p' -> TargetPrecisionSelector(p)\n"
|
|
816
|
+
" 'recall@p' -> TargetRecallSelector(p)\n"
|
|
817
|
+
"See CHANGELOG v0.7.0 for the full guide."
|
|
818
|
+
)
|
|
819
|
+
return criterion.select(y_true, y_score)
|