eval-toolkit 0.27.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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)