threshold-opt 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.
- threshold_opt-0.1.0/.gitignore +93 -0
- threshold_opt-0.1.0/LICENSE +21 -0
- threshold_opt-0.1.0/PKG-INFO +133 -0
- threshold_opt-0.1.0/README.md +113 -0
- threshold_opt-0.1.0/pyproject.toml +94 -0
- threshold_opt-0.1.0/src/threshold_opt/__init__.py +1 -0
- threshold_opt-0.1.0/src/threshold_opt/base.py +84 -0
- threshold_opt-0.1.0/src/threshold_opt/binary_strategies.py +524 -0
- threshold_opt-0.1.0/src/threshold_opt/binary_strategies_test.py +697 -0
- threshold_opt-0.1.0/src/threshold_opt/calibration.py +146 -0
- threshold_opt-0.1.0/src/threshold_opt/calibration_test.py +228 -0
- threshold_opt-0.1.0/src/threshold_opt/cv.py +51 -0
- threshold_opt-0.1.0/src/threshold_opt/cv_test.py +59 -0
- threshold_opt-0.1.0/src/threshold_opt/metrics.py +322 -0
- threshold_opt-0.1.0/src/threshold_opt/metrics_test.py +509 -0
- threshold_opt-0.1.0/src/threshold_opt/multiclass_strategies.py +1119 -0
- threshold_opt-0.1.0/src/threshold_opt/multiclass_strategies_test.py +1765 -0
- threshold_opt-0.1.0/src/threshold_opt/optimization.py +310 -0
- threshold_opt-0.1.0/src/threshold_opt/optimization_test.py +488 -0
- threshold_opt-0.1.0/src/threshold_opt/py.typed +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
eggs/
|
|
12
|
+
parts/
|
|
13
|
+
var/
|
|
14
|
+
sdist/
|
|
15
|
+
wheels/
|
|
16
|
+
.installed.cfg
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
|
|
20
|
+
# Virtual environments
|
|
21
|
+
.venv/
|
|
22
|
+
venv/
|
|
23
|
+
env/
|
|
24
|
+
ENV/
|
|
25
|
+
|
|
26
|
+
# uv
|
|
27
|
+
.uv/
|
|
28
|
+
|
|
29
|
+
# pytest
|
|
30
|
+
.pytest_cache/
|
|
31
|
+
.cache/
|
|
32
|
+
htmlcov/
|
|
33
|
+
.coverage
|
|
34
|
+
coverage.xml
|
|
35
|
+
|
|
36
|
+
# Jupyter
|
|
37
|
+
.ipynb_checkpoints/
|
|
38
|
+
*.ipynb
|
|
39
|
+
|
|
40
|
+
# Results / data (large files)
|
|
41
|
+
results/
|
|
42
|
+
experiments/results/
|
|
43
|
+
data/
|
|
44
|
+
*.parquet
|
|
45
|
+
*.csv
|
|
46
|
+
*.h5
|
|
47
|
+
*.pkl
|
|
48
|
+
|
|
49
|
+
# IDE
|
|
50
|
+
.vscode/
|
|
51
|
+
.idea/
|
|
52
|
+
*.swp
|
|
53
|
+
*.swo
|
|
54
|
+
|
|
55
|
+
# OS
|
|
56
|
+
.DS_Store
|
|
57
|
+
Thumbs.db
|
|
58
|
+
|
|
59
|
+
# Secrets
|
|
60
|
+
.env
|
|
61
|
+
.env.*
|
|
62
|
+
*.pem
|
|
63
|
+
*.key
|
|
64
|
+
|
|
65
|
+
# AI
|
|
66
|
+
.claude/
|
|
67
|
+
|
|
68
|
+
# catboost
|
|
69
|
+
catboost_info/
|
|
70
|
+
|
|
71
|
+
# tex audit outputs
|
|
72
|
+
tex/scripts/forward_refs_report.json
|
|
73
|
+
|
|
74
|
+
# LaTeX build artifacts
|
|
75
|
+
*.aux
|
|
76
|
+
*.fdb_latexmk
|
|
77
|
+
*.fls
|
|
78
|
+
*.log
|
|
79
|
+
*.out
|
|
80
|
+
*.synctex.gz
|
|
81
|
+
*.toc
|
|
82
|
+
*.bbl
|
|
83
|
+
*.blg
|
|
84
|
+
*.pdf
|
|
85
|
+
# biblatex/biber + makeindex + list-of artifacts (IME-USP template)
|
|
86
|
+
*.bcf
|
|
87
|
+
*.run.xml
|
|
88
|
+
*.idx
|
|
89
|
+
*.ilg
|
|
90
|
+
*.ind
|
|
91
|
+
*.lof
|
|
92
|
+
*.lot
|
|
93
|
+
tex/_backup_pre_argmax_rename_20260519_231422/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlo Lemos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: threshold-opt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Threshold optimization for binary and multiclass classification
|
|
5
|
+
Project-URL: Repository, https://github.com/vitaliset/threshold-opt
|
|
6
|
+
Project-URL: Issues, https://github.com/vitaliset/threshold-opt/issues
|
|
7
|
+
Author-email: Carlo Lemos <vitaliset@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: binary,calibration,classification,cost-sensitive,decision-making,multiclass,threshold,threshold-optimization
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: joblib>=1.3
|
|
16
|
+
Requires-Dist: numpy>=1.26
|
|
17
|
+
Requires-Dist: scikit-learn>=1.6
|
|
18
|
+
Requires-Dist: scipy>=1.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# threshold_opt
|
|
22
|
+
|
|
23
|
+
Threshold optimization for binary and multiclass classification.
|
|
24
|
+
|
|
25
|
+
Given a trained classifier and a cost matrix (or metric objective), `threshold_opt` finds the decision rule that minimizes expected cost. It supports binary and multiclass problems, with strategies ranging from closed-form analytical solutions to empirical search and calibration-based approaches.
|
|
26
|
+
|
|
27
|
+
This library is part of a [master's dissertation](https://github.com/vitaliset/threshold-opt/blob/main/tex/carlo_master.tex) on multiclass threshold optimization. The full experiment pipeline lives in [`experiments/`](https://github.com/vitaliset/threshold-opt/blob/main/experiments/README.md).
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install threshold-opt
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The package is published as `threshold-opt` and imported as `threshold_opt`.
|
|
36
|
+
|
|
37
|
+
For development (includes experiment dependencies):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/vitaliset/threshold-opt.git
|
|
41
|
+
cd threshold-opt
|
|
42
|
+
uv sync
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick example
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import numpy as np
|
|
49
|
+
from sklearn.linear_model import LogisticRegression
|
|
50
|
+
from sklearn.model_selection import train_test_split
|
|
51
|
+
from sklearn.datasets import load_breast_cancer
|
|
52
|
+
from threshold_opt import binary_strategies
|
|
53
|
+
|
|
54
|
+
# Train any sklearn-compatible classifier
|
|
55
|
+
X, y = load_breast_cancer(return_X_y=True)
|
|
56
|
+
X_train, X_val, y_train, y_val = train_test_split(
|
|
57
|
+
X, y, test_size=0.3, random_state=42
|
|
58
|
+
)
|
|
59
|
+
model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
|
|
60
|
+
|
|
61
|
+
# Define a cost matrix: missing cancer (FN) costs 10x more than a false alarm (FP)
|
|
62
|
+
cost_matrix = np.array([
|
|
63
|
+
[0.0, 1.0], # TN=0, FP=1
|
|
64
|
+
[10.0, 0.0], # FN=10, TP=0
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
# Find the optimal threshold analytically (no validation data needed)
|
|
68
|
+
strategy = binary_strategies.AnalyticalThreshold(model)
|
|
69
|
+
strategy.fit(np.array([]), np.array([]), cost_matrix)
|
|
70
|
+
y_pred = strategy.predict(X_val)
|
|
71
|
+
|
|
72
|
+
# Or search empirically on a validation set
|
|
73
|
+
strategy = binary_strategies.EmpiricalThreshold(model)
|
|
74
|
+
strategy.fit(X_val, y_val, cost_matrix)
|
|
75
|
+
y_pred = strategy.predict(X_val)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## What's inside
|
|
79
|
+
|
|
80
|
+
| Module | Description |
|
|
81
|
+
|--------|-------------|
|
|
82
|
+
| `binary_strategies` | 11 strategies for binary classification: default, analytical, empirical search, and calibration variants (isotonic, Platt, temperature, Dirichlet) |
|
|
83
|
+
| `multiclass_strategies` | 12 strategies for multiclass: argmax, risk minimization, threshold ratios, Voronoi tuning, sequential selection, risk partitioning, and calibration variants |
|
|
84
|
+
| `calibration` | `DirichletCalibrator` for post-hoc probability calibration in log-space |
|
|
85
|
+
| `metrics` | Cost matrix utilities, metric objectives (`MetricObjective`), vectorized scoring, analytical threshold derivation |
|
|
86
|
+
| `optimization` | Grid search and random search optimizers for strategy parameter tuning |
|
|
87
|
+
| `base` | `DecisionStrategy` abstract base class |
|
|
88
|
+
|
|
89
|
+
All strategies follow a unified interface: `fit(X, y, objective)` then `predict(X)`. Objectives can be cost matrices (numpy arrays), `MetricObjective` instances, or callable factories.
|
|
90
|
+
|
|
91
|
+
### Public API
|
|
92
|
+
|
|
93
|
+
`threshold_opt` ships its public API as importable submodules; there is no flat top-level namespace re-export. Stable surface:
|
|
94
|
+
|
|
95
|
+
- **Strategy contract** ([`base.DecisionStrategy`](https://github.com/vitaliset/threshold-opt/blob/main/src/threshold_opt/base.py)). All strategies subclass `DecisionStrategy` and expose:
|
|
96
|
+
- `fit(X, y, optimization_objective)` — fit the decision layer on validation data and return `self`.
|
|
97
|
+
- `predict(X)` — emit class predictions.
|
|
98
|
+
- `needs_validation_set: bool` — `True` if the strategy consumes a held-out validation split (calibrators, single-split tuners), `False` otherwise (default, analytical, CV-tuners).
|
|
99
|
+
- **Optimization objectives** ([`metrics.OptimizationObjective`](https://github.com/vitaliset/threshold-opt/blob/main/src/threshold_opt/metrics.py)). Either a numpy `(K, K)` cost matrix (cell `[i, j]` = cost of predicting `j` when the truth is `i`), or a `metrics.MetricObjective(metric_fn, higher_is_better, ...)` wrapping any `(y_true, y_pred) -> float` callable. `MetricObjective.vectorized_key` is the optional fast path used by binary strategies; binary metric objectives that omit it still work via a per-threshold loop.
|
|
100
|
+
- **Calibrator** ([`calibration.DirichletCalibrator`](https://github.com/vitaliset/threshold-opt/blob/main/src/threshold_opt/calibration.py)). Standalone `fit(probs, y)` / `predict_proba(probs)` calibrator with optional ODIR penalties (`reg_lambda`, `reg_mu`). Used by `DirichletCalibrationArgmax` / `DirichletCalibrationAnalytical` and freely composable.
|
|
101
|
+
- **Optimizers** ([`optimization.RandomSearchOptimizer`, `GridSearchOptimizer`](https://github.com/vitaliset/threshold-opt/blob/main/src/threshold_opt/optimization.py)). Pluggable parameter optimizers for tuning strategies; subclass `optimization.ParameterOptimizer` to add new ones.
|
|
102
|
+
- **Shared CV seed** ([`base.CV_RANDOM_STATE`](https://github.com/vitaliset/threshold-opt/blob/main/src/threshold_opt/base.py)). Every K-fold loop in the library uses this constant, so external precompute caches that key on it stay in sync with strategies' lazy refits.
|
|
103
|
+
|
|
104
|
+
Internal symbols (prefixed with `_`, e.g. `base._CVFoldPredictions`, `binary_strategies._positive_class_probs`) are not part of the public API and may change without notice.
|
|
105
|
+
|
|
106
|
+
## Dissertation and experiments
|
|
107
|
+
|
|
108
|
+
This repo also contains the experiment pipeline and LaTeX source for the dissertation. See [`experiments/README.md`](https://github.com/vitaliset/threshold-opt/blob/main/experiments/README.md) for how to run experiments, generate tables, and build the PDF.
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
make test # run pytest
|
|
114
|
+
make lint # ruff + ruff-format + chktex
|
|
115
|
+
make help # all available targets
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Releasing
|
|
119
|
+
|
|
120
|
+
The package is uploaded to [PyPI](https://pypi.org/project/threshold-opt/) by [`.github/workflows/publish.yml`](https://github.com/vitaliset/threshold-opt/blob/main/.github/workflows/publish.yml), through [trusted publishing](https://docs.pypi.org/trusted-publishers/). To release a new version:
|
|
121
|
+
|
|
122
|
+
1. Set `version` in `pyproject.toml`.
|
|
123
|
+
2. Publish a GitHub release whose tag is `v` followed by that version (`v0.1.0` for version `0.1.0`).
|
|
124
|
+
|
|
125
|
+
The workflow checks that the tag matches the version, builds the package and uploads it. Releases with any other tag publish nothing.
|
|
126
|
+
|
|
127
|
+
## Project structure
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
src/threshold_opt/ Library: strategies, calibration, metrics, optimization
|
|
131
|
+
experiments/ Experiment pipeline, analysis, cloud scripts
|
|
132
|
+
tex/ LaTeX dissertation source
|
|
133
|
+
```
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# threshold_opt
|
|
2
|
+
|
|
3
|
+
Threshold optimization for binary and multiclass classification.
|
|
4
|
+
|
|
5
|
+
Given a trained classifier and a cost matrix (or metric objective), `threshold_opt` finds the decision rule that minimizes expected cost. It supports binary and multiclass problems, with strategies ranging from closed-form analytical solutions to empirical search and calibration-based approaches.
|
|
6
|
+
|
|
7
|
+
This library is part of a [master's dissertation](https://github.com/vitaliset/threshold-opt/blob/main/tex/carlo_master.tex) on multiclass threshold optimization. The full experiment pipeline lives in [`experiments/`](https://github.com/vitaliset/threshold-opt/blob/main/experiments/README.md).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install threshold-opt
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The package is published as `threshold-opt` and imported as `threshold_opt`.
|
|
16
|
+
|
|
17
|
+
For development (includes experiment dependencies):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
git clone https://github.com/vitaliset/threshold-opt.git
|
|
21
|
+
cd threshold-opt
|
|
22
|
+
uv sync
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick example
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import numpy as np
|
|
29
|
+
from sklearn.linear_model import LogisticRegression
|
|
30
|
+
from sklearn.model_selection import train_test_split
|
|
31
|
+
from sklearn.datasets import load_breast_cancer
|
|
32
|
+
from threshold_opt import binary_strategies
|
|
33
|
+
|
|
34
|
+
# Train any sklearn-compatible classifier
|
|
35
|
+
X, y = load_breast_cancer(return_X_y=True)
|
|
36
|
+
X_train, X_val, y_train, y_val = train_test_split(
|
|
37
|
+
X, y, test_size=0.3, random_state=42
|
|
38
|
+
)
|
|
39
|
+
model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
|
|
40
|
+
|
|
41
|
+
# Define a cost matrix: missing cancer (FN) costs 10x more than a false alarm (FP)
|
|
42
|
+
cost_matrix = np.array([
|
|
43
|
+
[0.0, 1.0], # TN=0, FP=1
|
|
44
|
+
[10.0, 0.0], # FN=10, TP=0
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
# Find the optimal threshold analytically (no validation data needed)
|
|
48
|
+
strategy = binary_strategies.AnalyticalThreshold(model)
|
|
49
|
+
strategy.fit(np.array([]), np.array([]), cost_matrix)
|
|
50
|
+
y_pred = strategy.predict(X_val)
|
|
51
|
+
|
|
52
|
+
# Or search empirically on a validation set
|
|
53
|
+
strategy = binary_strategies.EmpiricalThreshold(model)
|
|
54
|
+
strategy.fit(X_val, y_val, cost_matrix)
|
|
55
|
+
y_pred = strategy.predict(X_val)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What's inside
|
|
59
|
+
|
|
60
|
+
| Module | Description |
|
|
61
|
+
|--------|-------------|
|
|
62
|
+
| `binary_strategies` | 11 strategies for binary classification: default, analytical, empirical search, and calibration variants (isotonic, Platt, temperature, Dirichlet) |
|
|
63
|
+
| `multiclass_strategies` | 12 strategies for multiclass: argmax, risk minimization, threshold ratios, Voronoi tuning, sequential selection, risk partitioning, and calibration variants |
|
|
64
|
+
| `calibration` | `DirichletCalibrator` for post-hoc probability calibration in log-space |
|
|
65
|
+
| `metrics` | Cost matrix utilities, metric objectives (`MetricObjective`), vectorized scoring, analytical threshold derivation |
|
|
66
|
+
| `optimization` | Grid search and random search optimizers for strategy parameter tuning |
|
|
67
|
+
| `base` | `DecisionStrategy` abstract base class |
|
|
68
|
+
|
|
69
|
+
All strategies follow a unified interface: `fit(X, y, objective)` then `predict(X)`. Objectives can be cost matrices (numpy arrays), `MetricObjective` instances, or callable factories.
|
|
70
|
+
|
|
71
|
+
### Public API
|
|
72
|
+
|
|
73
|
+
`threshold_opt` ships its public API as importable submodules; there is no flat top-level namespace re-export. Stable surface:
|
|
74
|
+
|
|
75
|
+
- **Strategy contract** ([`base.DecisionStrategy`](https://github.com/vitaliset/threshold-opt/blob/main/src/threshold_opt/base.py)). All strategies subclass `DecisionStrategy` and expose:
|
|
76
|
+
- `fit(X, y, optimization_objective)` — fit the decision layer on validation data and return `self`.
|
|
77
|
+
- `predict(X)` — emit class predictions.
|
|
78
|
+
- `needs_validation_set: bool` — `True` if the strategy consumes a held-out validation split (calibrators, single-split tuners), `False` otherwise (default, analytical, CV-tuners).
|
|
79
|
+
- **Optimization objectives** ([`metrics.OptimizationObjective`](https://github.com/vitaliset/threshold-opt/blob/main/src/threshold_opt/metrics.py)). Either a numpy `(K, K)` cost matrix (cell `[i, j]` = cost of predicting `j` when the truth is `i`), or a `metrics.MetricObjective(metric_fn, higher_is_better, ...)` wrapping any `(y_true, y_pred) -> float` callable. `MetricObjective.vectorized_key` is the optional fast path used by binary strategies; binary metric objectives that omit it still work via a per-threshold loop.
|
|
80
|
+
- **Calibrator** ([`calibration.DirichletCalibrator`](https://github.com/vitaliset/threshold-opt/blob/main/src/threshold_opt/calibration.py)). Standalone `fit(probs, y)` / `predict_proba(probs)` calibrator with optional ODIR penalties (`reg_lambda`, `reg_mu`). Used by `DirichletCalibrationArgmax` / `DirichletCalibrationAnalytical` and freely composable.
|
|
81
|
+
- **Optimizers** ([`optimization.RandomSearchOptimizer`, `GridSearchOptimizer`](https://github.com/vitaliset/threshold-opt/blob/main/src/threshold_opt/optimization.py)). Pluggable parameter optimizers for tuning strategies; subclass `optimization.ParameterOptimizer` to add new ones.
|
|
82
|
+
- **Shared CV seed** ([`base.CV_RANDOM_STATE`](https://github.com/vitaliset/threshold-opt/blob/main/src/threshold_opt/base.py)). Every K-fold loop in the library uses this constant, so external precompute caches that key on it stay in sync with strategies' lazy refits.
|
|
83
|
+
|
|
84
|
+
Internal symbols (prefixed with `_`, e.g. `base._CVFoldPredictions`, `binary_strategies._positive_class_probs`) are not part of the public API and may change without notice.
|
|
85
|
+
|
|
86
|
+
## Dissertation and experiments
|
|
87
|
+
|
|
88
|
+
This repo also contains the experiment pipeline and LaTeX source for the dissertation. See [`experiments/README.md`](https://github.com/vitaliset/threshold-opt/blob/main/experiments/README.md) for how to run experiments, generate tables, and build the PDF.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
make test # run pytest
|
|
94
|
+
make lint # ruff + ruff-format + chktex
|
|
95
|
+
make help # all available targets
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Releasing
|
|
99
|
+
|
|
100
|
+
The package is uploaded to [PyPI](https://pypi.org/project/threshold-opt/) by [`.github/workflows/publish.yml`](https://github.com/vitaliset/threshold-opt/blob/main/.github/workflows/publish.yml), through [trusted publishing](https://docs.pypi.org/trusted-publishers/). To release a new version:
|
|
101
|
+
|
|
102
|
+
1. Set `version` in `pyproject.toml`.
|
|
103
|
+
2. Publish a GitHub release whose tag is `v` followed by that version (`v0.1.0` for version `0.1.0`).
|
|
104
|
+
|
|
105
|
+
The workflow checks that the tag matches the version, builds the package and uploads it. Releases with any other tag publish nothing.
|
|
106
|
+
|
|
107
|
+
## Project structure
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
src/threshold_opt/ Library: strategies, calibration, metrics, optimization
|
|
111
|
+
experiments/ Experiment pipeline, analysis, cloud scripts
|
|
112
|
+
tex/ LaTeX dissertation source
|
|
113
|
+
```
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "threshold-opt"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Threshold optimization for binary and multiclass classification"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Carlo Lemos", email = "vitaliset@users.noreply.github.com" }]
|
|
9
|
+
keywords = [
|
|
10
|
+
"threshold-optimization",
|
|
11
|
+
"threshold",
|
|
12
|
+
"classification",
|
|
13
|
+
"binary",
|
|
14
|
+
"multiclass",
|
|
15
|
+
"cost-sensitive",
|
|
16
|
+
"decision-making",
|
|
17
|
+
"calibration",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
# Lower bounds: numpy>=1.26 for Python 3.11 wheels and the array API
|
|
26
|
+
# changes used by the calibration code; scipy>=1.11 for the numpy 1.26
|
|
27
|
+
# ABI; scikit-learn>=1.6 for sklearn.frozen.FrozenEstimator (used in
|
|
28
|
+
# binary_strategies._CalibratedDefaultThreshold and friends).
|
|
29
|
+
"numpy>=1.26",
|
|
30
|
+
"scipy>=1.11",
|
|
31
|
+
"scikit-learn>=1.6",
|
|
32
|
+
"joblib>=1.3",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Repository = "https://github.com/vitaliset/threshold-opt"
|
|
37
|
+
Issues = "https://github.com/vitaliset/threshold-opt/issues"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
# The distribution is the library alone: the experiment pipeline and the
|
|
44
|
+
# dissertation source stay out of both artifacts. The tests live next to
|
|
45
|
+
# the modules and ship in the sdist, not in the wheel.
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = ["src/threshold_opt"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/threshold_opt"]
|
|
51
|
+
exclude = ["*_test.py"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["src", "experiments"]
|
|
55
|
+
python_files = ["*_test.py"]
|
|
56
|
+
|
|
57
|
+
[dependency-groups]
|
|
58
|
+
experiments = [
|
|
59
|
+
"pandas",
|
|
60
|
+
"pyarrow", # pandas parquet backend
|
|
61
|
+
"lightgbm",
|
|
62
|
+
"xgboost",
|
|
63
|
+
"catboost",
|
|
64
|
+
"tabpfn",
|
|
65
|
+
"tabicl", # TabICLv2 tabular foundation model
|
|
66
|
+
"pytabkit", # RealMLP_TD_Classifier
|
|
67
|
+
"tabm", # TabM deep tabular model (ICLR 2025)
|
|
68
|
+
"imbalanced-learn", # geometric_mean_score
|
|
69
|
+
"tqdm",
|
|
70
|
+
]
|
|
71
|
+
dev = [
|
|
72
|
+
"pytest",
|
|
73
|
+
"matplotlib",
|
|
74
|
+
"seaborn",
|
|
75
|
+
"ipykernel>=7.2.0",
|
|
76
|
+
"jinja2",
|
|
77
|
+
"ruff",
|
|
78
|
+
"pre-commit>=4.5.1",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
[tool.uv]
|
|
82
|
+
default-groups = ["dev", "experiments"]
|
|
83
|
+
|
|
84
|
+
[tool.ruff]
|
|
85
|
+
line-length = 79
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint]
|
|
88
|
+
select = ["E", "W", "F", "I", "RUF"]
|
|
89
|
+
ignore = [
|
|
90
|
+
"E731", # lambda assignment fine in research code
|
|
91
|
+
"E741", # allow short math names (l, I, O)
|
|
92
|
+
# ruff format conflict rules
|
|
93
|
+
"W191", "E111", "E114", "E117",
|
|
94
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Threshold optimization for binary and multiclass classification."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Base classes for decision strategies."""
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import dataclasses
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.typing as npt
|
|
9
|
+
|
|
10
|
+
from threshold_opt import metrics
|
|
11
|
+
|
|
12
|
+
# Seed shared by every K-fold loop in the library and by the experiments
|
|
13
|
+
# pipeline's precompute (see ``experiments.common.pipeline``). Both call
|
|
14
|
+
# sites must agree on this value; otherwise the precompute cache desyncs
|
|
15
|
+
# from the strategy's own lazy refit and the experiment is broken in a
|
|
16
|
+
# hard-to-diagnose way.
|
|
17
|
+
CV_RANDOM_STATE = 42
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass(frozen=True)
|
|
21
|
+
class _CVFoldPredictions:
|
|
22
|
+
"""Labels and predicted probabilities for a single CV validation fold."""
|
|
23
|
+
|
|
24
|
+
y: np.ndarray
|
|
25
|
+
probs: np.ndarray
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _coerce_integer_labels(y: npt.ArrayLike) -> np.ndarray:
|
|
29
|
+
"""Cast *y* to int when it's already integer-valued (even if float dtype).
|
|
30
|
+
|
|
31
|
+
OpenML can return class labels encoded as float64 (values like 0.0, 1.0,
|
|
32
|
+
2.0). Downstream multiclass strategies use labels as array indices, so
|
|
33
|
+
we canonicalise here. Non-integer-valued floats (e.g. 0.5, regression
|
|
34
|
+
targets) pass through untouched; the strategy-level validator rejects
|
|
35
|
+
them before this runs.
|
|
36
|
+
"""
|
|
37
|
+
arr = np.asarray(y)
|
|
38
|
+
if np.issubdtype(arr.dtype, np.integer):
|
|
39
|
+
return arr
|
|
40
|
+
return arr.astype(int)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DecisionStrategy(abc.ABC):
|
|
44
|
+
"""Abstract base class for decision strategies."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, model):
|
|
47
|
+
self.model = model
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def needs_validation_set(self) -> bool:
|
|
51
|
+
"""Whether the strategy requires a validation set for calibration."""
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
def fit(
|
|
55
|
+
self,
|
|
56
|
+
X: npt.ArrayLike,
|
|
57
|
+
y: npt.ArrayLike,
|
|
58
|
+
optimization_objective: metrics.OptimizationObjective,
|
|
59
|
+
) -> Self:
|
|
60
|
+
"""Fit the strategy using validation data."""
|
|
61
|
+
self._validate_fit_input(X, y, optimization_objective)
|
|
62
|
+
return self._fit(X, _coerce_integer_labels(y), optimization_objective)
|
|
63
|
+
|
|
64
|
+
def _validate_fit_input(
|
|
65
|
+
self,
|
|
66
|
+
X: npt.ArrayLike,
|
|
67
|
+
y: npt.ArrayLike,
|
|
68
|
+
optimization_objective: metrics.OptimizationObjective,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Validates input data before fitting."""
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
@abc.abstractmethod
|
|
74
|
+
def _fit(
|
|
75
|
+
self,
|
|
76
|
+
X: npt.ArrayLike,
|
|
77
|
+
y: npt.ArrayLike,
|
|
78
|
+
optimization_objective: metrics.OptimizationObjective,
|
|
79
|
+
) -> Self:
|
|
80
|
+
"""Implementation of the fit logic."""
|
|
81
|
+
|
|
82
|
+
@abc.abstractmethod
|
|
83
|
+
def predict(self, X: npt.ArrayLike) -> npt.ArrayLike:
|
|
84
|
+
"""Predict class labels for X using the fitted strategy."""
|