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.
- threshold_opt/__init__.py +1 -0
- threshold_opt/base.py +84 -0
- threshold_opt/binary_strategies.py +524 -0
- threshold_opt/calibration.py +146 -0
- threshold_opt/cv.py +51 -0
- threshold_opt/metrics.py +322 -0
- threshold_opt/multiclass_strategies.py +1119 -0
- threshold_opt/optimization.py +310 -0
- threshold_opt/py.typed +0 -0
- threshold_opt-0.1.0.dist-info/METADATA +133 -0
- threshold_opt-0.1.0.dist-info/RECORD +13 -0
- threshold_opt-0.1.0.dist-info/WHEEL +4 -0
- threshold_opt-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|