optimal-classification-cutoffs 0.1.0__tar.gz

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,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,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,70 @@
1
+ # Optimal Classification Cut-Offs
2
+
3
+ Probabilistic classifiers output per-class probabilities, and fixed cutoffs such as ``0.5`` rarely maximize metrics like accuracy or the F\ :sub:`1` score.
4
+ This package provides utilities to **select optimal probability cutoffs for each class**, supporting both multi-class and binary classifiers.
5
+ Optimization methods include brute-force search, numerical techniques, and gradient-based approaches.
6
+ Binary thresholding at a single cutoff remains fully supported as a special case.
7
+
8
+ ## Quick start
9
+
10
+ ```python
11
+ from optimal_cutoffs import ThresholdOptimizer
12
+
13
+ # true binary labels and predicted probabilities
14
+ y_true = ...
15
+ y_prob = ...
16
+
17
+ optimizer = ThresholdOptimizer(objective="f1")
18
+ optimizer.fit(y_true, y_prob)
19
+ y_pred = optimizer.predict(y_prob)
20
+ ```
21
+
22
+ ## API
23
+
24
+ ### `get_confusion_matrix(true_labs, pred_prob, threshold)`
25
+ - **Purpose:** Compute confusion-matrix counts for a threshold.
26
+ - **Args:** arrays of true labels and probabilities, plus the decision threshold.
27
+ - **Returns:** `(tp, tn, fp, fn)` counts.
28
+
29
+ ### `register_metric(name=None, func=None)`
30
+ - **Purpose:** Add a metric function to the global registry.
31
+ - **Args:** optional metric name and callable; can also be used as a decorator.
32
+ - **Returns:** the registered function or decorator.
33
+
34
+ ### `register_metrics(metrics)`
35
+ - **Purpose:** Register multiple metric functions at once.
36
+ - **Args:** dictionary mapping names to callables.
37
+ - **Returns:** `None`.
38
+
39
+ ### `get_probability(true_labs, pred_prob, objective='accuracy', verbose=False)`
40
+ - **Purpose:** Brute-force search for the threshold that maximizes accuracy or F\ :sub:`1`.
41
+ - **Args:** true labels, predicted probabilities, metric name, and verbosity flag.
42
+ - **Returns:** optimal threshold.
43
+
44
+ ### `get_optimal_threshold(true_labs, pred_prob, metric='f1', method='smart_brute')`
45
+ - **Purpose:** Optimize any registered metric using different strategies
46
+ (brute force, ``minimize``, or ``gradient``).
47
+ - **Args:** true labels, probabilities, metric name, and optimization method.
48
+ - **Returns:** optimal threshold.
49
+
50
+ ### `cv_threshold_optimization(true_labs, pred_prob, metric='f1', method='smart_brute', cv=5, random_state=None)`
51
+ - **Purpose:** Estimate thresholds via cross-validation and report per-fold scores.
52
+ - **Returns:** arrays of thresholds and scores.
53
+
54
+ ### `nested_cv_threshold_optimization(true_labs, pred_prob, metric='f1', method='smart_brute', inner_cv=5, outer_cv=5, random_state=None)`
55
+ - **Purpose:** Perform nested cross-validation for threshold estimation and
56
+ unbiased performance evaluation.
57
+ - **Returns:** arrays of outer-fold thresholds and scores.
58
+
59
+ ### `ThresholdOptimizer(objective='accuracy', verbose=False)`
60
+ - **Purpose:** High-level wrapper with ``fit``/``predict`` methods.
61
+ - **Args:** metric name and verbosity flag.
62
+ - **Returns:** fitted instance with ``threshold_`` attribute after calling ``fit``.
63
+
64
+ ## Examples
65
+
66
+ - [Cross-validation and gradient methods](examples/comscore.ipynb)
67
+
68
+ ## Authors
69
+
70
+ Suriyan Laohaprapanon and Gaurav Sood
@@ -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,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ optimal_classification_cutoffs.egg-info/PKG-INFO
5
+ optimal_classification_cutoffs.egg-info/SOURCES.txt
6
+ optimal_classification_cutoffs.egg-info/dependency_links.txt
7
+ optimal_classification_cutoffs.egg-info/requires.txt
8
+ optimal_classification_cutoffs.egg-info/top_level.txt
9
+ optimal_cutoffs/__init__.py
10
+ optimal_cutoffs/cv.py
11
+ optimal_cutoffs/metrics.py
12
+ optimal_cutoffs/optimizers.py
13
+ optimal_cutoffs/wrapper.py
14
+ tests/test_metrics.py
15
+ tests/test_optimizers.py
@@ -0,0 +1,7 @@
1
+ numpy
2
+ scipy
3
+ scikit-learn
4
+
5
+ [examples]
6
+ matplotlib
7
+ pandas
@@ -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
+ ]
@@ -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_
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "optimal-classification-cutoffs"
7
+ version = "0.1.0"
8
+ description = "Utilities for computing optimal classification cutoffs for binary and multi-class"
9
+ readme = "README.md"
10
+ license = {file = "LICENSE"}
11
+ dependencies = [
12
+ "numpy",
13
+ "scipy",
14
+ "scikit-learn"
15
+ ]
16
+ authors = [
17
+ {name = "Gaurav Sood", email = "contact@gsood.com"}
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Science/Research",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.9",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Topic :: Scientific/Engineering :: Mathematics",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ examples = ["matplotlib", "pandas"]
33
+
34
+ [tool.setuptools]
35
+ packages = ["optimal_cutoffs"]
36
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,42 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from optimal_cutoffs import get_confusion_matrix, get_optimal_threshold
5
+ from optimal_cutoffs.metrics import METRIC_REGISTRY, register_metric, register_metrics
6
+
7
+
8
+ def test_confusion_matrix_and_metrics():
9
+ y_true = np.array([0, 1, 1, 0, 1])
10
+ y_prob = np.array([0.2, 0.6, 0.7, 0.3, 0.4])
11
+ threshold = 0.5
12
+ tp, tn, fp, fn = get_confusion_matrix(y_true, y_prob, threshold)
13
+ assert (tp, tn, fp, fn) == (2, 2, 0, 1)
14
+
15
+ precision = tp / (tp + fp) if tp + fp > 0 else 0.0
16
+ recall = tp / (tp + fn) if tp + fn > 0 else 0.0
17
+ f1 = 2 * precision * recall / (precision + recall)
18
+
19
+ assert precision == pytest.approx(1.0)
20
+ assert recall == pytest.approx(2 / 3)
21
+ assert f1 == pytest.approx(0.8)
22
+
23
+
24
+ def test_metric_registry_and_custom_registration():
25
+ assert "f1" in METRIC_REGISTRY
26
+ assert "accuracy" in METRIC_REGISTRY
27
+
28
+ @register_metric("sum_tp_tn")
29
+ def sum_tp_tn(tp, tn, fp, fn):
30
+ return tp + tn
31
+
32
+ assert METRIC_REGISTRY["sum_tp_tn"](1, 1, 0, 0) == 2
33
+
34
+ def tpr(tp, tn, fp, fn):
35
+ return tp / (tp + fn) if tp + fn > 0 else 0.0
36
+
37
+ register_metrics({"tpr": tpr})
38
+
39
+ y_true = np.array([0, 0, 1, 1])
40
+ y_prob = np.array([0.1, 0.4, 0.6, 0.9])
41
+ thr = get_optimal_threshold(y_true, y_prob, metric="tpr")
42
+ assert 0.0 <= thr <= 1.0
@@ -0,0 +1,26 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from optimal_cutoffs import get_optimal_threshold, cv_threshold_optimization
5
+
6
+
7
+ def test_get_optimal_threshold_methods():
8
+ y_true = np.array([0, 0, 0, 1, 1, 1])
9
+ y_prob = np.array([0.1, 0.2, 0.4, 0.6, 0.8, 0.9])
10
+ for method in ["smart_brute", "minimize", "gradient"]:
11
+ thr = get_optimal_threshold(y_true, y_prob, method=method)
12
+ assert 0.0 <= thr <= 1.0
13
+ assert thr == pytest.approx(0.5, abs=0.2)
14
+
15
+
16
+ def test_cv_threshold_optimization():
17
+ rng = np.random.default_rng(0)
18
+ y_prob = rng.random(100)
19
+ y_true = (y_prob > 0.5).astype(int)
20
+ thresholds, scores = cv_threshold_optimization(
21
+ y_true, y_prob, method="smart_brute", cv=5, random_state=0
22
+ )
23
+ assert thresholds.shape == (5,)
24
+ assert scores.shape == (5,)
25
+ assert np.all((thresholds >= 0) & (thresholds <= 1))
26
+ assert np.all((scores >= 0) & (scores <= 1))