optimal-classification-cutoffs 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.
- optimal_classification_cutoffs-0.1.0.dist-info/METADATA +114 -0
- optimal_classification_cutoffs-0.1.0.dist-info/RECORD +10 -0
- optimal_classification_cutoffs-0.1.0.dist-info/WHEEL +5 -0
- optimal_classification_cutoffs-0.1.0.dist-info/licenses/LICENSE +19 -0
- optimal_classification_cutoffs-0.1.0.dist-info/top_level.txt +1 -0
- optimal_cutoffs/__init__.py +23 -0
- optimal_cutoffs/cv.py +107 -0
- optimal_cutoffs/metrics.py +113 -0
- optimal_cutoffs/optimizers.py +126 -0
- optimal_cutoffs/wrapper.py +62 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: optimal-classification-cutoffs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Utilities for computing optimal classification cutoffs for binary and multi-class
|
|
5
|
+
Author-email: Gaurav Sood <contact@gsood.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
10
|
+
in the Software without restriction, including without limitation the rights
|
|
11
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
12
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
13
|
+
furnished to do so, subject to the following conditions:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice shall be included in all
|
|
16
|
+
copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
21
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
22
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
23
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
24
|
+
SOFTWARE.
|
|
25
|
+
|
|
26
|
+
Classifier: Development Status :: 4 - Beta
|
|
27
|
+
Classifier: Intended Audience :: Science/Research
|
|
28
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
34
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
License-File: LICENSE
|
|
37
|
+
Requires-Dist: numpy
|
|
38
|
+
Requires-Dist: scipy
|
|
39
|
+
Requires-Dist: scikit-learn
|
|
40
|
+
Provides-Extra: examples
|
|
41
|
+
Requires-Dist: matplotlib; extra == "examples"
|
|
42
|
+
Requires-Dist: pandas; extra == "examples"
|
|
43
|
+
Dynamic: license-file
|
|
44
|
+
|
|
45
|
+
# Optimal Classification Cut-Offs
|
|
46
|
+
|
|
47
|
+
Probabilistic classifiers output per-class probabilities, and fixed cutoffs such as ``0.5`` rarely maximize metrics like accuracy or the F\ :sub:`1` score.
|
|
48
|
+
This package provides utilities to **select optimal probability cutoffs for each class**, supporting both multi-class and binary classifiers.
|
|
49
|
+
Optimization methods include brute-force search, numerical techniques, and gradient-based approaches.
|
|
50
|
+
Binary thresholding at a single cutoff remains fully supported as a special case.
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from optimal_cutoffs import ThresholdOptimizer
|
|
56
|
+
|
|
57
|
+
# true binary labels and predicted probabilities
|
|
58
|
+
y_true = ...
|
|
59
|
+
y_prob = ...
|
|
60
|
+
|
|
61
|
+
optimizer = ThresholdOptimizer(objective="f1")
|
|
62
|
+
optimizer.fit(y_true, y_prob)
|
|
63
|
+
y_pred = optimizer.predict(y_prob)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
### `get_confusion_matrix(true_labs, pred_prob, threshold)`
|
|
69
|
+
- **Purpose:** Compute confusion-matrix counts for a threshold.
|
|
70
|
+
- **Args:** arrays of true labels and probabilities, plus the decision threshold.
|
|
71
|
+
- **Returns:** `(tp, tn, fp, fn)` counts.
|
|
72
|
+
|
|
73
|
+
### `register_metric(name=None, func=None)`
|
|
74
|
+
- **Purpose:** Add a metric function to the global registry.
|
|
75
|
+
- **Args:** optional metric name and callable; can also be used as a decorator.
|
|
76
|
+
- **Returns:** the registered function or decorator.
|
|
77
|
+
|
|
78
|
+
### `register_metrics(metrics)`
|
|
79
|
+
- **Purpose:** Register multiple metric functions at once.
|
|
80
|
+
- **Args:** dictionary mapping names to callables.
|
|
81
|
+
- **Returns:** `None`.
|
|
82
|
+
|
|
83
|
+
### `get_probability(true_labs, pred_prob, objective='accuracy', verbose=False)`
|
|
84
|
+
- **Purpose:** Brute-force search for the threshold that maximizes accuracy or F\ :sub:`1`.
|
|
85
|
+
- **Args:** true labels, predicted probabilities, metric name, and verbosity flag.
|
|
86
|
+
- **Returns:** optimal threshold.
|
|
87
|
+
|
|
88
|
+
### `get_optimal_threshold(true_labs, pred_prob, metric='f1', method='smart_brute')`
|
|
89
|
+
- **Purpose:** Optimize any registered metric using different strategies
|
|
90
|
+
(brute force, ``minimize``, or ``gradient``).
|
|
91
|
+
- **Args:** true labels, probabilities, metric name, and optimization method.
|
|
92
|
+
- **Returns:** optimal threshold.
|
|
93
|
+
|
|
94
|
+
### `cv_threshold_optimization(true_labs, pred_prob, metric='f1', method='smart_brute', cv=5, random_state=None)`
|
|
95
|
+
- **Purpose:** Estimate thresholds via cross-validation and report per-fold scores.
|
|
96
|
+
- **Returns:** arrays of thresholds and scores.
|
|
97
|
+
|
|
98
|
+
### `nested_cv_threshold_optimization(true_labs, pred_prob, metric='f1', method='smart_brute', inner_cv=5, outer_cv=5, random_state=None)`
|
|
99
|
+
- **Purpose:** Perform nested cross-validation for threshold estimation and
|
|
100
|
+
unbiased performance evaluation.
|
|
101
|
+
- **Returns:** arrays of outer-fold thresholds and scores.
|
|
102
|
+
|
|
103
|
+
### `ThresholdOptimizer(objective='accuracy', verbose=False)`
|
|
104
|
+
- **Purpose:** High-level wrapper with ``fit``/``predict`` methods.
|
|
105
|
+
- **Args:** metric name and verbosity flag.
|
|
106
|
+
- **Returns:** fitted instance with ``threshold_`` attribute after calling ``fit``.
|
|
107
|
+
|
|
108
|
+
## Examples
|
|
109
|
+
|
|
110
|
+
- [Cross-validation and gradient methods](examples/comscore.ipynb)
|
|
111
|
+
|
|
112
|
+
## Authors
|
|
113
|
+
|
|
114
|
+
Suriyan Laohaprapanon and Gaurav Sood
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
optimal_classification_cutoffs-0.1.0.dist-info/licenses/LICENSE,sha256=UIp30ue1HZit7tMmSK0SS3swJBqOcLLnLJn5LY5YdNE,1036
|
|
2
|
+
optimal_cutoffs/__init__.py,sha256=bWxw96ELKUsgmIY6wpmz90xzZN1lT_bEUaMw0hztsGY,628
|
|
3
|
+
optimal_cutoffs/cv.py,sha256=qTKzENz4sXDybwZlF1rMHqnpa5PO_d4KvQhD2jy54vA,3130
|
|
4
|
+
optimal_cutoffs/metrics.py,sha256=DEPz4y55paCoidz-NkybA0nNY-lwai37JM-93-XYOAA,3103
|
|
5
|
+
optimal_cutoffs/optimizers.py,sha256=jEia55zi5xtIcYUCaqfDPKQ4Q-Do7bTr-5YcN5eREok,4545
|
|
6
|
+
optimal_cutoffs/wrapper.py,sha256=QQveckCCAKcARIbczWmUUjyVXHTb-Qqu-ww-r4jvsLU,1824
|
|
7
|
+
optimal_classification_cutoffs-0.1.0.dist-info/METADATA,sha256=zDqlXvnZbLHDcKfD77WdYQBy0S6GtM7wBxaNnDgtBro,5137
|
|
8
|
+
optimal_classification_cutoffs-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
optimal_classification_cutoffs-0.1.0.dist-info/top_level.txt,sha256=Vjgzvn80t_BEJJZ1ERgMsXbrg_AYLIyPDoLwx9yFdpQ,16
|
|
10
|
+
optimal_classification_cutoffs-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
optimal_cutoffs
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Top-level package for optimal classification cutoff utilities."""
|
|
2
|
+
|
|
3
|
+
from .metrics import (
|
|
4
|
+
get_confusion_matrix,
|
|
5
|
+
METRIC_REGISTRY,
|
|
6
|
+
register_metric,
|
|
7
|
+
register_metrics,
|
|
8
|
+
)
|
|
9
|
+
from .optimizers import get_probability, get_optimal_threshold
|
|
10
|
+
from .cv import cv_threshold_optimization, nested_cv_threshold_optimization
|
|
11
|
+
from .wrapper import ThresholdOptimizer
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"get_confusion_matrix",
|
|
15
|
+
"METRIC_REGISTRY",
|
|
16
|
+
"register_metric",
|
|
17
|
+
"register_metrics",
|
|
18
|
+
"get_probability",
|
|
19
|
+
"get_optimal_threshold",
|
|
20
|
+
"cv_threshold_optimization",
|
|
21
|
+
"nested_cv_threshold_optimization",
|
|
22
|
+
"ThresholdOptimizer",
|
|
23
|
+
]
|
optimal_cutoffs/cv.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Cross-validation helpers for threshold optimization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from sklearn.model_selection import KFold
|
|
7
|
+
|
|
8
|
+
from .optimizers import get_optimal_threshold, _metric_score
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cv_threshold_optimization(
|
|
12
|
+
true_labs,
|
|
13
|
+
pred_prob,
|
|
14
|
+
metric="f1",
|
|
15
|
+
method="smart_brute",
|
|
16
|
+
cv=5,
|
|
17
|
+
random_state=None,
|
|
18
|
+
):
|
|
19
|
+
"""Estimate an optimal threshold using cross-validation.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
true_labs:
|
|
24
|
+
Array of true binary labels.
|
|
25
|
+
pred_prob:
|
|
26
|
+
Predicted probabilities from a classifier.
|
|
27
|
+
metric:
|
|
28
|
+
Metric name to optimize; must exist in the metric registry.
|
|
29
|
+
method:
|
|
30
|
+
Optimization strategy passed to
|
|
31
|
+
:func:`~optimal_cutoffs.optimizers.get_optimal_threshold`.
|
|
32
|
+
cv:
|
|
33
|
+
Number of folds for :class:`~sklearn.model_selection.KFold` cross-validation.
|
|
34
|
+
random_state:
|
|
35
|
+
Seed for the cross-validator shuffling.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
tuple[numpy.ndarray, numpy.ndarray]
|
|
40
|
+
Arrays of per-fold thresholds and scores.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
kf = KFold(n_splits=cv, shuffle=True, random_state=random_state)
|
|
44
|
+
thresholds = []
|
|
45
|
+
scores = []
|
|
46
|
+
for train_idx, test_idx in kf.split(true_labs):
|
|
47
|
+
thr = get_optimal_threshold(
|
|
48
|
+
true_labs[train_idx], pred_prob[train_idx], metric=metric, method=method
|
|
49
|
+
)
|
|
50
|
+
thresholds.append(thr)
|
|
51
|
+
score = _metric_score(true_labs[test_idx], pred_prob[test_idx], thr, metric)
|
|
52
|
+
scores.append(score)
|
|
53
|
+
return np.array(thresholds), np.array(scores)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def nested_cv_threshold_optimization(
|
|
57
|
+
true_labs,
|
|
58
|
+
pred_prob,
|
|
59
|
+
metric="f1",
|
|
60
|
+
method="smart_brute",
|
|
61
|
+
inner_cv=5,
|
|
62
|
+
outer_cv=5,
|
|
63
|
+
random_state=None,
|
|
64
|
+
):
|
|
65
|
+
"""Nested cross-validation for threshold optimization.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
true_labs:
|
|
70
|
+
Array of true binary labels.
|
|
71
|
+
pred_prob:
|
|
72
|
+
Predicted probabilities from a classifier.
|
|
73
|
+
metric:
|
|
74
|
+
Metric name to optimize.
|
|
75
|
+
method:
|
|
76
|
+
Optimization strategy passed to
|
|
77
|
+
:func:`~optimal_cutoffs.optimizers.get_optimal_threshold`.
|
|
78
|
+
inner_cv:
|
|
79
|
+
Number of folds in the inner loop used to estimate thresholds.
|
|
80
|
+
outer_cv:
|
|
81
|
+
Number of outer folds for unbiased performance assessment.
|
|
82
|
+
random_state:
|
|
83
|
+
Seed for the cross-validators.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
tuple[numpy.ndarray, numpy.ndarray]
|
|
88
|
+
Arrays of outer-fold thresholds and scores.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
outer = KFold(n_splits=outer_cv, shuffle=True, random_state=random_state)
|
|
92
|
+
outer_thresholds = []
|
|
93
|
+
outer_scores = []
|
|
94
|
+
for train_idx, test_idx in outer.split(true_labs):
|
|
95
|
+
inner_thresholds, _ = cv_threshold_optimization(
|
|
96
|
+
true_labs[train_idx],
|
|
97
|
+
pred_prob[train_idx],
|
|
98
|
+
metric=metric,
|
|
99
|
+
method=method,
|
|
100
|
+
cv=inner_cv,
|
|
101
|
+
random_state=random_state,
|
|
102
|
+
)
|
|
103
|
+
thr = float(np.mean(inner_thresholds))
|
|
104
|
+
outer_thresholds.append(thr)
|
|
105
|
+
score = _metric_score(true_labs[test_idx], pred_prob[test_idx], thr, metric)
|
|
106
|
+
outer_scores.append(score)
|
|
107
|
+
return np.array(outer_thresholds), np.array(outer_scores)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Metric registry, confusion matrix utilities, and built-in metrics."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Dict
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
METRIC_REGISTRY: Dict[str, Callable[[int, int, int, int], float]] = {}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_metric(name: str = None, func: Callable[[int, int, int, int], float] = None):
|
|
10
|
+
"""Register a metric function.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
name:
|
|
15
|
+
Optional key under which to store the metric. If not provided the
|
|
16
|
+
function's ``__name__`` is used.
|
|
17
|
+
func:
|
|
18
|
+
Metric callable accepting ``tp, tn, fp, fn``. When supplied the
|
|
19
|
+
function is registered immediately. If omitted, the returned decorator
|
|
20
|
+
can be used to annotate a metric function.
|
|
21
|
+
|
|
22
|
+
Returns
|
|
23
|
+
-------
|
|
24
|
+
Callable
|
|
25
|
+
The registered function or decorator.
|
|
26
|
+
"""
|
|
27
|
+
if func is not None:
|
|
28
|
+
METRIC_REGISTRY[name or func.__name__] = func
|
|
29
|
+
return func
|
|
30
|
+
|
|
31
|
+
def decorator(f: Callable[[int, int, int, int], float]):
|
|
32
|
+
METRIC_REGISTRY[name or f.__name__] = f
|
|
33
|
+
return f
|
|
34
|
+
|
|
35
|
+
return decorator
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register_metrics(metrics: Dict[str, Callable[[int, int, int, int], float]]):
|
|
39
|
+
"""Register multiple metric functions.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
metrics:
|
|
44
|
+
Mapping of metric names to callables that accept ``tp, tn, fp, fn``.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
None
|
|
49
|
+
This function mutates the global :data:`METRIC_REGISTRY` in-place.
|
|
50
|
+
"""
|
|
51
|
+
METRIC_REGISTRY.update(metrics)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@register_metric("f1")
|
|
55
|
+
def f1_score(tp: int, tn: int, fp: int, fn: int) -> float:
|
|
56
|
+
"""Compute the F\ :sub:`1` score.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
tp, tn, fp, fn:
|
|
61
|
+
Elements of the confusion matrix.
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
float
|
|
66
|
+
The harmonic mean of precision and recall.
|
|
67
|
+
"""
|
|
68
|
+
precision = tp / (tp + fp) if tp + fp > 0 else 0.0
|
|
69
|
+
recall = tp / (tp + fn) if tp + fn > 0 else 0.0
|
|
70
|
+
return 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@register_metric("accuracy")
|
|
74
|
+
def accuracy_score(tp: int, tn: int, fp: int, fn: int) -> float:
|
|
75
|
+
"""Compute classification accuracy.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
tp, tn, fp, fn:
|
|
80
|
+
Elements of the confusion matrix.
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
float
|
|
85
|
+
Ratio of correct predictions to total samples.
|
|
86
|
+
"""
|
|
87
|
+
total = tp + tn + fp + fn
|
|
88
|
+
return (tp + tn) / total if total > 0 else 0.0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_confusion_matrix(true_labs, pred_prob, prob):
|
|
92
|
+
"""Compute confusion-matrix counts for a given threshold.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
true_labs:
|
|
97
|
+
Array of true binary labels.
|
|
98
|
+
pred_prob:
|
|
99
|
+
Array of predicted probabilities in ``[0, 1]``.
|
|
100
|
+
prob:
|
|
101
|
+
Decision threshold applied to ``pred_prob``.
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
tuple[int, int, int, int]
|
|
106
|
+
Counts ``(tp, tn, fp, fn)``.
|
|
107
|
+
"""
|
|
108
|
+
pred_labs = pred_prob > prob
|
|
109
|
+
tp = np.sum(np.logical_and(pred_labs == 1, true_labs == 1))
|
|
110
|
+
tn = np.sum(np.logical_and(pred_labs == 0, true_labs == 0))
|
|
111
|
+
fp = np.sum(np.logical_and(pred_labs == 1, true_labs == 0))
|
|
112
|
+
fn = np.sum(np.logical_and(pred_labs == 0, true_labs == 1))
|
|
113
|
+
return tp, tn, fp, fn
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Threshold search strategies for optimizing classification metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy import optimize
|
|
7
|
+
|
|
8
|
+
from .metrics import METRIC_REGISTRY, get_confusion_matrix
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _accuracy(prob, true_labs, pred_prob, verbose=False):
|
|
12
|
+
tp, tn, fp, fn = get_confusion_matrix(true_labs, pred_prob, prob[0])
|
|
13
|
+
accuracy = (tp + tn) / (tp + tn + fp + fn)
|
|
14
|
+
if verbose:
|
|
15
|
+
print(f"Probability: {prob[0]:0.4f} Accuracy: {accuracy:0.4f}")
|
|
16
|
+
return 1 - accuracy
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _f1(prob, true_labs, pred_prob, verbose=False):
|
|
20
|
+
tp, tn, fp, fn = get_confusion_matrix(true_labs, pred_prob, prob[0])
|
|
21
|
+
precision = tp / (tp + fp) if tp + fp > 0 else 0.0
|
|
22
|
+
recall = tp / (tp + fn) if tp + fn > 0 else 0.0
|
|
23
|
+
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
|
|
24
|
+
if verbose:
|
|
25
|
+
print(f"Probability: {prob[0]:0.4f} F1 score: {f1:0.4f}")
|
|
26
|
+
return 1 - f1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_probability(true_labs, pred_prob, objective="accuracy", verbose=False):
|
|
30
|
+
"""Brute-force search for a simple metric's best threshold.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
true_labs:
|
|
35
|
+
Array of true binary labels.
|
|
36
|
+
pred_prob:
|
|
37
|
+
Predicted probabilities from a classifier.
|
|
38
|
+
objective:
|
|
39
|
+
Metric to optimize. Supported values are ``"accuracy"`` and ``"f1"``.
|
|
40
|
+
verbose:
|
|
41
|
+
If ``True``, print intermediate metric values during the search.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
float
|
|
46
|
+
Threshold that maximizes the specified metric.
|
|
47
|
+
"""
|
|
48
|
+
if objective == "accuracy":
|
|
49
|
+
prob = optimize.brute(
|
|
50
|
+
_accuracy, (slice(0.1, 0.9, 0.1),), args=(true_labs, pred_prob, verbose), disp=verbose
|
|
51
|
+
)
|
|
52
|
+
elif objective == "f1":
|
|
53
|
+
prob = optimize.brute(
|
|
54
|
+
_f1, (slice(0.1, 0.9, 0.1),), args=(true_labs, pred_prob, verbose), disp=verbose
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError(f"Unknown objective: {objective}")
|
|
58
|
+
return float(prob[0] if isinstance(prob, np.ndarray) else prob)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _metric_score(true_labs, pred_prob, threshold, metric="f1"):
|
|
62
|
+
"""Compute a metric score for a given threshold using registry metrics."""
|
|
63
|
+
tp, tn, fp, fn = get_confusion_matrix(true_labs, pred_prob, threshold)
|
|
64
|
+
try:
|
|
65
|
+
metric_func = METRIC_REGISTRY[metric]
|
|
66
|
+
except KeyError as exc:
|
|
67
|
+
raise ValueError(f"Unknown metric: {metric}") from exc
|
|
68
|
+
return float(metric_func(tp, tn, fp, fn))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_optimal_threshold(true_labs, pred_prob, metric="f1", method="smart_brute"):
|
|
72
|
+
"""Find the threshold that optimizes a metric.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
true_labs:
|
|
77
|
+
Array of true binary labels.
|
|
78
|
+
pred_prob:
|
|
79
|
+
Predicted probabilities from a classifier.
|
|
80
|
+
metric:
|
|
81
|
+
Name of a metric registered in :data:`~optimal_cutoffs.metrics.METRIC_REGISTRY`.
|
|
82
|
+
method:
|
|
83
|
+
Strategy used for optimization: ``"smart_brute"`` evaluates all unique
|
|
84
|
+
probabilities, ``"minimize"`` uses ``scipy.optimize.minimize_scalar``,
|
|
85
|
+
and ``"gradient"`` performs a simple gradient ascent.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
float
|
|
90
|
+
The threshold that maximizes the chosen metric.
|
|
91
|
+
"""
|
|
92
|
+
if method == "smart_brute":
|
|
93
|
+
thresholds = np.unique(pred_prob)
|
|
94
|
+
scores = [_metric_score(true_labs, pred_prob, t, metric) for t in thresholds]
|
|
95
|
+
return float(thresholds[int(np.argmax(scores))])
|
|
96
|
+
|
|
97
|
+
if method == "minimize":
|
|
98
|
+
res = optimize.minimize_scalar(
|
|
99
|
+
lambda t: -_metric_score(true_labs, pred_prob, t, metric),
|
|
100
|
+
bounds=(0, 1),
|
|
101
|
+
method="bounded",
|
|
102
|
+
)
|
|
103
|
+
# ``minimize_scalar`` may return a threshold that is suboptimal for
|
|
104
|
+
# piecewise-constant metrics like F1. To provide a more robust
|
|
105
|
+
# solution, also evaluate all unique predicted probabilities and pick
|
|
106
|
+
# whichever threshold yields the highest score.
|
|
107
|
+
candidates = np.unique(np.append(pred_prob, res.x))
|
|
108
|
+
scores = [_metric_score(true_labs, pred_prob, t, metric) for t in candidates]
|
|
109
|
+
return float(candidates[int(np.argmax(scores))])
|
|
110
|
+
|
|
111
|
+
if method == "gradient":
|
|
112
|
+
threshold = 0.5
|
|
113
|
+
lr = 0.1
|
|
114
|
+
eps = 1e-5
|
|
115
|
+
for _ in range(100):
|
|
116
|
+
grad = (
|
|
117
|
+
_metric_score(true_labs, pred_prob, threshold + eps, metric)
|
|
118
|
+
- _metric_score(true_labs, pred_prob, threshold - eps, metric)
|
|
119
|
+
) / (2 * eps)
|
|
120
|
+
threshold = np.clip(threshold + lr * grad, 0.0, 1.0)
|
|
121
|
+
return float(threshold)
|
|
122
|
+
|
|
123
|
+
raise ValueError(f"Unknown method: {method}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = ["get_probability", "get_optimal_threshold"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""High-level wrapper for threshold optimization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .optimizers import get_probability
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ThresholdOptimizer:
|
|
9
|
+
"""Brute-force optimizer for classification thresholds.
|
|
10
|
+
|
|
11
|
+
The class wraps :func:`optimal_cutoffs.optimizers.get_probability` and
|
|
12
|
+
exposes a scikit-learn style ``fit``/``predict`` API.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, objective: str = "accuracy", verbose: bool = False):
|
|
16
|
+
"""Create a new optimizer.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
objective:
|
|
21
|
+
Metric to optimize, e.g. ``"accuracy"`` or ``"f1"``.
|
|
22
|
+
verbose:
|
|
23
|
+
If ``True``, print progress during threshold search.
|
|
24
|
+
"""
|
|
25
|
+
self.objective = objective
|
|
26
|
+
self.verbose = verbose
|
|
27
|
+
self.threshold_ = None
|
|
28
|
+
|
|
29
|
+
def fit(self, true_labs, pred_prob):
|
|
30
|
+
"""Estimate the optimal threshold from labeled data.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
true_labs:
|
|
35
|
+
Array of true binary labels.
|
|
36
|
+
pred_prob:
|
|
37
|
+
Predicted probabilities from a classifier.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
ThresholdOptimizer
|
|
42
|
+
Fitted instance with ``threshold_`` attribute set.
|
|
43
|
+
"""
|
|
44
|
+
self.threshold_ = get_probability(true_labs, pred_prob, self.objective, self.verbose)
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def predict(self, pred_prob):
|
|
48
|
+
"""Convert probabilities to class predictions using the learned threshold.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
pred_prob:
|
|
53
|
+
Array of predicted probabilities to be thresholded.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
numpy.ndarray
|
|
58
|
+
Boolean array of predicted class labels.
|
|
59
|
+
"""
|
|
60
|
+
if self.threshold_ is None:
|
|
61
|
+
raise RuntimeError("ThresholdOptimizer has not been fitted.")
|
|
62
|
+
return pred_prob > self.threshold_
|