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,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)
|
threshold_opt/metrics.py
ADDED
|
@@ -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)
|