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.
@@ -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."""