nestkit 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.
- nestkit/__init__.py +49 -0
- nestkit/_base.py +398 -0
- nestkit/_validation.py +113 -0
- nestkit/calibration/__init__.py +12 -0
- nestkit/calibration/calibrators.py +162 -0
- nestkit/calibration/diagnostics.py +513 -0
- nestkit/callbacks.py +235 -0
- nestkit/classifier.py +559 -0
- nestkit/comparison/__init__.py +10 -0
- nestkit/comparison/comparator.py +391 -0
- nestkit/comparison/statistical_tests.py +327 -0
- nestkit/diagnostics/__init__.py +9 -0
- nestkit/diagnostics/stability.py +201 -0
- nestkit/importance/__init__.py +11 -0
- nestkit/importance/aggregator.py +319 -0
- nestkit/importance/extractors.py +180 -0
- nestkit/importance/stability.py +106 -0
- nestkit/inner/__init__.py +9 -0
- nestkit/inner/search.py +151 -0
- nestkit/inner/tuning_report.py +209 -0
- nestkit/plotting/__init__.py +66 -0
- nestkit/plotting/_style.py +38 -0
- nestkit/plotting/calibration.py +220 -0
- nestkit/plotting/comparison.py +302 -0
- nestkit/plotting/folds.py +199 -0
- nestkit/plotting/importance.py +242 -0
- nestkit/plotting/summary.py +479 -0
- nestkit/plotting/threshold.py +186 -0
- nestkit/plotting/tuning.py +121 -0
- nestkit/regressor.py +251 -0
- nestkit/results/__init__.py +19 -0
- nestkit/results/_base.py +283 -0
- nestkit/results/classifier_results.py +279 -0
- nestkit/results/regressor_results.py +146 -0
- nestkit/thresholding/__init__.py +28 -0
- nestkit/thresholding/criteria.py +288 -0
- nestkit/thresholding/results.py +73 -0
- nestkit/thresholding/strategies.py +300 -0
- nestkit-0.1.0.dist-info/METADATA +207 -0
- nestkit-0.1.0.dist-info/RECORD +43 -0
- nestkit-0.1.0.dist-info/WHEEL +5 -0
- nestkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- nestkit-0.1.0.dist-info/top_level.txt +1 -0
nestkit/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""nestkit - A rigorous nested cross-validation toolkit for scikit-learn.
|
|
2
|
+
|
|
3
|
+
Core estimators
|
|
4
|
+
---------------
|
|
5
|
+
- :class:`NestedCVClassifier` - Classification with optional calibration
|
|
6
|
+
and threshold optimization.
|
|
7
|
+
- :class:`NestedCVRegressor` - Regression with optional residual-based
|
|
8
|
+
prediction intervals.
|
|
9
|
+
|
|
10
|
+
Results
|
|
11
|
+
-------
|
|
12
|
+
- :class:`ClassifierResults` / :class:`RegressorResults` - Rich result
|
|
13
|
+
containers with summary statistics, export methods, and plots.
|
|
14
|
+
|
|
15
|
+
Submodules
|
|
16
|
+
----------
|
|
17
|
+
- :mod:`nestkit.calibration` - Post-hoc probability calibration and
|
|
18
|
+
calibration diagnostics.
|
|
19
|
+
- :mod:`nestkit.callbacks` - Fold-level monitoring (progress, logging,
|
|
20
|
+
checkpointing).
|
|
21
|
+
- :mod:`nestkit.comparison` - Statistical model comparison.
|
|
22
|
+
- :mod:`nestkit.diagnostics` - Hyperparameter stability analysis.
|
|
23
|
+
- :mod:`nestkit.importance` - Cross-fold feature importance aggregation.
|
|
24
|
+
- :mod:`nestkit.inner` - Inner CV tuning reports.
|
|
25
|
+
- :mod:`nestkit.plotting` - 25+ plotting functions for nested CV results.
|
|
26
|
+
- :mod:`nestkit.thresholding` - Decision-threshold optimization criteria.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
|
|
33
|
+
from nestkit.classifier import NestedCVClassifier
|
|
34
|
+
from nestkit.regressor import NestedCVRegressor
|
|
35
|
+
from nestkit.results.classifier_results import ClassifierResults
|
|
36
|
+
from nestkit.results.regressor_results import RegressorResults
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("nestkit")
|
|
41
|
+
logger.addHandler(logging.NullHandler())
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"ClassifierResults",
|
|
45
|
+
"NestedCVClassifier",
|
|
46
|
+
"NestedCVRegressor",
|
|
47
|
+
"RegressorResults",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
nestkit/_base.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Base class for nested cross-validation.
|
|
2
|
+
|
|
3
|
+
Provides the shared infrastructure for :class:`~nestkit.NestedCVClassifier`
|
|
4
|
+
and :class:`~nestkit.NestedCVRegressor`, including the outer-loop parallelism,
|
|
5
|
+
inner hyperparameter search dispatch, callback orchestration, and result
|
|
6
|
+
aggregation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from abc import ABCMeta, abstractmethod
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from joblib import Parallel, delayed
|
|
17
|
+
from sklearn.base import BaseEstimator, clone, is_classifier
|
|
18
|
+
from sklearn.model_selection import check_cv
|
|
19
|
+
from sklearn.utils.validation import check_X_y
|
|
20
|
+
|
|
21
|
+
from nestkit.inner.search import build_search
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("nestkit")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _BaseNestedCV(BaseEstimator, metaclass=ABCMeta):
|
|
27
|
+
"""Abstract base class for nested cross-validation estimators.
|
|
28
|
+
|
|
29
|
+
This class is not intended to be instantiated directly. Use
|
|
30
|
+
:class:`~nestkit.NestedCVClassifier` or :class:`~nestkit.NestedCVRegressor`
|
|
31
|
+
instead.
|
|
32
|
+
|
|
33
|
+
The nested CV procedure consists of four phases per outer fold:
|
|
34
|
+
|
|
35
|
+
1. **Inner CV search** - hyperparameter tuning via grid or randomized
|
|
36
|
+
search on the outer training set.
|
|
37
|
+
2. **Post-inner processing** - task-specific operations such as
|
|
38
|
+
probability calibration or prediction interval estimation
|
|
39
|
+
(implemented by subclasses).
|
|
40
|
+
3. **Refit** - the best hyperparameters are used to refit the estimator
|
|
41
|
+
on the full outer training set.
|
|
42
|
+
4. **Outer evaluation** - the refitted model is scored on the held-out
|
|
43
|
+
outer test fold.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
estimator : estimator object
|
|
48
|
+
A scikit-learn compatible estimator that implements ``fit``.
|
|
49
|
+
The estimator is cloned for each outer fold.
|
|
50
|
+
param_grid : dict or list of dict
|
|
51
|
+
Dictionary with parameter names (``str``) as keys and lists of
|
|
52
|
+
parameter settings to try as values, or a list of such
|
|
53
|
+
dictionaries. Passed directly to the inner search strategy.
|
|
54
|
+
search_strategy : {'grid', 'random', 'bayesian'}, default='grid'
|
|
55
|
+
Hyperparameter search strategy. ``'grid'`` uses
|
|
56
|
+
:class:`~sklearn.model_selection.GridSearchCV`; ``'random'`` uses
|
|
57
|
+
:class:`~sklearn.model_selection.RandomizedSearchCV`;
|
|
58
|
+
``'bayesian'`` uses :class:`skopt.BayesSearchCV` (requires
|
|
59
|
+
*scikit-optimize*).
|
|
60
|
+
outer_cv : int, cross-validation generator, or iterable, default=5
|
|
61
|
+
Determines the outer cross-validation splitting strategy.
|
|
62
|
+
See :func:`sklearn.model_selection.check_cv` for accepted formats.
|
|
63
|
+
inner_cv : int, cross-validation generator, or iterable, default=5
|
|
64
|
+
Determines the inner cross-validation splitting strategy used
|
|
65
|
+
for hyperparameter search.
|
|
66
|
+
scoring : str, callable, list, tuple, or dict, default=None
|
|
67
|
+
Scoring metric(s) for the inner search. If ``None``, the
|
|
68
|
+
estimator's default scorer is used. See
|
|
69
|
+
:func:`sklearn.metrics.get_scorer` for valid string values.
|
|
70
|
+
refit : bool or str, default=True
|
|
71
|
+
Whether to refit the best estimator on the full outer training
|
|
72
|
+
set. If a string, it must match one of the scoring keys when
|
|
73
|
+
multi-metric scoring is used.
|
|
74
|
+
return_train_score : bool, default=False
|
|
75
|
+
Whether to include training scores in the inner CV results.
|
|
76
|
+
return_estimator : bool, default=True
|
|
77
|
+
Whether to store the fitted estimator for each outer fold in
|
|
78
|
+
the results.
|
|
79
|
+
error_score : 'raise' or numeric, default='raise'
|
|
80
|
+
Value to assign to the score if an error occurs during inner CV
|
|
81
|
+
fitting. If ``'raise'``, the error is raised.
|
|
82
|
+
n_jobs_outer : int or None, default=None
|
|
83
|
+
Number of jobs for parallelizing the outer folds. ``None`` means
|
|
84
|
+
sequential execution; ``-1`` uses all processors.
|
|
85
|
+
n_jobs_inner : int or None, default=None
|
|
86
|
+
Number of jobs for parallelizing the inner search within each
|
|
87
|
+
outer fold.
|
|
88
|
+
verbose : int, default=0
|
|
89
|
+
Verbosity level. Values > 0 enable progressively more output
|
|
90
|
+
from the inner search.
|
|
91
|
+
random_state : int, RandomState instance, or None, default=None
|
|
92
|
+
Controls randomness in the inner search (e.g., for
|
|
93
|
+
``RandomizedSearchCV``). Pass an int for reproducible results.
|
|
94
|
+
callbacks : list of callback objects or None, default=None
|
|
95
|
+
List of :class:`~nestkit.FoldCallback` objects that are notified
|
|
96
|
+
at key points during the nested CV procedure.
|
|
97
|
+
pre_dispatch : int or str, default='2*n_jobs'
|
|
98
|
+
Controls the number of jobs dispatched during parallel outer
|
|
99
|
+
fold execution. See :class:`joblib.Parallel` for details.
|
|
100
|
+
|
|
101
|
+
Notes
|
|
102
|
+
-----
|
|
103
|
+
Setting ``n_jobs_outer > 1`` together with ``n_jobs_inner > 1`` may
|
|
104
|
+
cause thread oversubscription and degrade performance. As a rule of
|
|
105
|
+
thumb, parallelize at one level only.
|
|
106
|
+
|
|
107
|
+
See Also
|
|
108
|
+
--------
|
|
109
|
+
nestkit.NestedCVClassifier : Classification-specific nested CV.
|
|
110
|
+
nestkit.NestedCVRegressor : Regression-specific nested CV.
|
|
111
|
+
sklearn.model_selection.GridSearchCV : Inner search backend (grid).
|
|
112
|
+
sklearn.model_selection.RandomizedSearchCV : Inner search backend (random).
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
estimator,
|
|
118
|
+
param_grid,
|
|
119
|
+
*,
|
|
120
|
+
search_strategy="grid",
|
|
121
|
+
outer_cv=5,
|
|
122
|
+
inner_cv=5,
|
|
123
|
+
scoring=None,
|
|
124
|
+
refit=True,
|
|
125
|
+
return_train_score=False,
|
|
126
|
+
return_estimator=True,
|
|
127
|
+
error_score="raise",
|
|
128
|
+
n_jobs_outer=None,
|
|
129
|
+
n_jobs_inner=None,
|
|
130
|
+
verbose=0,
|
|
131
|
+
random_state=None,
|
|
132
|
+
callbacks=None,
|
|
133
|
+
pre_dispatch="2*n_jobs",
|
|
134
|
+
):
|
|
135
|
+
self.estimator = estimator
|
|
136
|
+
self.param_grid = param_grid
|
|
137
|
+
self.search_strategy = search_strategy
|
|
138
|
+
self.outer_cv = outer_cv
|
|
139
|
+
self.inner_cv = inner_cv
|
|
140
|
+
self.scoring = scoring
|
|
141
|
+
self.refit = refit
|
|
142
|
+
self.return_train_score = return_train_score
|
|
143
|
+
self.return_estimator = return_estimator
|
|
144
|
+
self.error_score = error_score
|
|
145
|
+
self.n_jobs_outer = n_jobs_outer
|
|
146
|
+
self.n_jobs_inner = n_jobs_inner
|
|
147
|
+
self.verbose = verbose
|
|
148
|
+
self.random_state = random_state
|
|
149
|
+
self.callbacks = callbacks
|
|
150
|
+
self.pre_dispatch = pre_dispatch
|
|
151
|
+
|
|
152
|
+
@abstractmethod
|
|
153
|
+
def _post_inner_processing(self, search, X_train, y_train, groups_train, **fit_params) -> dict:
|
|
154
|
+
"""Called after inner search. Returns task-specific artifacts."""
|
|
155
|
+
...
|
|
156
|
+
|
|
157
|
+
@abstractmethod
|
|
158
|
+
def _evaluate_outer_fold(self, estimator, X_test, y_test, post_inner_artifacts) -> dict:
|
|
159
|
+
"""Evaluate best estimator on outer test fold."""
|
|
160
|
+
...
|
|
161
|
+
|
|
162
|
+
@abstractmethod
|
|
163
|
+
def _build_results_container(self) -> Any:
|
|
164
|
+
"""Return the appropriate results container class."""
|
|
165
|
+
...
|
|
166
|
+
|
|
167
|
+
def fit(self, X, y, groups=None, **fit_params):
|
|
168
|
+
"""Run the full nested cross-validation procedure.
|
|
169
|
+
|
|
170
|
+
For each outer fold the method performs: (1) inner hyperparameter
|
|
171
|
+
search, (2) task-specific post-processing, (3) refit with the best
|
|
172
|
+
parameters on the full outer training set, and (4) evaluation on
|
|
173
|
+
the held-out outer test set.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
X : array-like of shape (n_samples, n_features)
|
|
178
|
+
Training data. If a pandas DataFrame is passed, feature names
|
|
179
|
+
and the original index are preserved in the results.
|
|
180
|
+
y : array-like of shape (n_samples,)
|
|
181
|
+
Target values.
|
|
182
|
+
groups : array-like of shape (n_samples,) or None, default=None
|
|
183
|
+
Group labels for the samples, used by group-aware CV
|
|
184
|
+
splitters (e.g., :class:`~sklearn.model_selection.GroupKFold`).
|
|
185
|
+
**fit_params : dict
|
|
186
|
+
Additional keyword arguments forwarded to the estimator's
|
|
187
|
+
``fit`` method in both the inner search and the final refit.
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
self
|
|
192
|
+
The fitted nested CV estimator. Results are accessible via
|
|
193
|
+
the :attr:`results_` attribute.
|
|
194
|
+
|
|
195
|
+
Raises
|
|
196
|
+
------
|
|
197
|
+
ValueError
|
|
198
|
+
If ``X`` and ``y`` have incompatible shapes.
|
|
199
|
+
"""
|
|
200
|
+
# DataFrame handling
|
|
201
|
+
if hasattr(X, "columns"):
|
|
202
|
+
self.feature_names_in_ = list(X.columns)
|
|
203
|
+
self._original_index = X.index.copy()
|
|
204
|
+
X = X.to_numpy()
|
|
205
|
+
else:
|
|
206
|
+
self.feature_names_in_ = [f"feature_{i}" for i in range(X.shape[1])]
|
|
207
|
+
self._original_index = None
|
|
208
|
+
|
|
209
|
+
X, y = check_X_y(X, y, multi_output=True, allow_nd=True)
|
|
210
|
+
|
|
211
|
+
outer_cv = check_cv(self.outer_cv, y, classifier=is_classifier(self.estimator))
|
|
212
|
+
splits = list(outer_cv.split(X, y, groups))
|
|
213
|
+
n_outer_folds = len(splits)
|
|
214
|
+
|
|
215
|
+
# Initialize results container
|
|
216
|
+
results_cls = self._build_results_container()
|
|
217
|
+
self.results_ = results_cls(
|
|
218
|
+
n_outer_folds=n_outer_folds,
|
|
219
|
+
feature_names=self.feature_names_in_,
|
|
220
|
+
original_index=self._original_index,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Outer loop
|
|
224
|
+
parallel = Parallel(
|
|
225
|
+
n_jobs=self.n_jobs_outer,
|
|
226
|
+
verbose=max(0, self.verbose - 1),
|
|
227
|
+
pre_dispatch=self.pre_dispatch,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
fold_results = parallel(
|
|
231
|
+
delayed(self._fit_outer_fold)(
|
|
232
|
+
clone(self.estimator),
|
|
233
|
+
X,
|
|
234
|
+
y,
|
|
235
|
+
train_idx,
|
|
236
|
+
test_idx,
|
|
237
|
+
fold_idx,
|
|
238
|
+
groups,
|
|
239
|
+
**fit_params,
|
|
240
|
+
)
|
|
241
|
+
for fold_idx, (train_idx, test_idx) in enumerate(splits)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Aggregate
|
|
245
|
+
for fr in fold_results:
|
|
246
|
+
self.results_.add_fold(fr)
|
|
247
|
+
self.results_.finalize()
|
|
248
|
+
|
|
249
|
+
# Callbacks
|
|
250
|
+
if self.callbacks:
|
|
251
|
+
for cb in self.callbacks:
|
|
252
|
+
if hasattr(cb, "on_nested_cv_complete"):
|
|
253
|
+
cb.on_nested_cv_complete(self.results_)
|
|
254
|
+
|
|
255
|
+
self.is_fitted_ = True
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
def _fit_outer_fold(
|
|
259
|
+
self, estimator, X, y, train_idx, test_idx, fold_idx, groups, **fit_params
|
|
260
|
+
):
|
|
261
|
+
"""Execute all four phases of a single outer fold.
|
|
262
|
+
|
|
263
|
+
Parameters
|
|
264
|
+
----------
|
|
265
|
+
estimator : estimator object
|
|
266
|
+
Cloned base estimator for this fold.
|
|
267
|
+
X : ndarray of shape (n_samples, n_features)
|
|
268
|
+
Full feature matrix.
|
|
269
|
+
y : ndarray of shape (n_samples,)
|
|
270
|
+
Full target vector.
|
|
271
|
+
train_idx : ndarray of shape (n_train,)
|
|
272
|
+
Indices of the outer training set.
|
|
273
|
+
test_idx : ndarray of shape (n_test,)
|
|
274
|
+
Indices of the outer test set.
|
|
275
|
+
fold_idx : int
|
|
276
|
+
Zero-based index of the current outer fold.
|
|
277
|
+
groups : ndarray or None
|
|
278
|
+
Group labels for the full dataset.
|
|
279
|
+
**fit_params : dict
|
|
280
|
+
Additional keyword arguments forwarded to ``fit``.
|
|
281
|
+
|
|
282
|
+
Returns
|
|
283
|
+
-------
|
|
284
|
+
fold_result : dataclass instance
|
|
285
|
+
Task-specific fold result (e.g.,
|
|
286
|
+
:class:`~nestkit.results.ClassifierOuterFoldResult`).
|
|
287
|
+
"""
|
|
288
|
+
logger.info("Outer fold %d: starting", fold_idx)
|
|
289
|
+
|
|
290
|
+
# Callbacks
|
|
291
|
+
if self.callbacks:
|
|
292
|
+
for cb in self.callbacks:
|
|
293
|
+
if hasattr(cb, "on_outer_fold_start"):
|
|
294
|
+
cb.on_outer_fold_start(fold_idx, train_idx, test_idx)
|
|
295
|
+
|
|
296
|
+
X_train, y_train = X[train_idx], y[train_idx]
|
|
297
|
+
X_test, y_test = X[test_idx], y[test_idx]
|
|
298
|
+
groups_train = groups[train_idx] if groups is not None else None
|
|
299
|
+
|
|
300
|
+
# Phase 1: Inner CV search
|
|
301
|
+
t_start = time.time()
|
|
302
|
+
search = build_search(
|
|
303
|
+
strategy=self.search_strategy,
|
|
304
|
+
estimator=estimator,
|
|
305
|
+
param_grid=self.param_grid,
|
|
306
|
+
cv=self.inner_cv,
|
|
307
|
+
scoring=self.scoring,
|
|
308
|
+
refit=self.refit,
|
|
309
|
+
n_jobs=self.n_jobs_inner,
|
|
310
|
+
return_train_score=self.return_train_score,
|
|
311
|
+
error_score=self.error_score,
|
|
312
|
+
verbose=self.verbose,
|
|
313
|
+
random_state=self.random_state,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
fit_kw = {}
|
|
317
|
+
if groups_train is not None:
|
|
318
|
+
fit_kw["groups"] = groups_train
|
|
319
|
+
search.fit(X_train, y_train, **fit_kw, **fit_params)
|
|
320
|
+
|
|
321
|
+
fit_time = time.time() - t_start
|
|
322
|
+
logger.info(
|
|
323
|
+
"Outer fold %d: inner search complete (%.1fs), best_score=%.4f",
|
|
324
|
+
fold_idx,
|
|
325
|
+
fit_time,
|
|
326
|
+
search.best_score_,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Callbacks
|
|
330
|
+
if self.callbacks:
|
|
331
|
+
for cb in self.callbacks:
|
|
332
|
+
if hasattr(cb, "on_inner_search_complete"):
|
|
333
|
+
cb.on_inner_search_complete(fold_idx, search)
|
|
334
|
+
|
|
335
|
+
# Post-inner processing (calibration, thresholding, etc.)
|
|
336
|
+
artifacts = self._post_inner_processing(
|
|
337
|
+
search, X_train, y_train, groups_train, **fit_params
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if self.callbacks:
|
|
341
|
+
for cb in self.callbacks:
|
|
342
|
+
if hasattr(cb, "on_post_processing_complete"):
|
|
343
|
+
cb.on_post_processing_complete(fold_idx, artifacts)
|
|
344
|
+
|
|
345
|
+
# Phase 4: Refit on full outer train
|
|
346
|
+
best_params = search.best_params_
|
|
347
|
+
final_estimator = clone(self.estimator).set_params(**best_params)
|
|
348
|
+
final_estimator.fit(X_train, y_train, **fit_params)
|
|
349
|
+
|
|
350
|
+
# Evaluate on outer test
|
|
351
|
+
t_score = time.time()
|
|
352
|
+
eval_result = self._evaluate_outer_fold(final_estimator, X_test, y_test, artifacts)
|
|
353
|
+
score_time = time.time() - t_score
|
|
354
|
+
|
|
355
|
+
# Build fold result
|
|
356
|
+
fold_result = self._build_fold_result(
|
|
357
|
+
fold_idx=fold_idx,
|
|
358
|
+
train_idx=train_idx,
|
|
359
|
+
test_idx=test_idx,
|
|
360
|
+
best_params=best_params,
|
|
361
|
+
best_inner_score=float(search.best_score_),
|
|
362
|
+
inner_cv_results=search.cv_results_,
|
|
363
|
+
fit_time=fit_time,
|
|
364
|
+
score_time=score_time,
|
|
365
|
+
estimator=final_estimator if self.return_estimator else None,
|
|
366
|
+
artifacts=artifacts,
|
|
367
|
+
eval_result=eval_result,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if self.callbacks:
|
|
371
|
+
for cb in self.callbacks:
|
|
372
|
+
if hasattr(cb, "on_outer_fold_complete"):
|
|
373
|
+
cb.on_outer_fold_complete(fold_idx, fold_result)
|
|
374
|
+
|
|
375
|
+
logger.info("Outer fold %d: complete", fold_idx)
|
|
376
|
+
return fold_result
|
|
377
|
+
|
|
378
|
+
@abstractmethod
|
|
379
|
+
def _build_fold_result(self, **kwargs) -> Any:
|
|
380
|
+
"""Build the task-specific fold result dataclass."""
|
|
381
|
+
...
|
|
382
|
+
|
|
383
|
+
# sklearn compatibility
|
|
384
|
+
|
|
385
|
+
def __sklearn_tags__(self):
|
|
386
|
+
try:
|
|
387
|
+
from sklearn.utils._tags import Tags, TargetTags
|
|
388
|
+
|
|
389
|
+
return Tags(
|
|
390
|
+
estimator_type=None,
|
|
391
|
+
target_tags=TargetTags(required=True),
|
|
392
|
+
no_validation=False,
|
|
393
|
+
)
|
|
394
|
+
except ImportError:
|
|
395
|
+
return {"estimator_type": None, "no_validation": False}
|
|
396
|
+
|
|
397
|
+
def __sklearn_is_fitted__(self) -> bool:
|
|
398
|
+
return hasattr(self, "is_fitted_") and self.is_fitted_
|
nestkit/_validation.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Input validation helpers for nestkit parameters.
|
|
2
|
+
|
|
3
|
+
These functions are used internally by :class:`~nestkit.NestedCVClassifier`
|
|
4
|
+
and :class:`~nestkit.NestedCVRegressor` to validate user-provided
|
|
5
|
+
configuration before the nested CV procedure begins.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_threshold_params(
|
|
16
|
+
threshold_strategy: str | None,
|
|
17
|
+
threshold_criterion: str | Any,
|
|
18
|
+
cost_matrix: Any | None,
|
|
19
|
+
min_recall: float | None,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Validate threshold optimization parameters.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
threshold_strategy : str or None
|
|
26
|
+
One of ``'fold_specific'``, ``'pooled'``, or ``None``.
|
|
27
|
+
threshold_criterion : str or callable
|
|
28
|
+
Threshold selection criterion name or callable.
|
|
29
|
+
cost_matrix : array-like or None
|
|
30
|
+
Cost matrix required for ``'cost'`` criterion.
|
|
31
|
+
min_recall : float or None
|
|
32
|
+
Minimum recall required for ``'precision_at_recall'`` criterion.
|
|
33
|
+
|
|
34
|
+
Raises
|
|
35
|
+
------
|
|
36
|
+
ValueError
|
|
37
|
+
If any parameter combination is invalid.
|
|
38
|
+
"""
|
|
39
|
+
valid_strategies = {None, "fold_specific", "pooled"}
|
|
40
|
+
if threshold_strategy not in valid_strategies:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"threshold_strategy must be one of {valid_strategies}, got '{threshold_strategy}'"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
valid_criteria = {"youden", "f_beta", "cost", "balanced_accuracy", "precision_at_recall"}
|
|
46
|
+
if isinstance(threshold_criterion, str) and threshold_criterion not in valid_criteria:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"threshold_criterion must be one of {valid_criteria} or callable, "
|
|
49
|
+
f"got '{threshold_criterion}'"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if threshold_criterion == "cost" and cost_matrix is None:
|
|
53
|
+
raise ValueError("cost_matrix is required when threshold_criterion='cost'")
|
|
54
|
+
|
|
55
|
+
if threshold_criterion == "precision_at_recall" and min_recall is None:
|
|
56
|
+
raise ValueError("min_recall is required when threshold_criterion='precision_at_recall'")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_calibration_method(method: str | None) -> None:
|
|
60
|
+
"""Validate the calibration method parameter.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
method : str or None
|
|
65
|
+
One of ``'sigmoid'``, ``'isotonic'``, ``'beta'``,
|
|
66
|
+
``'venn_abers'``, or ``None``.
|
|
67
|
+
|
|
68
|
+
Raises
|
|
69
|
+
------
|
|
70
|
+
ValueError
|
|
71
|
+
If ``method`` is not a recognized calibration method.
|
|
72
|
+
"""
|
|
73
|
+
valid = {None, "sigmoid", "isotonic", "beta", "venn_abers"}
|
|
74
|
+
if method not in valid:
|
|
75
|
+
raise ValueError(f"calibration_method must be one of {valid}, got '{method}'")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ensure_2d_proba(y_proba: np.ndarray) -> np.ndarray:
|
|
79
|
+
"""Ensure probability array is 2D ``(n_samples, n_classes)``.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
y_proba : ndarray of shape (n_samples,) or (n_samples, n_classes)
|
|
84
|
+
Probability predictions. If 1-D, interpreted as positive-class
|
|
85
|
+
probabilities for a binary problem.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
ndarray of shape (n_samples, 2) or (n_samples, n_classes)
|
|
90
|
+
Two-dimensional probability array.
|
|
91
|
+
"""
|
|
92
|
+
if y_proba.ndim == 1:
|
|
93
|
+
return np.column_stack([1 - y_proba, y_proba])
|
|
94
|
+
return y_proba
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract_positive_proba(y_proba: np.ndarray) -> np.ndarray:
|
|
98
|
+
"""Extract positive-class probabilities from a probability array.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
y_proba : ndarray of shape (n_samples,) or (n_samples, n_classes)
|
|
103
|
+
Probability predictions. If 2-D, the second column (index 1) is
|
|
104
|
+
returned.
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
ndarray of shape (n_samples,)
|
|
109
|
+
Positive-class probabilities.
|
|
110
|
+
"""
|
|
111
|
+
if y_proba.ndim == 2:
|
|
112
|
+
return y_proba[:, 1]
|
|
113
|
+
return y_proba
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Post-hoc probability calibration for classification models.
|
|
2
|
+
|
|
3
|
+
Provides :class:`PostHocCalibrator` supporting Platt scaling (sigmoid),
|
|
4
|
+
isotonic regression, beta calibration, and Venn-ABERS prediction, as well
|
|
5
|
+
as :class:`CalibrationDiagnostics` for evaluating calibration quality
|
|
6
|
+
via ECE, MCE, Brier score, and reliability diagrams.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from nestkit.calibration.calibrators import PostHocCalibrator
|
|
10
|
+
from nestkit.calibration.diagnostics import CalibrationDiagnostics
|
|
11
|
+
|
|
12
|
+
__all__ = ["CalibrationDiagnostics", "PostHocCalibrator"]
|