threshold-opt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Threshold optimization for binary and multiclass classification."""
threshold_opt/base.py ADDED
@@ -0,0 +1,84 @@
1
+ """Base classes for decision strategies."""
2
+
3
+ import abc
4
+ import dataclasses
5
+ from typing import Self
6
+
7
+ import numpy as np
8
+ import numpy.typing as npt
9
+
10
+ from threshold_opt import metrics
11
+
12
+ # Seed shared by every K-fold loop in the library and by the experiments
13
+ # pipeline's precompute (see ``experiments.common.pipeline``). Both call
14
+ # sites must agree on this value; otherwise the precompute cache desyncs
15
+ # from the strategy's own lazy refit and the experiment is broken in a
16
+ # hard-to-diagnose way.
17
+ CV_RANDOM_STATE = 42
18
+
19
+
20
+ @dataclasses.dataclass(frozen=True)
21
+ class _CVFoldPredictions:
22
+ """Labels and predicted probabilities for a single CV validation fold."""
23
+
24
+ y: np.ndarray
25
+ probs: np.ndarray
26
+
27
+
28
+ def _coerce_integer_labels(y: npt.ArrayLike) -> np.ndarray:
29
+ """Cast *y* to int when it's already integer-valued (even if float dtype).
30
+
31
+ OpenML can return class labels encoded as float64 (values like 0.0, 1.0,
32
+ 2.0). Downstream multiclass strategies use labels as array indices, so
33
+ we canonicalise here. Non-integer-valued floats (e.g. 0.5, regression
34
+ targets) pass through untouched; the strategy-level validator rejects
35
+ them before this runs.
36
+ """
37
+ arr = np.asarray(y)
38
+ if np.issubdtype(arr.dtype, np.integer):
39
+ return arr
40
+ return arr.astype(int)
41
+
42
+
43
+ class DecisionStrategy(abc.ABC):
44
+ """Abstract base class for decision strategies."""
45
+
46
+ def __init__(self, model):
47
+ self.model = model
48
+
49
+ @property
50
+ def needs_validation_set(self) -> bool:
51
+ """Whether the strategy requires a validation set for calibration."""
52
+ return True
53
+
54
+ def fit(
55
+ self,
56
+ X: npt.ArrayLike,
57
+ y: npt.ArrayLike,
58
+ optimization_objective: metrics.OptimizationObjective,
59
+ ) -> Self:
60
+ """Fit the strategy using validation data."""
61
+ self._validate_fit_input(X, y, optimization_objective)
62
+ return self._fit(X, _coerce_integer_labels(y), optimization_objective)
63
+
64
+ def _validate_fit_input(
65
+ self,
66
+ X: npt.ArrayLike,
67
+ y: npt.ArrayLike,
68
+ optimization_objective: metrics.OptimizationObjective,
69
+ ) -> None:
70
+ """Validates input data before fitting."""
71
+ pass
72
+
73
+ @abc.abstractmethod
74
+ def _fit(
75
+ self,
76
+ X: npt.ArrayLike,
77
+ y: npt.ArrayLike,
78
+ optimization_objective: metrics.OptimizationObjective,
79
+ ) -> Self:
80
+ """Implementation of the fit logic."""
81
+
82
+ @abc.abstractmethod
83
+ def predict(self, X: npt.ArrayLike) -> npt.ArrayLike:
84
+ """Predict class labels for X using the fitted strategy."""
@@ -0,0 +1,524 @@
1
+ """Decision Strategies implementing the inference-to-decision mapping."""
2
+
3
+ import warnings
4
+ from typing import Optional, Self
5
+
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+ from sklearn import calibration as sklearn_calibration
9
+ from sklearn import model_selection
10
+ from sklearn.base import clone
11
+ from sklearn.frozen import FrozenEstimator
12
+
13
+ from threshold_opt import base, calibration, cv, metrics, optimization
14
+
15
+
16
+ def _positive_class_probs(probs: np.ndarray) -> np.ndarray:
17
+ """Extract positive-class probabilities from a 1D or 2D ``predict_proba``
18
+ output. Shared-fold caches (see pipeline._precompute_cv_fold_results)
19
+ always store 2D probs; legacy callers may pass the already-extracted
20
+ 1D vector.
21
+ """
22
+ if probs.ndim == 1:
23
+ return probs
24
+ if probs.shape[1] >= 2:
25
+ return probs[:, 1]
26
+ # Degenerate single-class fold; probs[:, 0] is the only column. The
27
+ # binary strategies treat that as "no positive signal" and fall back
28
+ # to zero probabilities downstream.
29
+ warnings.warn(
30
+ "_positive_class_probs received a single-column predict_proba"
31
+ " output and is returning zeros; the fold lost the positive class"
32
+ " entirely and downstream threshold selection will be biased toward"
33
+ " predicting only negatives.",
34
+ RuntimeWarning,
35
+ stacklevel=2,
36
+ )
37
+ return np.zeros(probs.shape[0])
38
+
39
+
40
+ def _score_all_thresholds(
41
+ y_true: np.ndarray,
42
+ probs: np.ndarray,
43
+ grid: np.ndarray,
44
+ optimization_objective: metrics.OptimizationObjective,
45
+ ) -> np.ndarray:
46
+ """Score all thresholds at once using vectorized numpy operations.
47
+
48
+ Returns an array of shape ``(len(grid),)`` where higher is better.
49
+ Falls back to a per-threshold loop for :class:`MetricObjective` instances
50
+ that do not provide a ``vectorized_key``.
51
+ """
52
+ tp, fp, fn, tn = metrics.binary_confusion_counts_vectorized(
53
+ y_true, probs, grid
54
+ )
55
+
56
+ if isinstance(optimization_objective, metrics.MetricObjective):
57
+ obj = optimization_objective
58
+ if obj.vectorized_key is not None:
59
+ scores = metrics.vectorized_scores_from_counts(
60
+ tp, fp, fn, tn, obj.vectorized_key, beta=obj.beta
61
+ )
62
+ if not obj.higher_is_better:
63
+ scores = -scores
64
+ return scores
65
+ return np.array(
66
+ [
67
+ metrics.get_objective_score(
68
+ y_true, (probs >= t).astype(int), optimization_objective
69
+ )
70
+ for t in grid
71
+ ]
72
+ )
73
+
74
+ cost_matrix = np.asarray(optimization_objective)
75
+ return metrics.vectorized_cost_scores(tp, fp, fn, tn, cost_matrix)
76
+
77
+
78
+ class BinaryDecisionStrategy(base.DecisionStrategy):
79
+ """Abstract base class for threshold determination strategies."""
80
+
81
+ def __init__(self, model):
82
+ super().__init__(model)
83
+ self._threshold: float | None = None
84
+
85
+ def _validate_fit_input(
86
+ self,
87
+ X: npt.ArrayLike,
88
+ y: npt.ArrayLike,
89
+ optimization_objective: metrics.OptimizationObjective,
90
+ ) -> None:
91
+ y = np.asarray(y)
92
+ unique_labels = np.unique(y)
93
+ if not np.all(np.isin(unique_labels, [0, 1])):
94
+ raise ValueError(
95
+ "Binary strategies require labels to be 0 or 1."
96
+ f" Got {unique_labels}."
97
+ )
98
+
99
+ def _get_positive_proba(self, model, X: npt.ArrayLike) -> npt.ArrayLike:
100
+ """Safely extracts probabilities for the positive class (1)."""
101
+ probs = model.predict_proba(X)
102
+ if probs.shape[1] >= 2:
103
+ return probs[:, 1]
104
+
105
+ if hasattr(model, "classes_"):
106
+ if len(model.classes_) == 1 and model.classes_[0] == 1:
107
+ return probs[:, 0]
108
+ # Degenerate fit: model.classes_ does not include the positive
109
+ # class, so the single column we have represents the negative
110
+ # class. Returning zeros reports "no positive signal", which
111
+ # collapses downstream threshold selection toward predicting only
112
+ # negatives — surface that explicitly so callers can audit.
113
+ warnings.warn(
114
+ "_get_positive_proba: model has no positive-class column; "
115
+ "returning zeros. Threshold selection will be biased toward "
116
+ "predicting only negatives.",
117
+ RuntimeWarning,
118
+ stacklevel=2,
119
+ )
120
+ return np.zeros(probs.shape[0])
121
+
122
+ def predict(self, X: npt.ArrayLike) -> npt.ArrayLike:
123
+ """Predict class labels for X using the determined threshold."""
124
+ probs = self._get_positive_proba(self.model, X)
125
+ return (probs >= self.threshold).astype(int)
126
+
127
+ @property
128
+ def threshold(self) -> float:
129
+ if self._threshold is None:
130
+ raise ValueError(
131
+ "Threshold not determined. Please call `fit` first."
132
+ )
133
+ return self._threshold
134
+
135
+
136
+ class ArgmaxThreshold(BinaryDecisionStrategy):
137
+ """Argmax threshold derivation."""
138
+
139
+ @property
140
+ def needs_validation_set(self) -> bool:
141
+ return False
142
+
143
+ def _fit(
144
+ self,
145
+ X: npt.ArrayLike,
146
+ y: npt.ArrayLike,
147
+ optimization_objective: metrics.OptimizationObjective,
148
+ ) -> Self:
149
+ self._threshold = 0.5
150
+ return self
151
+
152
+
153
+ class _CalibratedArgmaxThreshold(BinaryDecisionStrategy):
154
+ """Fits sklearn calibration then thresholds at 0.5.
155
+
156
+ Subclasses set ``_calibration_method`` to choose the sklearn backend.
157
+ """
158
+
159
+ _calibration_method: str = ""
160
+
161
+ @property
162
+ def needs_validation_set(self) -> bool:
163
+ return True
164
+
165
+ def __init__(self, model):
166
+ super().__init__(model)
167
+ self.calibrated_model: Optional[
168
+ sklearn_calibration.CalibratedClassifierCV
169
+ ] = None
170
+
171
+ def _fit(
172
+ self,
173
+ X: npt.ArrayLike,
174
+ y: npt.ArrayLike,
175
+ optimization_objective: metrics.OptimizationObjective,
176
+ ) -> Self:
177
+ if self.calibrated_model is None:
178
+ self.calibrated_model = sklearn_calibration.CalibratedClassifierCV(
179
+ estimator=FrozenEstimator(self.model),
180
+ method=self._calibration_method,
181
+ )
182
+ self.calibrated_model.fit(X, y)
183
+ self._threshold = 0.5
184
+ return self
185
+
186
+ def predict(self, X: npt.ArrayLike) -> npt.ArrayLike:
187
+ if self.calibrated_model is None:
188
+ raise ValueError("Strategy not fit.")
189
+
190
+ probs_calibrated = self._get_positive_proba(self.calibrated_model, X)
191
+ return (probs_calibrated >= self.threshold).astype(int)
192
+
193
+
194
+ class _CalibratedAnalyticalThreshold(BinaryDecisionStrategy):
195
+ """Fits sklearn calibration then uses the analytical optimal threshold."""
196
+
197
+ _calibration_method: str = ""
198
+
199
+ @property
200
+ def needs_validation_set(self) -> bool:
201
+ return True
202
+
203
+ def __init__(self, model):
204
+ super().__init__(model)
205
+ self.calibrated_model: Optional[
206
+ sklearn_calibration.CalibratedClassifierCV
207
+ ] = None
208
+
209
+ def _fit(
210
+ self,
211
+ X: npt.ArrayLike,
212
+ y: npt.ArrayLike,
213
+ optimization_objective: metrics.OptimizationObjective,
214
+ ) -> Self:
215
+ if isinstance(optimization_objective, metrics.MetricObjective):
216
+ raise ValueError(
217
+ f"{type(self).__name__} requires a cost matrix for"
218
+ " optimization, got MetricObjective."
219
+ )
220
+ if self.calibrated_model is None:
221
+ self.calibrated_model = sklearn_calibration.CalibratedClassifierCV(
222
+ estimator=FrozenEstimator(self.model),
223
+ method=self._calibration_method,
224
+ )
225
+ self.calibrated_model.fit(X, y)
226
+ self._threshold = metrics.calculate_optimal_threshold(
227
+ optimization_objective
228
+ )
229
+ return self
230
+
231
+ def predict(self, X: npt.ArrayLike) -> npt.ArrayLike:
232
+ if self.calibrated_model is None:
233
+ raise ValueError("Strategy not fit.")
234
+
235
+ probs_calibrated = self._get_positive_proba(self.calibrated_model, X)
236
+ return (probs_calibrated >= self.threshold).astype(int)
237
+
238
+
239
+ class IsotonicArgmax(_CalibratedArgmaxThreshold):
240
+ """Isotonic Regression + argmax (threshold 0.5)."""
241
+
242
+ _calibration_method = "isotonic"
243
+
244
+
245
+ class PlattScalingArgmax(_CalibratedArgmaxThreshold):
246
+ """Platt Scaling + argmax (threshold 0.5).
247
+
248
+ Reference: Platt, J. (1999).
249
+ """
250
+
251
+ _calibration_method = "sigmoid"
252
+
253
+
254
+ class TemperatureScalingArgmax(_CalibratedArgmaxThreshold):
255
+ """Temperature Scaling + argmax (threshold 0.5).
256
+
257
+ Reference: Guo, C. et al. (2017). "On Calibration of Modern Neural
258
+ Networks." ICML 2017.
259
+ """
260
+
261
+ _calibration_method = "temperature"
262
+
263
+
264
+ class IsotonicAnalytical(_CalibratedAnalyticalThreshold):
265
+ """Isotonic Regression + analytical optimal threshold."""
266
+
267
+ _calibration_method = "isotonic"
268
+
269
+
270
+ class PlattScalingAnalytical(_CalibratedAnalyticalThreshold):
271
+ """Platt Scaling + analytical optimal threshold.
272
+
273
+ Reference: Platt, J. (1999).
274
+ """
275
+
276
+ _calibration_method = "sigmoid"
277
+
278
+
279
+ class TemperatureScalingAnalytical(_CalibratedAnalyticalThreshold):
280
+ """Temperature Scaling + analytical optimal threshold.
281
+
282
+ Reference: Guo, C. et al. (2017). "On Calibration of Modern Neural
283
+ Networks." ICML 2017.
284
+ """
285
+
286
+ _calibration_method = "temperature"
287
+
288
+
289
+ class AnalyticalThreshold(BinaryDecisionStrategy):
290
+ """Analytical threshold derivation."""
291
+
292
+ @property
293
+ def needs_validation_set(self) -> bool:
294
+ return False
295
+
296
+ def _fit(
297
+ self,
298
+ X: npt.ArrayLike,
299
+ y: npt.ArrayLike,
300
+ optimization_objective: metrics.OptimizationObjective,
301
+ ) -> Self:
302
+ if isinstance(optimization_objective, metrics.MetricObjective):
303
+ raise ValueError(
304
+ "AnalyticalThreshold requires a cost matrix for optimization,"
305
+ " got metrics.MetricObjective."
306
+ )
307
+ self._threshold = metrics.calculate_optimal_threshold(
308
+ optimization_objective
309
+ )
310
+ return self
311
+
312
+
313
+ class EmpiricalThreshold(BinaryDecisionStrategy):
314
+ """Post-hoc optimization via Optimizer on Validation set."""
315
+
316
+ def __init__(
317
+ self,
318
+ model,
319
+ optimizer: Optional[optimization.ParameterOptimizer] = None,
320
+ use_cv: bool = False,
321
+ cv_folds: int = 5,
322
+ ):
323
+ super().__init__(model)
324
+ self.optimizer = optimizer or optimization.GridSearchOptimizer(
325
+ bounds=(0.00, 1.00), steps=301
326
+ )
327
+ self.use_cv = use_cv
328
+ self.cv_folds = cv_folds
329
+ self._fold_results: Optional[list[base._CVFoldPredictions]] = None
330
+
331
+ @property
332
+ def self_tunes_with_cv(self) -> bool:
333
+ return self.use_cv
334
+
335
+ @property
336
+ def needs_validation_set(self) -> bool:
337
+ return not self.use_cv
338
+
339
+ def _fit(
340
+ self,
341
+ X: npt.ArrayLike,
342
+ y: npt.ArrayLike,
343
+ optimization_objective: metrics.OptimizationObjective,
344
+ ) -> Self:
345
+ if self.use_cv:
346
+ if not isinstance(
347
+ self.optimizer, optimization.GridSearchOptimizer
348
+ ):
349
+ raise ValueError(
350
+ "CV tuning only supported with GridSearchOptimizer for"
351
+ " EmpiricalThreshold."
352
+ )
353
+
354
+ if self._fold_results is None:
355
+ kf = model_selection.StratifiedKFold(
356
+ n_splits=cv.safe_n_splits(y, self.cv_folds),
357
+ shuffle=True,
358
+ random_state=base.CV_RANDOM_STATE,
359
+ )
360
+
361
+ self._fold_results = []
362
+ for train_idx, val_idx in kf.split(X, y):
363
+ X_tr, y_tr = X[train_idx], y[train_idx]
364
+ X_v, y_v = X[val_idx], y[val_idx]
365
+ model = clone(self.model)
366
+ model.fit(X_tr, y_tr)
367
+ self._fold_results.append(
368
+ base._CVFoldPredictions(
369
+ y=y_v, probs=model.predict_proba(X_v)
370
+ )
371
+ )
372
+
373
+ grid = np.linspace(
374
+ self.optimizer.bounds[0],
375
+ self.optimizer.bounds[1],
376
+ self.optimizer.steps,
377
+ )
378
+
379
+ scores_per_t = np.zeros(len(grid))
380
+ for fold in self._fold_results:
381
+ scores_per_t += _score_all_thresholds(
382
+ fold.y,
383
+ _positive_class_probs(fold.probs),
384
+ grid,
385
+ optimization_objective,
386
+ )
387
+ scores_per_t /= len(self._fold_results)
388
+
389
+ self._threshold = grid[np.argmax(scores_per_t)]
390
+ return self
391
+ else:
392
+ probs = self._get_positive_proba(self.model, X)
393
+
394
+ if isinstance(self.optimizer, optimization.GridSearchOptimizer):
395
+ grid = np.linspace(
396
+ self.optimizer.bounds[0],
397
+ self.optimizer.bounds[1],
398
+ self.optimizer.steps,
399
+ )
400
+ scores = _score_all_thresholds(
401
+ y, probs, grid, optimization_objective
402
+ )
403
+ self._threshold = grid[np.argmax(scores)]
404
+ else:
405
+
406
+ def objective_fn(params):
407
+ threshold = params[0]
408
+ y_pred = (probs >= threshold).astype(int)
409
+ return metrics.get_objective_score(
410
+ y, y_pred, optimization_objective
411
+ )
412
+
413
+ initial_params = np.array([0.5])
414
+ initial_score = objective_fn(initial_params)
415
+
416
+ best_params, _ = self.optimizer.optimize(
417
+ objective_fn,
418
+ n_params=1,
419
+ current_best_params=initial_params,
420
+ current_best_score=initial_score,
421
+ )
422
+
423
+ self._threshold = best_params[0]
424
+ return self
425
+
426
+
427
+ class DirichletCalibrationArgmax(BinaryDecisionStrategy):
428
+ """Applies Dirichlet Calibration then uses argmax (threshold 0.5).
429
+
430
+ Reference: Kull, M. et al. (2019). "Beyond temperature scaling:
431
+ Obtaining well-calibrated multiclass probabilities with Dirichlet
432
+ calibration." NeurIPS 2019.
433
+ """
434
+
435
+ @property
436
+ def needs_validation_set(self) -> bool:
437
+ return True
438
+
439
+ def __init__(
440
+ self,
441
+ model,
442
+ reg_lambda: float = 0.0,
443
+ reg_mu: float | None = None,
444
+ ):
445
+ super().__init__(model)
446
+ self.calibrator: Optional[calibration.DirichletCalibrator] = None
447
+ self.reg_lambda = reg_lambda
448
+ self.reg_mu = reg_mu
449
+
450
+ def _fit(
451
+ self,
452
+ X: npt.ArrayLike,
453
+ y: npt.ArrayLike,
454
+ optimization_objective: metrics.OptimizationObjective,
455
+ ) -> Self:
456
+ if self.calibrator is None:
457
+ probs = self.model.predict_proba(X)
458
+ self.calibrator = calibration.DirichletCalibrator(
459
+ reg_lambda=self.reg_lambda, reg_mu=self.reg_mu
460
+ )
461
+ self.calibrator.fit(probs, np.asarray(y))
462
+ self._threshold = 0.5
463
+ return self
464
+
465
+ def predict(self, X: npt.ArrayLike) -> npt.ArrayLike:
466
+ if self.calibrator is None:
467
+ raise ValueError("Strategy not fit.")
468
+ probs = self.model.predict_proba(X)
469
+ calibrated_probs = self.calibrator.predict_proba(probs)
470
+ return (calibrated_probs[:, 1] >= self.threshold).astype(int)
471
+
472
+
473
+ class DirichletCalibrationAnalytical(BinaryDecisionStrategy):
474
+ """Applies Dirichlet Calibration then uses Analytical Threshold.
475
+
476
+ Reference: Kull, M. et al. (2019). "Beyond temperature scaling:
477
+ Obtaining well-calibrated multiclass probabilities with Dirichlet
478
+ calibration." NeurIPS 2019.
479
+ """
480
+
481
+ @property
482
+ def needs_validation_set(self) -> bool:
483
+ return True
484
+
485
+ def __init__(
486
+ self,
487
+ model,
488
+ reg_lambda: float = 0.0,
489
+ reg_mu: float | None = None,
490
+ ):
491
+ super().__init__(model)
492
+ self.calibrator: Optional[calibration.DirichletCalibrator] = None
493
+ self.reg_lambda = reg_lambda
494
+ self.reg_mu = reg_mu
495
+
496
+ def _fit(
497
+ self,
498
+ X: npt.ArrayLike,
499
+ y: npt.ArrayLike,
500
+ optimization_objective: metrics.OptimizationObjective,
501
+ ) -> Self:
502
+ if isinstance(optimization_objective, metrics.MetricObjective):
503
+ raise ValueError(
504
+ "DirichletCalibrationAnalytical requires"
505
+ " a cost matrix for optimization,"
506
+ " got MetricObjective."
507
+ )
508
+ if self.calibrator is None:
509
+ probs = self.model.predict_proba(X)
510
+ self.calibrator = calibration.DirichletCalibrator(
511
+ reg_lambda=self.reg_lambda, reg_mu=self.reg_mu
512
+ )
513
+ self.calibrator.fit(probs, np.asarray(y))
514
+ self._threshold = metrics.calculate_optimal_threshold(
515
+ optimization_objective
516
+ )
517
+ return self
518
+
519
+ def predict(self, X: npt.ArrayLike) -> npt.ArrayLike:
520
+ if self.calibrator is None:
521
+ raise ValueError("Strategy not fit.")
522
+ probs = self.model.predict_proba(X)
523
+ calibrated_probs = self.calibrator.predict_proba(probs)
524
+ return (calibrated_probs[:, 1] >= self.threshold).astype(int)