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.
Files changed (60) hide show
  1. {nestkit-0.2.0/src/nestkit.egg-info → nestkit-0.3.0}/PKG-INFO +64 -11
  2. {nestkit-0.2.0 → nestkit-0.3.0}/README.md +55 -5
  3. {nestkit-0.2.0 → nestkit-0.3.0}/pyproject.toml +8 -4
  4. {nestkit-0.2.0 → nestkit-0.3.0}/requirements.txt +2 -0
  5. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/__init__.py +9 -1
  6. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/_base.py +21 -4
  7. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/_validation.py +55 -0
  8. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/calibration/calibrators.py +18 -1
  9. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/classifier.py +24 -19
  10. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/comparison/statistical_tests.py +11 -5
  11. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/conformal/regressor_conformal.py +72 -46
  12. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/diagnostics/stability.py +1 -1
  13. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/importance/extractors.py +7 -1
  14. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/importance/stability.py +14 -0
  15. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/__init__.py +4 -3
  16. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/comparison.py +1 -1
  17. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/regressor.py +3 -1
  18. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/results/__init__.py +8 -4
  19. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/results/_base.py +24 -4
  20. nestkit-0.3.0/src/nestkit/results/survival_results.py +123 -0
  21. nestkit-0.3.0/src/nestkit/survival/__init__.py +17 -0
  22. nestkit-0.3.0/src/nestkit/survival/_scoring.py +392 -0
  23. nestkit-0.3.0/src/nestkit/survival/_target.py +132 -0
  24. nestkit-0.3.0/src/nestkit/survival/_wrapper.py +224 -0
  25. nestkit-0.3.0/src/nestkit/survival/survival.py +475 -0
  26. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/thresholding/criteria.py +4 -4
  27. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/thresholding/strategies.py +22 -3
  28. {nestkit-0.2.0 → nestkit-0.3.0/src/nestkit.egg-info}/PKG-INFO +64 -11
  29. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit.egg-info/SOURCES.txt +6 -0
  30. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit.egg-info/requires.txt +9 -5
  31. {nestkit-0.2.0 → nestkit-0.3.0}/LICENSE +0 -0
  32. {nestkit-0.2.0 → nestkit-0.3.0}/setup.cfg +0 -0
  33. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/_constants.py +0 -0
  34. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/calibration/__init__.py +0 -0
  35. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/calibration/diagnostics.py +0 -0
  36. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/callbacks.py +0 -0
  37. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/comparison/__init__.py +0 -0
  38. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/comparison/comparator.py +0 -0
  39. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/conformal/__init__.py +0 -0
  40. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/conformal/classifier_conformal.py +0 -0
  41. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/conformal/results.py +0 -0
  42. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/diagnostics/__init__.py +0 -0
  43. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/importance/__init__.py +0 -0
  44. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/importance/aggregator.py +0 -0
  45. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/inner/__init__.py +0 -0
  46. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/inner/search.py +0 -0
  47. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/inner/tuning_report.py +0 -0
  48. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/_style.py +0 -0
  49. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/calibration.py +0 -0
  50. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/folds.py +0 -0
  51. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/importance.py +0 -0
  52. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/summary.py +0 -0
  53. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/threshold.py +0 -0
  54. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/plotting/tuning.py +0 -0
  55. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/results/classifier_results.py +0 -0
  56. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/results/regressor_results.py +0 -0
  57. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/thresholding/__init__.py +0 -0
  58. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit/thresholding/results.py +0 -0
  59. {nestkit-0.2.0 → nestkit-0.3.0}/src/nestkit.egg-info/dependency_links.txt +0 -0
  60. {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.2.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
- Provides-Extra: plotting
26
- Requires-Dist: matplotlib>=3.5; extra == "plotting"
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,plotting]; extra == "full"
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[plotting] # matplotlib + seaborn
96
- pip install nestkit[full] # plotting + SHAP
97
- pip install nestkit[dev] # testing + linting
98
- pip install nestkit[docs] # Sphinx documentation
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
- | `ClassifierResults` / `RegressorResults` | Rich result containers |
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[plotting] # matplotlib + seaborn
48
- pip install nestkit[full] # plotting + SHAP
49
- pip install nestkit[dev] # testing + linting
50
- pip install nestkit[docs] # Sphinx documentation
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
- | `ClassifierResults` / `RegressorResults` | Rich result containers |
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
- full = ["nestkit[plotting,bayesian]", "shap>=0.42"]
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
- testpaths = ["tests"]
80
- addopts = "-ra -q"
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
  ]
@@ -4,3 +4,5 @@ pandas>=1.4
4
4
  joblib>=1.2
5
5
  scipy>=1.9
6
6
  jinja2>=3.0
7
+ matplotlib>=3.10
8
+ seaborn>=0.12
@@ -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.2.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
- X, y = check_X_y(X, y, multi_output=True, allow_nd=True)
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
- self.classes_ = np.unique(y)
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, y, groups=groups, **fit_params)
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
- """Phase 2 + Phase 3: calibration and threshold optimization.
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 -- the alternative (triple-nested CV) is
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 == self.classes_[c]).astype(int)
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 == self.classes_[c]).astype(int) for y in oof_y_true]
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.classes_,
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 = self.classes_[np.argmax(cal_proba, axis=1)]
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": y_test,
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 = self.classes_[idx_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
- y_test[i] in conformal_output["prediction_sets"][i]
527
- for i in range(len(y_test))
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 or n < 2:
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.04, 0.06]
332
+ [0.03, 0.06, 0.06]
327
333
  """
328
334
  n = len(p_values)
329
335
  sorted_indices = np.argsort(p_values)