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,146 @@
1
+ """Post-hoc calibration methods.
2
+
3
+ Implements Dirichlet calibration (Kull et al., NeurIPS 2019) as a
4
+ self-contained module, avoiding an external dependency on `dirichletcal`.
5
+ """
6
+
7
+ import numpy as np
8
+ import numpy.typing as npt
9
+ from scipy.optimize import minimize
10
+ from scipy.special import softmax
11
+
12
+
13
+ class DirichletCalibrator:
14
+ """Dirichlet calibration (Kull et al., NeurIPS 2019).
15
+
16
+ Learns a linear mapping in log-probability space:
17
+
18
+ calibrated = softmax(W @ log(p) + b)
19
+
20
+ where W is (K x K) and b is (K,), parameterised as a single
21
+ (K x K+1) weight matrix. Trained by minimising NLL on a held-out
22
+ calibration set.
23
+
24
+ Parameters
25
+ ----------
26
+ reg_lambda : float
27
+ Weight-matrix L2 regularisation. With ``reg_mu is None`` (the
28
+ DirL2 baseline in Kull et al., 2019) it penalises every entry of
29
+ ``W`` with no normalisation. Under DirODIR (``reg_mu`` set) it
30
+ penalises only the off-diagonal entries of ``W`` with the
31
+ ``1/(k(k-1))`` factor from the paper.
32
+ reg_mu : float or None
33
+ Selects the DirODIR (Off-Diagonal and Intercept Regularised)
34
+ variant of Kull et al. (2019). When not None, ``reg_lambda``
35
+ switches to off-diagonal-only and ``reg_mu`` applies an L2
36
+ penalty ``mu * (1/k) * sum(b**2)`` to the intercept; the
37
+ diagonal of ``W`` is left free. When None, the plain DirL2
38
+ baseline is used and the intercept is unregularised.
39
+ """
40
+
41
+ def __init__(self, reg_lambda: float = 0.0, reg_mu: float | None = None):
42
+ self.reg_lambda = reg_lambda
43
+ self.reg_mu = reg_mu
44
+ self.weights_: npt.NDArray[np.float64] | None = None
45
+
46
+ def fit(
47
+ self,
48
+ probs: npt.NDArray[np.float64],
49
+ y: npt.NDArray[np.int_],
50
+ ) -> "DirichletCalibrator":
51
+ """Fit the calibrator on held-out probabilities and labels."""
52
+ n_samples, n_classes = probs.shape
53
+ y = np.asarray(y)
54
+
55
+ # Initialise as identity mapping (no calibration).
56
+ W0 = np.zeros((n_classes, n_classes + 1))
57
+ W0[:, :n_classes] = np.eye(n_classes)
58
+
59
+ log_p = np.log(np.clip(probs, 1e-12, 1.0))
60
+ X = np.hstack([log_p, np.ones((n_samples, 1))])
61
+
62
+ result = minimize(
63
+ _dirichlet_loss_and_grad,
64
+ W0.flatten(),
65
+ args=(X, y, n_classes, self.reg_lambda, self.reg_mu),
66
+ jac=True,
67
+ method="L-BFGS-B",
68
+ options={"maxiter": 1000},
69
+ )
70
+ self.weights_ = result.x.reshape(n_classes, n_classes + 1)
71
+ return self
72
+
73
+ def predict_proba(
74
+ self, probs: npt.NDArray[np.float64]
75
+ ) -> npt.NDArray[np.float64]:
76
+ """Return calibrated probabilities."""
77
+ if self.weights_ is None:
78
+ raise ValueError("Calibrator not fitted.")
79
+ n_samples = probs.shape[0]
80
+ log_p = np.log(np.clip(probs, 1e-12, 1.0))
81
+ X = np.hstack([log_p, np.ones((n_samples, 1))])
82
+ logits = X @ self.weights_.T
83
+ return softmax(logits, axis=1)
84
+
85
+
86
+ def _dirichlet_loss_and_grad(
87
+ w_flat: npt.NDArray[np.float64],
88
+ X: npt.NDArray[np.float64],
89
+ y: npt.NDArray[np.int_],
90
+ n_classes: int,
91
+ reg_lambda: float,
92
+ reg_mu: float | None,
93
+ ) -> tuple[float, npt.NDArray[np.float64]]:
94
+ """Dirichlet-calibration NLL (+ L2/ODIR reg) and its analytical gradient.
95
+
96
+ `X` is the precomputed `[log_p | 1]` design matrix of shape
97
+ (n_samples, n_classes + 1). `W` is reshaped from `w_flat` as
98
+ (n_classes, n_classes + 1).
99
+ """
100
+ n_samples = X.shape[0]
101
+ W = w_flat.reshape(n_classes, n_classes + 1)
102
+ logits = X @ W.T
103
+
104
+ # Softmax and log-softmax in one numerically-stable pass.
105
+ shifted = logits - logits.max(axis=1, keepdims=True)
106
+ exp_shifted = np.exp(shifted)
107
+ sum_exp = exp_shifted.sum(axis=1, keepdims=True)
108
+ log_probs = shifted - np.log(sum_exp)
109
+ probs_soft = exp_shifted / sum_exp
110
+
111
+ row_idx = np.arange(n_samples)
112
+ nll = -np.mean(log_probs[row_idx, y])
113
+
114
+ # d(nll)/d(logits) = (softmax - onehot(y)) / n_samples
115
+ # d(nll)/d(W) = d(logits).T @ X (since logits = X @ W.T)
116
+ dlogits = probs_soft
117
+ dlogits[row_idx, y] -= 1.0
118
+ dlogits /= n_samples
119
+ grad_W = dlogits.T @ X
120
+
121
+ # Regularisation follows Kull et al. (2019). ``reg_mu`` acts as a
122
+ # mode switch rather than an additive penalty:
123
+ # reg_mu is None -> DirL2 baseline from the paper: plain L2 on the
124
+ # full weight matrix (intercept excluded).
125
+ # reg_mu is set -> DirODIR (eq. in §3): reg_lambda penalises the
126
+ # off-diagonal entries of W with a 1/(k(k-1))
127
+ # factor, reg_mu penalises the intercept b with
128
+ # a 1/k factor, and the diagonal of W is free.
129
+ loss = nll
130
+ if reg_mu is None:
131
+ if reg_lambda > 0:
132
+ loss += reg_lambda * np.sum(W[:, :n_classes] ** 2)
133
+ grad_W[:, :n_classes] += 2.0 * reg_lambda * W[:, :n_classes]
134
+ else:
135
+ if reg_lambda > 0 and n_classes > 1:
136
+ off_diag = W[:, :n_classes].copy()
137
+ np.fill_diagonal(off_diag, 0.0)
138
+ norm_off = 1.0 / (n_classes * (n_classes - 1))
139
+ loss += reg_lambda * norm_off * np.sum(off_diag**2)
140
+ grad_W[:, :n_classes] += 2.0 * reg_lambda * norm_off * off_diag
141
+ if reg_mu > 0:
142
+ norm_b = 1.0 / n_classes
143
+ loss += reg_mu * norm_b * np.sum(W[:, -1] ** 2)
144
+ grad_W[:, -1] += 2.0 * reg_mu * norm_b * W[:, -1]
145
+
146
+ return float(loss), grad_W.ravel()
threshold_opt/cv.py ADDED
@@ -0,0 +1,51 @@
1
+ """Cross-validation fold-count helpers shared by strategies and the
2
+ experiments pipeline.
3
+
4
+ Kept out of :mod:`threshold_opt.base` (which holds base-class content)
5
+ because this is a stand-alone utility: the CV-tuning strategies
6
+ (``EmpiricalThreshold``, ``ThresholdRatioMulticlass``, the calibration
7
+ strategies) and the pipeline's fold precompute all need the same
8
+ "largest StratifiedKFold this label vector can honour" decision, and
9
+ all need to react to "CV is impossible here" identically.
10
+ """
11
+
12
+ import numpy as np
13
+ import numpy.typing as npt
14
+
15
+
16
+ class InsufficientCVData(ValueError):
17
+ """Raised when a class has too few members for the requested K-fold CV.
18
+
19
+ Subclasses ``ValueError`` so existing ``except ValueError`` handlers
20
+ still catch it, while callers that want to treat "CV is impossible on
21
+ this split" distinctly (e.g. the experiments pipeline, which logs it
22
+ as a countable ``CV_SKIP`` rather than a generic warning) can catch
23
+ it by type first.
24
+ """
25
+
26
+
27
+ def safe_n_splits(y: npt.ArrayLike, max_splits: int = 5) -> int:
28
+ """Largest ``StratifiedKFold`` ``n_splits`` honourable on *y*.
29
+
30
+ Returns ``min(max_splits, smallest_class_count)`` (never below 2).
31
+ Raises :class:`InsufficientCVData` when the smallest class has fewer
32
+ than 2 members, because no ``n_splits >= 2`` stratified split exists
33
+ in that case -- the old ``max(2, ...)`` clamp returned 2 here and
34
+ then crashed deep inside sklearn with a misleading ``ValueError`` /
35
+ ``IndexError``.
36
+
37
+ The result is a deterministic function of *y* alone, so the pipeline
38
+ precompute and a strategy's own lazy refit (which key off the same
39
+ fit-set labels) still derive the same K and the shared fold cache
40
+ stays interchangeable with the lazy path.
41
+ """
42
+ counts = np.unique(np.asarray(y), return_counts=True)[1]
43
+ if len(counts) == 0:
44
+ raise InsufficientCVData("Cannot run CV: no samples.")
45
+ min_count = int(counts.min())
46
+ if min_count < 2:
47
+ raise InsufficientCVData(
48
+ f"Cannot run StratifiedKFold: smallest class has "
49
+ f"{min_count} member(s), need >= 2."
50
+ )
51
+ return min(max_splits, min_count)
@@ -0,0 +1,322 @@
1
+ """Core mathematical functions for risk estimation and threshold derivation."""
2
+
3
+ import dataclasses
4
+ from typing import Callable, Union
5
+
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+
9
+ CostMatrix = npt.ArrayLike
10
+ CostMatrixFactory = Callable[[int], CostMatrix]
11
+
12
+
13
+ @dataclasses.dataclass
14
+ class MetricObjective:
15
+ metric_fn: Callable[[npt.ArrayLike, npt.ArrayLike], float]
16
+ higher_is_better: bool
17
+ vectorized_key: str | None = None
18
+ beta: float = 1.0
19
+
20
+
21
+ @dataclasses.dataclass
22
+ class DataDependentCostBuilder:
23
+ """Cost matrix that depends on the training-set class priors.
24
+
25
+ The pipeline materializes the builder once per task by calling
26
+ ``build_fn(y_train)`` and then passes the resulting cost matrix to
27
+ strategies just like any other ``CostMatrix`` ndarray. This lets a
28
+ single objective definition adapt to each dataset's empirical
29
+ class distribution without duplicating cost-matrix entries.
30
+ """
31
+
32
+ build_fn: Callable[[npt.NDArray], np.ndarray]
33
+
34
+
35
+ def balanced_error_cost(y: npt.ArrayLike) -> np.ndarray:
36
+ """Cost matrix whose expected value equals macro balanced error.
37
+
38
+ Macro balanced error rate equals
39
+ ``(1/K) * sum_y (1 - recall_y) = E_{(X,Y)}[cost(Y, A(X))]`` for
40
+ ``cost(y, a) = 1 / (K * pi(y))`` when ``a != y`` and 0 on the
41
+ diagonal, where ``pi(y)`` is the marginal class prior. Strategies
42
+ that minimize this empirical risk on a held-out set therefore
43
+ minimize balanced error directly.
44
+
45
+ Empty classes (no samples in ``y``) get cost 0; this avoids
46
+ division by zero but means strategies cannot be punished for
47
+ failing to predict classes absent at fit time.
48
+ """
49
+ y_arr = np.asarray(y)
50
+ classes, counts = np.unique(y_arr, return_counts=True)
51
+ K = int(classes.max()) + 1 if len(classes) else 0
52
+ priors = np.zeros(K)
53
+ priors[classes.astype(int)] = counts / counts.sum()
54
+ M = np.zeros((K, K), dtype=np.float64)
55
+ for i in range(K):
56
+ if priors[i] <= 0:
57
+ continue
58
+ M[i, :] = 1.0 / (K * priors[i])
59
+ M[i, i] = 0.0
60
+ return M
61
+
62
+
63
+ BALANCED_ERROR_COST = DataDependentCostBuilder(build_fn=balanced_error_cost)
64
+
65
+
66
+ OptimizationObjective = Union[
67
+ CostMatrix, MetricObjective, CostMatrixFactory, DataDependentCostBuilder
68
+ ]
69
+
70
+
71
+ def get_objective_score(
72
+ y_true: npt.ArrayLike,
73
+ y_pred: npt.ArrayLike,
74
+ optimization_objective: "OptimizationObjective",
75
+ ) -> float:
76
+ """Score a prediction under an OptimizationObjective.
77
+
78
+ Higher is always better: metric signs are flipped to minimization
79
+ form, and cost-matrix objectives return negative empirical risk.
80
+ """
81
+ if isinstance(optimization_objective, MetricObjective):
82
+ sign = 1.0 if optimization_objective.higher_is_better else -1.0
83
+ return sign * optimization_objective.metric_fn(y_true, y_pred)
84
+
85
+ cost_matrix = np.asarray(optimization_objective)
86
+ return -calculate_empirical_risk(y_true, y_pred, cost_matrix)
87
+
88
+
89
+ def calculate_optimal_threshold(cost_matrix: CostMatrix) -> float:
90
+ """Derives the theoretical optimal threshold p* for a binary cost matrix.
91
+
92
+ The optimal threshold is derived by minimizing the expected cost.
93
+ The formula is:
94
+ p* = (FP - TN) / ( (FP - TN) + (FN - TP) )
95
+ = (C_01 - C_00) / ( (C_01 - C_00) + (C_10 - C_11) )
96
+
97
+ Args:
98
+ cost_matrix: 2x2 matrix [[TN_cost, FP_cost], [FN_cost, TP_cost]].
99
+ Note: This follows the standard confusion matrix
100
+ layout where
101
+ C_ij is the cost of predicting class j when truth is i.
102
+
103
+ Returns:
104
+ float: The probability threshold in [0, 1].
105
+
106
+ Raises:
107
+ ValueError: If the cost matrix is not 2x2.
108
+ """
109
+ cost_matrix = np.asarray(cost_matrix)
110
+ if cost_matrix.shape != (2, 2):
111
+ raise ValueError(
112
+ f"Binary threshold requires 2x2 matrix, got {cost_matrix.shape}"
113
+ )
114
+
115
+ c_00, c_01 = cost_matrix[0]
116
+ c_10, c_11 = cost_matrix[1]
117
+
118
+ numerator = c_01 - c_00
119
+ denominator = (c_01 - c_00) + (c_10 - c_11)
120
+
121
+ if np.isclose(denominator, 0):
122
+ return 0.5
123
+
124
+ return float(numerator / denominator)
125
+
126
+
127
+ def calculate_empirical_risk(
128
+ y_true: npt.ArrayLike,
129
+ y_pred: npt.ArrayLike,
130
+ cost_matrix: CostMatrix,
131
+ ) -> float:
132
+ """Computes average realized cost (Empirical Risk).
133
+
134
+ Args:
135
+ y_true: The true labels.
136
+ y_pred: The predicted labels.
137
+ cost_matrix: The cost matrix where `cost_matrix[i, j]`
138
+ represents the cost
139
+ of predicting class `j` when the true class is `i`.
140
+
141
+ Returns:
142
+ The average empirical risk.
143
+
144
+ Raises:
145
+ ValueError: If the cost matrix is not square.
146
+ """
147
+ cost_matrix = np.asarray(cost_matrix)
148
+
149
+ if cost_matrix.ndim != 2 or cost_matrix.shape[0] != cost_matrix.shape[1]:
150
+ raise ValueError("Cost matrix must be square.")
151
+
152
+ y_true = _as_integer_labels(y_true, "y_true")
153
+ y_pred = _as_integer_labels(y_pred, "y_pred")
154
+
155
+ n = y_true.shape[0]
156
+ if n == 0:
157
+ return 0.0
158
+
159
+ if y_pred.shape[0] != n:
160
+ raise ValueError(
161
+ f"y_true and y_pred must have the same length, "
162
+ f"got {n} and {y_pred.shape[0]}."
163
+ )
164
+
165
+ n_classes = cost_matrix.shape[0]
166
+ for name, arr in (("y_true", y_true), ("y_pred", y_pred)):
167
+ if arr.min() < 0 or arr.max() >= n_classes:
168
+ raise ValueError(
169
+ f"{name} contains labels outside [0, {n_classes - 1}]."
170
+ )
171
+
172
+ total_cost = cost_matrix[y_true, y_pred].sum()
173
+ return float(total_cost / n)
174
+
175
+
176
+ def _as_integer_labels(
177
+ labels: npt.ArrayLike, name: str
178
+ ) -> npt.NDArray[np.intp]:
179
+ arr = np.asarray(labels)
180
+ if np.issubdtype(arr.dtype, np.integer) or arr.dtype == bool:
181
+ return arr.astype(np.intp, copy=False)
182
+ if np.issubdtype(arr.dtype, np.floating):
183
+ cast = arr.astype(np.intp)
184
+ if not np.array_equal(cast, arr):
185
+ raise ValueError(
186
+ f"{name} must be integer-valued, got non-integer floats."
187
+ )
188
+ return cast
189
+ raise ValueError(f"{name} must be integer labels, got dtype {arr.dtype}.")
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Vectorized scoring for binary threshold grid search
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def binary_confusion_counts_vectorized(
198
+ y_true: npt.NDArray[np.int_],
199
+ probs: npt.NDArray[np.float64],
200
+ thresholds: npt.NDArray[np.float64],
201
+ ) -> tuple[npt.NDArray, npt.NDArray, npt.NDArray, npt.NDArray]:
202
+ """Compute TP, FP, FN, TN for all thresholds simultaneously.
203
+
204
+ Uses numpy broadcasting to avoid per-threshold Python loops.
205
+
206
+ Args:
207
+ y_true: shape (n_samples,), values in {0, 1}.
208
+ probs: shape (n_samples,), predicted probability of class 1.
209
+ thresholds: shape (n_thresholds,).
210
+
211
+ Returns:
212
+ (tp, fp, fn, tn) each of shape (n_thresholds,).
213
+ """
214
+ # pred_positive has shape (n_thresholds, n_samples)
215
+ pred_positive = probs[np.newaxis, :] >= thresholds[:, np.newaxis]
216
+ positives = y_true.astype(bool)
217
+ negatives = ~positives
218
+
219
+ tp = (pred_positive & positives).sum(axis=1)
220
+ fp = (pred_positive & negatives).sum(axis=1)
221
+ fn = (~pred_positive & positives).sum(axis=1)
222
+ tn = (~pred_positive & negatives).sum(axis=1)
223
+ return tp, fp, fn, tn
224
+
225
+
226
+ def vectorized_scores_from_counts(
227
+ tp: npt.NDArray,
228
+ fp: npt.NDArray,
229
+ fn: npt.NDArray,
230
+ tn: npt.NDArray,
231
+ metric_key: str,
232
+ *,
233
+ beta: float = 1.0,
234
+ ) -> npt.NDArray[np.float64]:
235
+ """Compute metric scores for all thresholds from confusion counts.
236
+
237
+ All inputs have shape (n_thresholds,). Returns shape (n_thresholds,)
238
+ holding the metric's natural value (e.g. F1 or balanced accuracy
239
+ higher-is-better, balanced error lower-is-better); the caller is
240
+ responsible for sign conventions via ``MetricObjective.higher_is_better``.
241
+ """
242
+ tp = tp.astype(np.float64)
243
+ fp = fp.astype(np.float64)
244
+ fn = fn.astype(np.float64)
245
+ tn = tn.astype(np.float64)
246
+
247
+ if np.any(np.isnan(tp) | np.isnan(fp) | np.isnan(fn) | np.isnan(tn)):
248
+ raise ValueError(
249
+ "Confusion counts contain NaN values. Check input arrays for NaN."
250
+ )
251
+
252
+ if metric_key == "f1":
253
+ num = 2.0 * tp
254
+ den = 2.0 * tp + fp + fn
255
+ return np.divide(num, den, out=np.zeros_like(num), where=den > 0)
256
+
257
+ if metric_key == "f_beta":
258
+ beta2 = beta**2
259
+ num = (1.0 + beta2) * tp
260
+ den = (1.0 + beta2) * tp + beta2 * fn + fp
261
+ return np.divide(num, den, out=np.zeros_like(num), where=den > 0)
262
+
263
+ if metric_key == "mcc":
264
+ num = tp * tn - fp * fn
265
+ product = (tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)
266
+ den = np.sqrt(np.maximum(product, 0.0))
267
+ return np.divide(num, den, out=np.zeros_like(num), where=den > 0)
268
+
269
+ if metric_key == "balanced_accuracy":
270
+ tpr = np.divide(
271
+ tp, tp + fn, out=np.zeros_like(tp), where=(tp + fn) > 0
272
+ )
273
+ tnr = np.divide(
274
+ tn, tn + fp, out=np.zeros_like(tn), where=(tn + fp) > 0
275
+ )
276
+ return (tpr + tnr) / 2.0
277
+
278
+ if metric_key == "balanced_error":
279
+ # 1 - balanced accuracy = (FPR + FNR) / 2; the natural cost form
280
+ # of BA under prior-weighted misclassification costs (the BER /
281
+ # balanced error rate of Menon et al. 2013). Returned as-is so the
282
+ # caller can negate via ``higher_is_better=False``.
283
+ tpr = np.divide(
284
+ tp, tp + fn, out=np.zeros_like(tp), where=(tp + fn) > 0
285
+ )
286
+ tnr = np.divide(
287
+ tn, tn + fp, out=np.zeros_like(tn), where=(tn + fp) > 0
288
+ )
289
+ return 1.0 - (tpr + tnr) / 2.0
290
+
291
+ if metric_key == "gmean":
292
+ tpr = np.divide(
293
+ tp, tp + fn, out=np.zeros_like(tp), where=(tp + fn) > 0
294
+ )
295
+ tnr = np.divide(
296
+ tn, tn + fp, out=np.zeros_like(tn), where=(tn + fp) > 0
297
+ )
298
+ return np.sqrt(tpr * tnr)
299
+
300
+ raise ValueError(f"Unknown vectorized metric key: {metric_key!r}")
301
+
302
+
303
+ def vectorized_cost_scores(
304
+ tp: npt.NDArray,
305
+ fp: npt.NDArray,
306
+ fn: npt.NDArray,
307
+ tn: npt.NDArray,
308
+ cost_matrix: npt.NDArray,
309
+ ) -> npt.NDArray[np.float64]:
310
+ """Compute negative empirical risk for all thresholds at once.
311
+
312
+ Returns scores where higher is better (negative cost).
313
+ """
314
+ cost_matrix = np.asarray(cost_matrix, dtype=np.float64)
315
+ n = (tp + fp + fn + tn).astype(np.float64)
316
+ total_cost = (
317
+ tn * cost_matrix[0, 0]
318
+ + fp * cost_matrix[0, 1]
319
+ + fn * cost_matrix[1, 0]
320
+ + tp * cost_matrix[1, 1]
321
+ )
322
+ return -(total_cost / n)