nestkit 0.2.0__tar.gz → 0.3.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.
- {nestkit-0.2.0/src/nestkit.egg-info → nestkit-0.3.0}/PKG-INFO +64 -11
- {nestkit-0.2.0 → nestkit-0.3.0}/README.md +55 -5
- {nestkit-0.2.0 → nestkit-0.3.0}/pyproject.toml +8 -4
- {nestkit-0.2.0 → nestkit-0.3.0}/requirements.txt +2 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/__init__.py +9 -1
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/_base.py +21 -4
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/_validation.py +55 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/calibration/calibrators.py +18 -1
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/classifier.py +24 -19
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/comparison/statistical_tests.py +11 -5
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/conformal/regressor_conformal.py +72 -46
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/diagnostics/stability.py +1 -1
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/importance/extractors.py +7 -1
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/importance/stability.py +14 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/__init__.py +4 -3
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/comparison.py +1 -1
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/regressor.py +3 -1
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/results/__init__.py +8 -4
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/results/_base.py +24 -4
- nestkit-0.3.0/src/nestkit/results/survival_results.py +123 -0
- nestkit-0.3.0/src/nestkit/survival/__init__.py +17 -0
- nestkit-0.3.0/src/nestkit/survival/_scoring.py +392 -0
- nestkit-0.3.0/src/nestkit/survival/_target.py +132 -0
- nestkit-0.3.0/src/nestkit/survival/_wrapper.py +224 -0
- nestkit-0.3.0/src/nestkit/survival/survival.py +475 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/thresholding/criteria.py +4 -4
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/thresholding/strategies.py +22 -3
- {nestkit-0.2.0 → nestkit-0.3.0/src/nestkit.egg-info}/PKG-INFO +64 -11
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit.egg-info/SOURCES.txt +6 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit.egg-info/requires.txt +9 -5
- {nestkit-0.2.0 → nestkit-0.3.0}/LICENSE +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/setup.cfg +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/_constants.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/calibration/__init__.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/calibration/diagnostics.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/callbacks.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/comparison/__init__.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/comparison/comparator.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/conformal/__init__.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/conformal/classifier_conformal.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/conformal/results.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/diagnostics/__init__.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/importance/__init__.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/importance/aggregator.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/inner/__init__.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/inner/search.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/inner/tuning_report.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/_style.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/calibration.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/folds.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/importance.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/summary.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/threshold.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/tuning.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/results/classifier_results.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/results/regressor_results.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/thresholding/__init__.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/thresholding/results.py +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit.egg-info/dependency_links.txt +0 -0
- {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nestkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Nested cross-validation toolkit with optional probability calibration, threshold optimization, and comprehensive diagnostics
|
|
5
5
|
Author: Ettore Rocchi
|
|
6
6
|
License: MIT
|
|
@@ -22,14 +22,16 @@ Requires-Dist: pandas>=1.4
|
|
|
22
22
|
Requires-Dist: joblib>=1.2
|
|
23
23
|
Requires-Dist: scipy>=1.9
|
|
24
24
|
Requires-Dist: jinja2>=3.0
|
|
25
|
-
|
|
26
|
-
Requires-Dist:
|
|
27
|
-
Requires-Dist: seaborn>=0.12; extra == "plotting"
|
|
25
|
+
Requires-Dist: matplotlib>=3.10
|
|
26
|
+
Requires-Dist: seaborn>=0.12
|
|
28
27
|
Provides-Extra: bayesian
|
|
29
28
|
Requires-Dist: scikit-optimize>=0.9; extra == "bayesian"
|
|
29
|
+
Provides-Extra: survival
|
|
30
|
+
Requires-Dist: lifelines>=0.27; extra == "survival"
|
|
31
|
+
Provides-Extra: explainability
|
|
32
|
+
Requires-Dist: shap>=0.42; extra == "explainability"
|
|
30
33
|
Provides-Extra: full
|
|
31
|
-
Requires-Dist: nestkit[bayesian,
|
|
32
|
-
Requires-Dist: shap>=0.42; extra == "full"
|
|
34
|
+
Requires-Dist: nestkit[bayesian,explainability,survival]; extra == "full"
|
|
33
35
|
Provides-Extra: dev
|
|
34
36
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
35
37
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -44,6 +46,7 @@ Requires-Dist: numpydoc; extra == "docs"
|
|
|
44
46
|
Requires-Dist: nbsphinx; extra == "docs"
|
|
45
47
|
Requires-Dist: ipykernel; extra == "docs"
|
|
46
48
|
Requires-Dist: sphinx-design; extra == "docs"
|
|
49
|
+
Requires-Dist: myst-parser; extra == "docs"
|
|
47
50
|
Dynamic: license-file
|
|
48
51
|
|
|
49
52
|
<p align="center">
|
|
@@ -73,6 +76,7 @@ Standard cross-validation conflates model selection with performance estimation,
|
|
|
73
76
|
## Key Features
|
|
74
77
|
|
|
75
78
|
- **Nested cross-validation** for classification and regression with full scikit-learn API compatibility
|
|
79
|
+
- **Missing values** - `X` may contain `NaN`; an imputer placed in a `Pipeline` is refitted inside every inner and outer fold, so imputation is leakage-free
|
|
76
80
|
- **Post-hoc probability calibration** - Platt scaling, isotonic regression, beta calibration, and Venn-ABERS prediction
|
|
77
81
|
- **Threshold optimization** - Youden's J, F-beta, cost-sensitive, balanced accuracy, and precision-at-recall criteria with pooled or fold-specific strategies
|
|
78
82
|
- **CV+ Mondrian conformal prediction** - class-conditional prediction sets (classification) and Mondrian-binned conditional prediction intervals (regression) with formal coverage guarantees
|
|
@@ -82,6 +86,7 @@ Standard cross-validation conflates model selection with performance estimation,
|
|
|
82
86
|
- **Callback system** - progress tracking, logging, checkpointing, and custom hooks
|
|
83
87
|
- **25+ plotting functions** - ROC curves, confusion matrices, calibration diagrams, threshold sensitivity, critical difference diagrams, and more
|
|
84
88
|
- **Prediction intervals** for regression - global or Mondrian-binned conformal intervals
|
|
89
|
+
- **Survival analysis** - nested CV for Cox PH models via lifelines with Harrell's and Uno's concordance indices, integrated Brier score, and coefficient stability analysis
|
|
85
90
|
|
|
86
91
|
## Installation
|
|
87
92
|
|
|
@@ -92,10 +97,12 @@ pip install nestkit
|
|
|
92
97
|
Optional dependency groups:
|
|
93
98
|
|
|
94
99
|
```bash
|
|
95
|
-
pip install nestkit[
|
|
96
|
-
pip install nestkit[
|
|
97
|
-
pip install nestkit[
|
|
98
|
-
pip install nestkit[
|
|
100
|
+
pip install nestkit[survival] # lifelines (survival analysis)
|
|
101
|
+
pip install nestkit[bayesian] # scikit-optimize (Bayesian search)
|
|
102
|
+
pip install nestkit[explainability] # SHAP
|
|
103
|
+
pip install nestkit[full] # all of the above
|
|
104
|
+
pip install nestkit[dev] # testing + linting
|
|
105
|
+
pip install nestkit[docs] # Sphinx documentation
|
|
99
106
|
```
|
|
100
107
|
|
|
101
108
|
## Quick Start
|
|
@@ -158,6 +165,50 @@ ncv.fit(X, y)
|
|
|
158
165
|
print(ncv.results_.conformal_report())
|
|
159
166
|
```
|
|
160
167
|
|
|
168
|
+
With missing values (the imputer is fitted within each fold):
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from sklearn.impute import SimpleImputer
|
|
172
|
+
from sklearn.pipeline import make_pipeline
|
|
173
|
+
|
|
174
|
+
ncv = NestedCVClassifier(
|
|
175
|
+
estimator=make_pipeline(
|
|
176
|
+
SimpleImputer(keep_empty_features=True),
|
|
177
|
+
RandomForestClassifier(random_state=42),
|
|
178
|
+
),
|
|
179
|
+
param_grid={"randomforestclassifier__max_depth": [3, 5, 10]},
|
|
180
|
+
outer_cv=5,
|
|
181
|
+
inner_cv=3,
|
|
182
|
+
random_state=42,
|
|
183
|
+
)
|
|
184
|
+
ncv.fit(X, y) # X may contain NaN
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Survival Analysis
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from nestkit import NestedCVSurvival
|
|
191
|
+
from nestkit.survival import CoxPHWrapper, make_survival_target
|
|
192
|
+
from lifelines.datasets import load_rossi
|
|
193
|
+
|
|
194
|
+
rossi = load_rossi()
|
|
195
|
+
X = rossi.drop(columns=["week", "arrest"])
|
|
196
|
+
y = make_survival_target(event=rossi["arrest"].values, duration=rossi["week"].values)
|
|
197
|
+
|
|
198
|
+
ncv = NestedCVSurvival(
|
|
199
|
+
estimator=CoxPHWrapper(),
|
|
200
|
+
param_grid={"penalizer": [0.001, 0.01, 0.1, 1.0], "l1_ratio": [0.0, 0.5, 1.0]},
|
|
201
|
+
outer_cv=5,
|
|
202
|
+
inner_cv=3,
|
|
203
|
+
random_state=42,
|
|
204
|
+
)
|
|
205
|
+
ncv.fit(X, y)
|
|
206
|
+
|
|
207
|
+
results = ncv.results_
|
|
208
|
+
print(results.summary_default_) # Harrell C-index, Uno C-index, IBS
|
|
209
|
+
print(results.coefficient_stability_) # hazard ratio stability across folds
|
|
210
|
+
```
|
|
211
|
+
|
|
161
212
|
### Regression
|
|
162
213
|
|
|
163
214
|
```python
|
|
@@ -197,7 +248,9 @@ nestkit's nested CV procedure executes four phases per outer fold:
|
|
|
197
248
|
|-------|---------|
|
|
198
249
|
| `NestedCVClassifier` | Classification with calibration, thresholding, and conformal prediction |
|
|
199
250
|
| `NestedCVRegressor` | Regression with prediction intervals and Mondrian binning |
|
|
200
|
-
| `
|
|
251
|
+
| `NestedCVSurvival` | Survival analysis with Cox PH models (requires lifelines) |
|
|
252
|
+
| `CoxPHWrapper` | sklearn-compatible wrapper for lifelines' `CoxPHFitter` |
|
|
253
|
+
| `ClassifierResults` / `RegressorResults` / `SurvivalResults` | Rich result containers |
|
|
201
254
|
| `NestedCVComparator` | Statistical model comparison |
|
|
202
255
|
| `FeatureImportanceAggregator` | Cross-fold importance analysis |
|
|
203
256
|
| `HyperparameterStability` | Selection consistency diagnostics |
|
|
@@ -25,6 +25,7 @@ Standard cross-validation conflates model selection with performance estimation,
|
|
|
25
25
|
## Key Features
|
|
26
26
|
|
|
27
27
|
- **Nested cross-validation** for classification and regression with full scikit-learn API compatibility
|
|
28
|
+
- **Missing values** - `X` may contain `NaN`; an imputer placed in a `Pipeline` is refitted inside every inner and outer fold, so imputation is leakage-free
|
|
28
29
|
- **Post-hoc probability calibration** - Platt scaling, isotonic regression, beta calibration, and Venn-ABERS prediction
|
|
29
30
|
- **Threshold optimization** - Youden's J, F-beta, cost-sensitive, balanced accuracy, and precision-at-recall criteria with pooled or fold-specific strategies
|
|
30
31
|
- **CV+ Mondrian conformal prediction** - class-conditional prediction sets (classification) and Mondrian-binned conditional prediction intervals (regression) with formal coverage guarantees
|
|
@@ -34,6 +35,7 @@ Standard cross-validation conflates model selection with performance estimation,
|
|
|
34
35
|
- **Callback system** - progress tracking, logging, checkpointing, and custom hooks
|
|
35
36
|
- **25+ plotting functions** - ROC curves, confusion matrices, calibration diagrams, threshold sensitivity, critical difference diagrams, and more
|
|
36
37
|
- **Prediction intervals** for regression - global or Mondrian-binned conformal intervals
|
|
38
|
+
- **Survival analysis** - nested CV for Cox PH models via lifelines with Harrell's and Uno's concordance indices, integrated Brier score, and coefficient stability analysis
|
|
37
39
|
|
|
38
40
|
## Installation
|
|
39
41
|
|
|
@@ -44,10 +46,12 @@ pip install nestkit
|
|
|
44
46
|
Optional dependency groups:
|
|
45
47
|
|
|
46
48
|
```bash
|
|
47
|
-
pip install nestkit[
|
|
48
|
-
pip install nestkit[
|
|
49
|
-
pip install nestkit[
|
|
50
|
-
pip install nestkit[
|
|
49
|
+
pip install nestkit[survival] # lifelines (survival analysis)
|
|
50
|
+
pip install nestkit[bayesian] # scikit-optimize (Bayesian search)
|
|
51
|
+
pip install nestkit[explainability] # SHAP
|
|
52
|
+
pip install nestkit[full] # all of the above
|
|
53
|
+
pip install nestkit[dev] # testing + linting
|
|
54
|
+
pip install nestkit[docs] # Sphinx documentation
|
|
51
55
|
```
|
|
52
56
|
|
|
53
57
|
## Quick Start
|
|
@@ -110,6 +114,50 @@ ncv.fit(X, y)
|
|
|
110
114
|
print(ncv.results_.conformal_report())
|
|
111
115
|
```
|
|
112
116
|
|
|
117
|
+
With missing values (the imputer is fitted within each fold):
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from sklearn.impute import SimpleImputer
|
|
121
|
+
from sklearn.pipeline import make_pipeline
|
|
122
|
+
|
|
123
|
+
ncv = NestedCVClassifier(
|
|
124
|
+
estimator=make_pipeline(
|
|
125
|
+
SimpleImputer(keep_empty_features=True),
|
|
126
|
+
RandomForestClassifier(random_state=42),
|
|
127
|
+
),
|
|
128
|
+
param_grid={"randomforestclassifier__max_depth": [3, 5, 10]},
|
|
129
|
+
outer_cv=5,
|
|
130
|
+
inner_cv=3,
|
|
131
|
+
random_state=42,
|
|
132
|
+
)
|
|
133
|
+
ncv.fit(X, y) # X may contain NaN
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Survival Analysis
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from nestkit import NestedCVSurvival
|
|
140
|
+
from nestkit.survival import CoxPHWrapper, make_survival_target
|
|
141
|
+
from lifelines.datasets import load_rossi
|
|
142
|
+
|
|
143
|
+
rossi = load_rossi()
|
|
144
|
+
X = rossi.drop(columns=["week", "arrest"])
|
|
145
|
+
y = make_survival_target(event=rossi["arrest"].values, duration=rossi["week"].values)
|
|
146
|
+
|
|
147
|
+
ncv = NestedCVSurvival(
|
|
148
|
+
estimator=CoxPHWrapper(),
|
|
149
|
+
param_grid={"penalizer": [0.001, 0.01, 0.1, 1.0], "l1_ratio": [0.0, 0.5, 1.0]},
|
|
150
|
+
outer_cv=5,
|
|
151
|
+
inner_cv=3,
|
|
152
|
+
random_state=42,
|
|
153
|
+
)
|
|
154
|
+
ncv.fit(X, y)
|
|
155
|
+
|
|
156
|
+
results = ncv.results_
|
|
157
|
+
print(results.summary_default_) # Harrell C-index, Uno C-index, IBS
|
|
158
|
+
print(results.coefficient_stability_) # hazard ratio stability across folds
|
|
159
|
+
```
|
|
160
|
+
|
|
113
161
|
### Regression
|
|
114
162
|
|
|
115
163
|
```python
|
|
@@ -149,7 +197,9 @@ nestkit's nested CV procedure executes four phases per outer fold:
|
|
|
149
197
|
|-------|---------|
|
|
150
198
|
| `NestedCVClassifier` | Classification with calibration, thresholding, and conformal prediction |
|
|
151
199
|
| `NestedCVRegressor` | Regression with prediction intervals and Mondrian binning |
|
|
152
|
-
| `
|
|
200
|
+
| `NestedCVSurvival` | Survival analysis with Cox PH models (requires lifelines) |
|
|
201
|
+
| `CoxPHWrapper` | sklearn-compatible wrapper for lifelines' `CoxPHFitter` |
|
|
202
|
+
| `ClassifierResults` / `RegressorResults` / `SurvivalResults` | Rich result containers |
|
|
153
203
|
| `NestedCVComparator` | Statistical model comparison |
|
|
154
204
|
| `FeatureImportanceAggregator` | Cross-fold importance analysis |
|
|
155
205
|
| `HyperparameterStability` | Selection consistency diagnostics |
|
|
@@ -23,9 +23,10 @@ classifiers = [
|
|
|
23
23
|
]
|
|
24
24
|
|
|
25
25
|
[project.optional-dependencies]
|
|
26
|
-
plotting = ["matplotlib>=3.5", "seaborn>=0.12"]
|
|
27
26
|
bayesian = ["scikit-optimize>=0.9"]
|
|
28
|
-
|
|
27
|
+
survival = ["lifelines>=0.27"]
|
|
28
|
+
explainability = ["shap>=0.42"]
|
|
29
|
+
full = ["nestkit[bayesian,survival,explainability]"]
|
|
29
30
|
dev = [
|
|
30
31
|
"pytest>=7",
|
|
31
32
|
"pytest-cov",
|
|
@@ -41,6 +42,7 @@ docs = [
|
|
|
41
42
|
"nbsphinx",
|
|
42
43
|
"ipykernel",
|
|
43
44
|
"sphinx-design",
|
|
45
|
+
"myst-parser",
|
|
44
46
|
]
|
|
45
47
|
|
|
46
48
|
[tool.setuptools.dynamic]
|
|
@@ -76,8 +78,10 @@ ignore = [
|
|
|
76
78
|
known-first-party = ["nestkit"]
|
|
77
79
|
|
|
78
80
|
[tool.pytest.ini_options]
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
# src is included so that --doctest-modules actually runs the docstring
|
|
82
|
+
# examples; without it pytest would only collect from tests/.
|
|
83
|
+
testpaths = ["tests", "src"]
|
|
84
|
+
addopts = "-ra -q --doctest-modules"
|
|
81
85
|
markers = [
|
|
82
86
|
"slow: end-to-end tests that fit NestedCV estimators (deselect with '-m \"not slow\"')",
|
|
83
87
|
]
|
|
@@ -26,6 +26,8 @@ Submodules
|
|
|
26
26
|
- :mod:`nestkit.conformal` - CV+ Mondrian conformal prediction sets and
|
|
27
27
|
intervals.
|
|
28
28
|
- :mod:`nestkit.thresholding` - Decision-threshold optimization criteria.
|
|
29
|
+
- :mod:`nestkit.survival` - Survival analysis with Cox PH models
|
|
30
|
+
(requires lifelines).
|
|
29
31
|
"""
|
|
30
32
|
|
|
31
33
|
from __future__ import annotations
|
|
@@ -36,16 +38,22 @@ from nestkit.classifier import NestedCVClassifier
|
|
|
36
38
|
from nestkit.regressor import NestedCVRegressor
|
|
37
39
|
from nestkit.results.classifier_results import ClassifierResults
|
|
38
40
|
from nestkit.results.regressor_results import RegressorResults
|
|
41
|
+
from nestkit.results.survival_results import SurvivalResults
|
|
42
|
+
from nestkit.survival import CoxPHWrapper, NestedCVSurvival, make_survival_target
|
|
39
43
|
|
|
40
|
-
__version__ = "0.
|
|
44
|
+
__version__ = "0.3.0"
|
|
41
45
|
|
|
42
46
|
logger = logging.getLogger("nestkit")
|
|
43
47
|
logger.addHandler(logging.NullHandler())
|
|
44
48
|
|
|
45
49
|
__all__ = [
|
|
46
50
|
"ClassifierResults",
|
|
51
|
+
"CoxPHWrapper",
|
|
47
52
|
"NestedCVClassifier",
|
|
48
53
|
"NestedCVRegressor",
|
|
54
|
+
"NestedCVSurvival",
|
|
49
55
|
"RegressorResults",
|
|
56
|
+
"SurvivalResults",
|
|
50
57
|
"__version__",
|
|
58
|
+
"make_survival_target",
|
|
51
59
|
]
|
|
@@ -8,6 +8,7 @@ aggregation.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import inspect
|
|
11
12
|
import logging
|
|
12
13
|
import time
|
|
13
14
|
from abc import ABCMeta, abstractmethod
|
|
@@ -22,6 +23,13 @@ from nestkit.inner.search import build_search
|
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger("nestkit")
|
|
24
25
|
|
|
26
|
+
# ``force_all_finite`` was renamed to ``ensure_all_finite`` in scikit-learn 1.6
|
|
27
|
+
_ALLOW_NAN = (
|
|
28
|
+
{"ensure_all_finite": "allow-nan"}
|
|
29
|
+
if "ensure_all_finite" in inspect.signature(check_X_y).parameters
|
|
30
|
+
else {"force_all_finite": "allow-nan"}
|
|
31
|
+
)
|
|
32
|
+
|
|
25
33
|
|
|
26
34
|
class _BaseNestedCV(BaseEstimator, metaclass=ABCMeta):
|
|
27
35
|
"""Abstract base class for nested cross-validation estimators.
|
|
@@ -176,7 +184,12 @@ class _BaseNestedCV(BaseEstimator, metaclass=ABCMeta):
|
|
|
176
184
|
----------
|
|
177
185
|
X : array-like of shape (n_samples, n_features)
|
|
178
186
|
Training data. If a pandas DataFrame is passed, feature names
|
|
179
|
-
and the original index are preserved in the results.
|
|
187
|
+
and the original index are preserved in the results. Missing
|
|
188
|
+
values encoded as ``NaN`` (or ``pd.NA``) are allowed, so that
|
|
189
|
+
imputation can be fitted inside each fold by making the
|
|
190
|
+
imputer the first step of a
|
|
191
|
+
:class:`~sklearn.pipeline.Pipeline` estimator. Infinite
|
|
192
|
+
values are rejected.
|
|
180
193
|
y : array-like of shape (n_samples,)
|
|
181
194
|
Target values.
|
|
182
195
|
groups : array-like of shape (n_samples,) or None, default=None
|
|
@@ -195,18 +208,22 @@ class _BaseNestedCV(BaseEstimator, metaclass=ABCMeta):
|
|
|
195
208
|
Raises
|
|
196
209
|
------
|
|
197
210
|
ValueError
|
|
198
|
-
If ``X`` and ``y`` have incompatible shapes
|
|
211
|
+
If ``X`` and ``y`` have incompatible shapes, if ``X`` contains
|
|
212
|
+
infinite values, or if ``y`` contains NaN or infinite values.
|
|
199
213
|
"""
|
|
200
214
|
# DataFrame handling
|
|
201
215
|
if hasattr(X, "columns"):
|
|
202
216
|
self.feature_names_in_ = list(X.columns)
|
|
203
217
|
self._original_index = X.index.copy()
|
|
204
|
-
X = X.to_numpy()
|
|
205
218
|
else:
|
|
206
219
|
self.feature_names_in_ = [f"feature_{i}" for i in range(X.shape[1])]
|
|
207
220
|
self._original_index = None
|
|
208
221
|
|
|
209
|
-
|
|
222
|
+
# NaN is allowed in X so that imputation can be fitted inside the
|
|
223
|
+
# folds (e.g. as the first step of a Pipeline); inf and NaN in y are
|
|
224
|
+
# still rejected. DataFrames are converted here so that pandas
|
|
225
|
+
# nullable dtypes (pd.NA) become NaN.
|
|
226
|
+
X, y = check_X_y(X, y, multi_output=True, allow_nd=True, **_ALLOW_NAN)
|
|
210
227
|
|
|
211
228
|
outer_cv = check_cv(self.outer_cv, y, classifier=is_classifier(self.estimator))
|
|
212
229
|
splits = list(outer_cv.split(X, y, groups))
|
|
@@ -123,6 +123,61 @@ def validate_mondrian_params(
|
|
|
123
123
|
)
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
def validate_survival_target(y: np.ndarray) -> None:
|
|
127
|
+
"""Validate a normalized survival target array.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
y : ndarray of shape (n_samples, 2)
|
|
132
|
+
Survival target with columns ``[event, duration]``.
|
|
133
|
+
Must already be normalized via
|
|
134
|
+
:func:`~nestkit.survival._target._normalize_survival_target`.
|
|
135
|
+
|
|
136
|
+
Raises
|
|
137
|
+
------
|
|
138
|
+
ValueError
|
|
139
|
+
If the target does not have the expected shape or content.
|
|
140
|
+
|
|
141
|
+
Warns
|
|
142
|
+
-----
|
|
143
|
+
UserWarning
|
|
144
|
+
If the censoring rate is extremely high (>95%) or
|
|
145
|
+
extremely low (<5%).
|
|
146
|
+
"""
|
|
147
|
+
import warnings
|
|
148
|
+
|
|
149
|
+
if y.ndim != 2 or y.shape[1] != 2:
|
|
150
|
+
raise ValueError(f"Survival target must have shape (n_samples, 2), got {y.shape}")
|
|
151
|
+
|
|
152
|
+
event = y[:, 0]
|
|
153
|
+
duration = y[:, 1]
|
|
154
|
+
|
|
155
|
+
unique_events = np.unique(event)
|
|
156
|
+
if not np.all(np.isin(unique_events, [0.0, 1.0])):
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"Event column must be binary (0 or 1), got unique values {unique_events}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if np.any(duration <= 0):
|
|
162
|
+
raise ValueError("Duration column must contain only positive values")
|
|
163
|
+
|
|
164
|
+
censoring_rate = 1.0 - np.mean(event)
|
|
165
|
+
if censoring_rate > 0.95:
|
|
166
|
+
warnings.warn(
|
|
167
|
+
f"Very high censoring rate ({censoring_rate:.1%}). "
|
|
168
|
+
"Model performance and metric reliability may be poor.",
|
|
169
|
+
UserWarning,
|
|
170
|
+
stacklevel=2,
|
|
171
|
+
)
|
|
172
|
+
elif censoring_rate < 0.05:
|
|
173
|
+
warnings.warn(
|
|
174
|
+
f"Very low censoring rate ({censoring_rate:.1%}). "
|
|
175
|
+
"Consider whether a standard regression model might be more appropriate.",
|
|
176
|
+
UserWarning,
|
|
177
|
+
stacklevel=2,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
126
181
|
def ensure_2d_proba(y_proba: np.ndarray) -> np.ndarray:
|
|
127
182
|
"""Ensure probability array is 2D ``(n_samples, n_classes)``.
|
|
128
183
|
|
|
@@ -42,9 +42,26 @@ class PostHocCalibrator:
|
|
|
42
42
|
y_proba : array of shape (n_samples,) or (n_samples, 2)
|
|
43
43
|
Uncalibrated predicted probabilities.
|
|
44
44
|
y_true : array of shape (n_samples,)
|
|
45
|
-
True binary labels.
|
|
45
|
+
True binary labels, encoded as 0 (negative) and 1 (positive).
|
|
46
|
+
|
|
47
|
+
Raises
|
|
48
|
+
------
|
|
49
|
+
ValueError
|
|
50
|
+
If *y_true* holds labels other than 0 and 1. The isotonic and
|
|
51
|
+
Venn-ABERS fits regress directly onto the label values, so any
|
|
52
|
+
other encoding silently yields probabilities outside ``[0, 1]``
|
|
53
|
+
that are then clipped (e.g. labels ``{1, 2}`` map every sample
|
|
54
|
+
to 1.0).
|
|
46
55
|
"""
|
|
47
56
|
p = extract_positive_proba(y_proba)
|
|
57
|
+
y_true = np.asarray(y_true)
|
|
58
|
+
observed = np.unique(y_true)
|
|
59
|
+
if not np.all(np.isin(observed, [0, 1])):
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"y_true must be encoded as 0 (negative) and 1 (positive), "
|
|
62
|
+
f"got labels {observed.tolist()}. Encode the target first, "
|
|
63
|
+
"e.g. with sklearn.preprocessing.LabelEncoder."
|
|
64
|
+
)
|
|
48
65
|
|
|
49
66
|
if self.method == "sigmoid":
|
|
50
67
|
self._fit_sigmoid(p, y_true)
|
|
@@ -253,7 +253,9 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
253
253
|
Parameters
|
|
254
254
|
----------
|
|
255
255
|
X : array-like of shape (n_samples, n_features)
|
|
256
|
-
Training data.
|
|
256
|
+
Training data. May contain ``NaN`` if the estimator handles
|
|
257
|
+
them, e.g. a Pipeline starting with an imputer (see
|
|
258
|
+
:ref:`missing-values`).
|
|
257
259
|
y : array-like of shape (n_samples,)
|
|
258
260
|
Target labels.
|
|
259
261
|
groups : array-like of shape (n_samples,) or None, default=None
|
|
@@ -281,20 +283,25 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
281
283
|
self.min_recall,
|
|
282
284
|
)
|
|
283
285
|
validate_conformal_params(self.conformal_prediction, self.conformal_alpha)
|
|
284
|
-
|
|
286
|
+
|
|
287
|
+
self.classes_, y_encoded = np.unique(y, return_inverse=True)
|
|
285
288
|
self.n_classes_ = len(self.classes_)
|
|
286
|
-
return super().fit(X,
|
|
289
|
+
return super().fit(X, np.asarray(y_encoded).ravel(), groups=groups, **fit_params)
|
|
290
|
+
|
|
291
|
+
def _decode(self, y_encoded) -> np.ndarray:
|
|
292
|
+
"""Map encoded labels (0..n_classes-1) back to the original labels."""
|
|
293
|
+
return self.classes_[np.asarray(y_encoded).astype(int)]
|
|
287
294
|
|
|
288
295
|
def _build_results_container(self) -> type:
|
|
289
296
|
return ClassifierResults
|
|
290
297
|
|
|
291
298
|
def _post_inner_processing(self, search, X_train, y_train, groups_train, **fit_params) -> dict:
|
|
292
|
-
"""
|
|
299
|
+
"""Calibration and threshold optimization.
|
|
293
300
|
|
|
294
301
|
Note: The OOF loop uses ``search.best_params_`` which were selected
|
|
295
302
|
using all of ``X_train``. The OOF validation folds therefore
|
|
296
303
|
influenced hyperparameter selection. This is a widely accepted
|
|
297
|
-
approximation
|
|
304
|
+
approximation - the alternative (triple-nested CV) is
|
|
298
305
|
computationally prohibitive for most practical use cases.
|
|
299
306
|
"""
|
|
300
307
|
artifacts: dict[str, Any] = {
|
|
@@ -318,7 +325,6 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
318
325
|
):
|
|
319
326
|
return artifacts
|
|
320
327
|
|
|
321
|
-
# Slow path: collect inner OOF predictions (always refit)
|
|
322
328
|
cal_cv = check_cv(self.calibration_cv or self.inner_cv, y_train, classifier=True)
|
|
323
329
|
best_params = search.best_params_
|
|
324
330
|
base_estimator = clone(self.estimator).set_params(**best_params)
|
|
@@ -340,7 +346,6 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
340
346
|
n_classes = oof_probas_all.shape[1] if oof_probas_all.ndim == 2 else 2
|
|
341
347
|
is_binary = n_classes == 2
|
|
342
348
|
|
|
343
|
-
# --- Phase 2: Calibration ---
|
|
344
349
|
if self.calibration_method is not None:
|
|
345
350
|
if is_binary:
|
|
346
351
|
calibrator = PostHocCalibrator(method=self.calibration_method)
|
|
@@ -357,7 +362,7 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
357
362
|
cal_probas_per_fold = [np.zeros_like(p) for p in oof_probas]
|
|
358
363
|
|
|
359
364
|
for c in range(n_classes):
|
|
360
|
-
y_binary = (oof_y_all ==
|
|
365
|
+
y_binary = (oof_y_all == c).astype(int)
|
|
361
366
|
p_c = oof_probas_all[:, c]
|
|
362
367
|
cal_c = PostHocCalibrator(method=self.calibration_method)
|
|
363
368
|
cal_c.fit(p_c, y_binary)
|
|
@@ -382,7 +387,6 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
382
387
|
|
|
383
388
|
artifacts["oof_probas_calibrated"] = cal_probas_all
|
|
384
389
|
|
|
385
|
-
# --- Phase 3: Threshold optimization ---
|
|
386
390
|
if self.threshold_strategy is not None:
|
|
387
391
|
criterion_fn = self._resolve_criterion()
|
|
388
392
|
criterion_name = (
|
|
@@ -407,7 +411,7 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
407
411
|
# Multiclass OVR: apply threshold strategy per class
|
|
408
412
|
thresholds_ovr = []
|
|
409
413
|
for c in range(n_classes):
|
|
410
|
-
y_binary_per_fold = [(y ==
|
|
414
|
+
y_binary_per_fold = [(y == c).astype(int) for y in oof_y_true]
|
|
411
415
|
p_c_per_fold = [p[:, c] for p in cal_probas_per_fold]
|
|
412
416
|
if self.threshold_strategy == "fold_specific":
|
|
413
417
|
tr_c = FoldSpecificThreshold.optimize(
|
|
@@ -420,14 +424,13 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
420
424
|
thresholds_ovr.append(tr_c.optimal_threshold)
|
|
421
425
|
artifacts["optimal_thresholds_ovr"] = np.array(thresholds_ovr)
|
|
422
426
|
|
|
423
|
-
# --- Phase 2c: Conformal prediction ---
|
|
424
427
|
if self.conformal_prediction:
|
|
425
428
|
from nestkit.conformal.classifier_conformal import MondrianClassifierConformal
|
|
426
429
|
|
|
427
430
|
conformal_result = MondrianClassifierConformal.fit(
|
|
428
431
|
oof_probas=cal_probas_all,
|
|
429
432
|
oof_y_true=oof_y_all,
|
|
430
|
-
classes=self.
|
|
433
|
+
classes=np.arange(self.n_classes_),
|
|
431
434
|
alpha=self.conformal_alpha,
|
|
432
435
|
)
|
|
433
436
|
artifacts["conformal_result"] = conformal_result
|
|
@@ -448,7 +451,7 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
448
451
|
effective_proba = extract_positive_proba(cal_proba)
|
|
449
452
|
y_pred_default = (effective_proba >= 0.5).astype(int)
|
|
450
453
|
else:
|
|
451
|
-
y_pred_default =
|
|
454
|
+
y_pred_default = np.argmax(cal_proba, axis=1)
|
|
452
455
|
|
|
453
456
|
scores_default = self._compute_metrics(y_test, y_pred_default, cal_proba, is_binary)
|
|
454
457
|
cm_default = confusion_matrix(y_test, y_pred_default)
|
|
@@ -457,11 +460,13 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
457
460
|
artifacts["calibrator"] is not None or artifacts.get("calibrators_ovr") is not None
|
|
458
461
|
)
|
|
459
462
|
|
|
463
|
+
y_true_labels = self._decode(y_test)
|
|
464
|
+
|
|
460
465
|
result = {
|
|
461
|
-
"y_true":
|
|
466
|
+
"y_true": y_true_labels,
|
|
462
467
|
"y_proba_raw": raw_proba,
|
|
463
468
|
"y_proba_calibrated": cal_proba if has_calibration else None,
|
|
464
|
-
"y_pred_default": y_pred_default,
|
|
469
|
+
"y_pred_default": self._decode(y_pred_default),
|
|
465
470
|
"scores_default": scores_default,
|
|
466
471
|
"confusion_matrix_default": cm_default,
|
|
467
472
|
"y_pred_optimized": None,
|
|
@@ -502,8 +507,8 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
502
507
|
np.argmax(above, axis=1),
|
|
503
508
|
np.argmax(cal_proba, axis=1),
|
|
504
509
|
)
|
|
505
|
-
y_pred_opt =
|
|
506
|
-
result["y_pred_optimized"] = y_pred_opt
|
|
510
|
+
y_pred_opt = idx_opt
|
|
511
|
+
result["y_pred_optimized"] = self._decode(y_pred_opt)
|
|
507
512
|
result["scores_optimized"] = self._compute_metrics(
|
|
508
513
|
y_test, y_pred_opt, cal_proba, is_binary
|
|
509
514
|
)
|
|
@@ -523,8 +528,8 @@ class NestedCVClassifier(_BaseNestedCV):
|
|
|
523
528
|
result["conformal_coverage"] = float(
|
|
524
529
|
np.mean(
|
|
525
530
|
[
|
|
526
|
-
|
|
527
|
-
for i in range(len(
|
|
531
|
+
y_true_labels[i] in conformal_output["prediction_sets"][i]
|
|
532
|
+
for i in range(len(y_true_labels))
|
|
528
533
|
]
|
|
529
534
|
)
|
|
530
535
|
)
|
|
@@ -95,17 +95,18 @@ def nadeau_bengio_corrected_ttest(
|
|
|
95
95
|
"""
|
|
96
96
|
diffs = scores_a - scores_b
|
|
97
97
|
n = len(diffs)
|
|
98
|
+
if n < 2:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"The corrected t-test needs at least 2 folds to estimate a variance, got {n}."
|
|
101
|
+
)
|
|
98
102
|
mean_diff = float(np.mean(diffs))
|
|
99
103
|
var_diff = float(np.var(diffs, ddof=1))
|
|
100
104
|
|
|
101
|
-
# Nadeau-Bengio correction
|
|
102
105
|
correction = (1.0 / n) + (n_test / n_train)
|
|
103
106
|
corrected_var = correction * var_diff
|
|
104
107
|
corrected_std = float(np.sqrt(corrected_var))
|
|
105
108
|
|
|
106
109
|
if corrected_std == 0:
|
|
107
|
-
# When all fold differences are identical: if nonzero, evidence
|
|
108
|
-
# against H_0 is maximal; if zero, no difference exists.
|
|
109
110
|
if mean_diff != 0:
|
|
110
111
|
return {
|
|
111
112
|
"t_statistic": float(np.sign(mean_diff) * np.inf),
|
|
@@ -235,10 +236,15 @@ def bayesian_correlated_ttest(
|
|
|
235
236
|
"""
|
|
236
237
|
diffs = scores_a - scores_b
|
|
237
238
|
n = len(diffs)
|
|
239
|
+
if n < 2:
|
|
240
|
+
raise ValueError(
|
|
241
|
+
f"The Bayesian correlated t-test needs at least 2 folds to estimate a "
|
|
242
|
+
f"variance, got {n}."
|
|
243
|
+
)
|
|
238
244
|
mean_diff = float(np.mean(diffs))
|
|
239
245
|
std_diff = float(np.std(diffs, ddof=1))
|
|
240
246
|
|
|
241
|
-
if std_diff == 0
|
|
247
|
+
if std_diff == 0:
|
|
242
248
|
if abs(mean_diff) <= rope:
|
|
243
249
|
return {
|
|
244
250
|
"p_a_better": 0.0,
|
|
@@ -323,7 +329,7 @@ def holm_bonferroni_correction(p_values: list[float]) -> list[float]:
|
|
|
323
329
|
Examples
|
|
324
330
|
--------
|
|
325
331
|
>>> holm_bonferroni_correction([0.01, 0.04, 0.03])
|
|
326
|
-
[0.03, 0.
|
|
332
|
+
[0.03, 0.06, 0.06]
|
|
327
333
|
"""
|
|
328
334
|
n = len(p_values)
|
|
329
335
|
sorted_indices = np.argsort(p_values)
|