h2ml 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.
- h2ml/__init__.py +2 -0
- h2ml/evaluation/__init__.py +0 -0
- h2ml/evaluation/compare.py +283 -0
- h2ml/evaluation/metrics.py +359 -0
- h2ml/features/__init__.py +0 -0
- h2ml/features/correlation.py +104 -0
- h2ml/features/feature_store.py +135 -0
- h2ml/features/selector.py +242 -0
- h2ml/features/shap_importance.py +351 -0
- h2ml/features/spatial_cv.py +573 -0
- h2ml/geo/__init__.py +3 -0
- h2ml/geo/geo_predict.py +190 -0
- h2ml/optimization/__init__.py +0 -0
- h2ml/optimization/opt_params.py +43 -0
- h2ml/optimization/optimizer.py +556 -0
- h2ml/optimization/params/__init__.py +0 -0
- h2ml/optimization/params/classifiers.py +131 -0
- h2ml/optimization/params/regressors.py +172 -0
- h2ml/persistence/__init__.py +3 -0
- h2ml/persistence/result_io.py +259 -0
- h2ml/pipeline/__init__.py +0 -0
- h2ml/pipeline/base.py +194 -0
- h2ml/pipeline/cv.py +467 -0
- h2ml/pipeline/final_model.py +392 -0
- h2ml/pipeline/pipeline.py +1100 -0
- h2ml/pipeline/step.py +233 -0
- h2ml/plots/__init__.py +17 -0
- h2ml/plots/plots.py +566 -0
- h2ml/preprocessing/__init__.py +0 -0
- h2ml/preprocessing/transform_stores.py +50 -0
- h2ml/preprocessing/transforms.py +63 -0
- h2ml/utils/__init__.py +0 -0
- h2ml/utils/registry.py +299 -0
- h2ml/utils/variogram.py +227 -0
- h2ml-0.1.0.dist-info/METADATA +340 -0
- h2ml-0.1.0.dist-info/RECORD +38 -0
- h2ml-0.1.0.dist-info/WHEEL +4 -0
- h2ml-0.1.0.dist-info/licenses/LICENSE +21 -0
h2ml/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""
|
|
2
|
+
h2ml/evaluation/compare.py
|
|
3
|
+
|
|
4
|
+
compare_results() — compare multiple PipelineResult objects in one DataFrame.
|
|
5
|
+
|
|
6
|
+
Each row represents one pipeline run. Useful for selecting the best configuration
|
|
7
|
+
across runs with different PipelineConfig settings, feature schemas, or datasets.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional, TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
from h2ml.pipeline.pipeline import _MINIMIZE
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from h2ml.pipeline.pipeline import PipelineResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compare_results(
|
|
24
|
+
results: list["PipelineResult"],
|
|
25
|
+
labels: Optional[list[str]] = None,
|
|
26
|
+
metric: Optional[str] = None,
|
|
27
|
+
n_folds: Optional[int] = None,
|
|
28
|
+
) -> pd.DataFrame:
|
|
29
|
+
"""
|
|
30
|
+
Summarise multiple PipelineResult objects as a single comparison DataFrame.
|
|
31
|
+
|
|
32
|
+
One row per result. Columns:
|
|
33
|
+
|
|
34
|
+
Run — label (from labels, or Run_0 / Run_1 / …)
|
|
35
|
+
Metric — short metric name used for Score_Mean (e.g. "AUC", "R2")
|
|
36
|
+
Best_Model — winning model class name
|
|
37
|
+
Best_Stage — default | reduced | optimized
|
|
38
|
+
Y_Transform — winning y-transform, or None
|
|
39
|
+
Score_Mean — mean CV score for the selected metric
|
|
40
|
+
Score_Std — fold std for the selected metric
|
|
41
|
+
Conservative_Bound — variance-penalised score used for ranking. Direction is
|
|
42
|
+
derived automatically from the metric name via _MINIMIZE:
|
|
43
|
+
higher-is-better: Score_Mean - Score_Std / sqrt(n_folds)
|
|
44
|
+
lower-is-better: Score_Mean + Score_Std / sqrt(n_folds)
|
|
45
|
+
A model with high mean but high variance is pulled toward
|
|
46
|
+
a worse value, so stable models are preferred over
|
|
47
|
+
optimistically-estimated ones.
|
|
48
|
+
Brier_Mean — Brier_Test_Mean from the winning stage's agg_df
|
|
49
|
+
(classification only; NaN otherwise)
|
|
50
|
+
OOF_Brier — Brier score computed on the assembled OOF predictions
|
|
51
|
+
(more robust than the fold-averaged Brier_Mean;
|
|
52
|
+
classification only; NaN otherwise)
|
|
53
|
+
N_Features — feature count in the winning feature stage
|
|
54
|
+
Completed_Steps — list of pipeline steps that finished
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
results: List of PipelineResult objects to compare.
|
|
58
|
+
labels: Optional run labels (same length as results).
|
|
59
|
+
Defaults to "Run_0", "Run_1", …
|
|
60
|
+
metric: Short metric name to use as the common comparison column
|
|
61
|
+
(e.g. "AUC", "R2", "RMSE"). When provided, Score_Mean and
|
|
62
|
+
Score_Std are read from the ``<metric>_Test_Mean/Std`` columns
|
|
63
|
+
of each result's winning-stage agg DataFrame, making
|
|
64
|
+
cross-run scores directly comparable even when runs were
|
|
65
|
+
optimised on different metrics. When omitted, Score_Mean falls
|
|
66
|
+
back to each result's ``best_model_value``. Minimisation
|
|
67
|
+
direction and sort order are derived automatically from
|
|
68
|
+
``_MINIMIZE`` — no need to pass ``ascending`` manually.
|
|
69
|
+
n_folds: Number of CV folds used in all runs. When provided it overrides
|
|
70
|
+
automatic inference from the fold DataFrames, which can fail if
|
|
71
|
+
results were loaded from disk or the fold DataFrames are absent.
|
|
72
|
+
Conservative_Bound cannot be computed without a fold count and
|
|
73
|
+
will fall back to Score_Mean if both this argument and
|
|
74
|
+
inference fail.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
DataFrame sorted by Conservative_Bound (falls back to Score_Mean when
|
|
78
|
+
fold count cannot be inferred), one row per result.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
>>> r1 = pipeline_a.run(store)
|
|
82
|
+
>>> r2 = pipeline_b.run(store)
|
|
83
|
+
>>> compare_results([r1, r2], labels=["baseline", "spatial_cv"], metric="AUC")
|
|
84
|
+
"""
|
|
85
|
+
if labels is not None and len(labels) != len(results):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"labels has {len(labels)} entries but results has {len(results)}."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if metric is None:
|
|
91
|
+
unique_metrics = {r.metric for r in results if r.metric is not None}
|
|
92
|
+
if len(unique_metrics) > 1:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"compare_results: results use different metrics {sorted(unique_metrics)} — "
|
|
95
|
+
"Score_Mean values are not directly comparable. "
|
|
96
|
+
"Pass metric= to select a common comparison metric."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
rows = []
|
|
100
|
+
for i, result in enumerate(results):
|
|
101
|
+
label = labels[i] if labels is not None else f"Run_{i}"
|
|
102
|
+
fold_count = n_folds if n_folds is not None else _infer_n_folds(result)
|
|
103
|
+
|
|
104
|
+
if metric is not None:
|
|
105
|
+
score_mean, score_std = _get_score_for_metric(result, metric)
|
|
106
|
+
display_metric = metric
|
|
107
|
+
else:
|
|
108
|
+
score_mean = result.best_model_value
|
|
109
|
+
score_std = result.best_model_std
|
|
110
|
+
display_metric = result.metric
|
|
111
|
+
|
|
112
|
+
minimize = _MINIMIZE.get(display_metric, False) if display_metric else False
|
|
113
|
+
|
|
114
|
+
conservative_bound: Optional[float] = None
|
|
115
|
+
if score_mean is not None and score_std is not None and fold_count:
|
|
116
|
+
penalty = score_std / np.sqrt(fold_count)
|
|
117
|
+
conservative_bound = score_mean + penalty if minimize else score_mean - penalty
|
|
118
|
+
|
|
119
|
+
rows.append({
|
|
120
|
+
"Run": label,
|
|
121
|
+
"Metric": display_metric,
|
|
122
|
+
"Best_Model": result.best_model_name,
|
|
123
|
+
"Best_Stage": result.best_stage,
|
|
124
|
+
"Y_Transform": result.y_transform,
|
|
125
|
+
"Score_Mean": score_mean,
|
|
126
|
+
"Score_Std": score_std,
|
|
127
|
+
"Conservative_Bound": conservative_bound,
|
|
128
|
+
"Brier_Mean": _get_brier_from_agg(result),
|
|
129
|
+
"OOF_Brier": _get_oof_brier(result),
|
|
130
|
+
"N_Features": _get_n_features(result),
|
|
131
|
+
"Completed_Steps": result.completed_steps,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
df = pd.DataFrame(rows)
|
|
135
|
+
if df.empty:
|
|
136
|
+
return df
|
|
137
|
+
|
|
138
|
+
# Sort direction derived from metric, not from an ascending argument
|
|
139
|
+
if metric is not None:
|
|
140
|
+
sort_ascending = _MINIMIZE.get(metric, False)
|
|
141
|
+
else:
|
|
142
|
+
unique_minimize = {_MINIMIZE.get(r.metric, False) for r in results if r.metric}
|
|
143
|
+
sort_ascending = unique_minimize.pop() if len(unique_minimize) == 1 else False
|
|
144
|
+
|
|
145
|
+
if df["Conservative_Bound"].isna().all():
|
|
146
|
+
logger.warning(
|
|
147
|
+
"compare_results: Conservative_Bound could not be computed for any result "
|
|
148
|
+
"(fold count unknown and inference failed). Sorting by Score_Mean instead. "
|
|
149
|
+
"Pass n_folds= to enable variance penalisation."
|
|
150
|
+
)
|
|
151
|
+
sort_col = "Score_Mean"
|
|
152
|
+
else:
|
|
153
|
+
sort_col = "Conservative_Bound"
|
|
154
|
+
|
|
155
|
+
if sort_col in df.columns and df[sort_col].notna().any():
|
|
156
|
+
df = df.sort_values(sort_col, ascending=sort_ascending, ignore_index=True)
|
|
157
|
+
|
|
158
|
+
return df
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Private helpers
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _get_score_for_metric(
|
|
166
|
+
result: "PipelineResult",
|
|
167
|
+
metric: str,
|
|
168
|
+
) -> tuple[Optional[float], Optional[float]]:
|
|
169
|
+
"""Return (mean, std) for *metric* from the winning stage's agg DataFrame."""
|
|
170
|
+
mean_col = f"{metric}_Test_Mean"
|
|
171
|
+
std_col = f"{metric}_Test_Std"
|
|
172
|
+
|
|
173
|
+
stage = result.best_stage
|
|
174
|
+
if stage == "optimized":
|
|
175
|
+
agg_df = result.step4_agg_df
|
|
176
|
+
elif stage == "reduced":
|
|
177
|
+
agg_df = result.step3_agg_df
|
|
178
|
+
else:
|
|
179
|
+
agg_df = result.step1_agg_df
|
|
180
|
+
|
|
181
|
+
if agg_df is None or mean_col not in agg_df.columns or agg_df.empty:
|
|
182
|
+
logger.warning(
|
|
183
|
+
f"compare_results: column '{mean_col}' not found in the winning-stage "
|
|
184
|
+
f"agg DataFrame — Score_Mean will be NaN for this result."
|
|
185
|
+
)
|
|
186
|
+
return None, None
|
|
187
|
+
|
|
188
|
+
mask = agg_df["Model"] == result.best_model_name
|
|
189
|
+
if result.y_transform and "Y_transform" in agg_df.columns:
|
|
190
|
+
mask = mask & (agg_df["Y_transform"] == result.y_transform)
|
|
191
|
+
if stage == "reduced" and "Stage" in agg_df.columns:
|
|
192
|
+
mask = mask & (agg_df["Stage"] == "reduced")
|
|
193
|
+
|
|
194
|
+
subset = agg_df.loc[mask]
|
|
195
|
+
if subset.empty:
|
|
196
|
+
return None, None
|
|
197
|
+
|
|
198
|
+
row = subset.iloc[0]
|
|
199
|
+
mean_val = float(row[mean_col])
|
|
200
|
+
std_val = float(row[std_col]) if std_col in subset.columns else None
|
|
201
|
+
return mean_val, std_val
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _infer_n_folds(result: "PipelineResult") -> Optional[int]:
|
|
205
|
+
"""Infer fold count, preferring the stored splitter to avoid under-counting when folds fail."""
|
|
206
|
+
if result.splitter is not None and hasattr(result.splitter, "n_splits"):
|
|
207
|
+
return result.splitter.n_splits
|
|
208
|
+
for df in (result.step1_fold_df, result.step3_fold_df, result.step4_fold_df):
|
|
209
|
+
if df is not None and "Fold" in df.columns and not df.empty:
|
|
210
|
+
return int(df["Fold"].max()) + 1
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _get_n_features(result: "PipelineResult") -> Optional[int]:
|
|
215
|
+
"""Return the feature count for the winning feature stage."""
|
|
216
|
+
feature_stage = result.best_feature_stage or result.best_stage
|
|
217
|
+
if feature_stage == "reduced" and result.features_reduced is not None:
|
|
218
|
+
return result.features_reduced.n_features
|
|
219
|
+
if result.features is not None:
|
|
220
|
+
return result.features.n_features
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _get_brier_from_agg(result: "PipelineResult") -> Optional[float]:
|
|
225
|
+
"""
|
|
226
|
+
Extract Brier_Test_Mean for the winning (model, stage, transform) from
|
|
227
|
+
whichever agg_df corresponds to the best_stage.
|
|
228
|
+
Returns None when Brier was not computed or the column is absent.
|
|
229
|
+
"""
|
|
230
|
+
stage = result.best_stage
|
|
231
|
+
|
|
232
|
+
if stage == "optimized":
|
|
233
|
+
df = result.step4_agg_df
|
|
234
|
+
if df is not None and "Brier_Test_Mean" in df.columns and not df.empty:
|
|
235
|
+
return float(df.iloc[0]["Brier_Test_Mean"])
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
df = result.step3_agg_df if stage == "reduced" else result.step1_agg_df
|
|
239
|
+
if df is None or "Brier_Test_Mean" not in df.columns:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
mask = df["Model"] == result.best_model_name
|
|
243
|
+
if result.y_transform and "Y_transform" in df.columns:
|
|
244
|
+
mask = mask & (df["Y_transform"] == result.y_transform)
|
|
245
|
+
if stage == "reduced" and "Stage" in df.columns:
|
|
246
|
+
mask = mask & (df["Stage"] == "reduced")
|
|
247
|
+
|
|
248
|
+
subset = df.loc[mask, "Brier_Test_Mean"]
|
|
249
|
+
return float(subset.iloc[0]) if not subset.empty else None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _get_oof_brier(result: "PipelineResult") -> Optional[float]:
|
|
253
|
+
"""
|
|
254
|
+
Compute Brier score on assembled OOF predictions.
|
|
255
|
+
Only meaningful for classification — returns None for regression or when
|
|
256
|
+
OOF data is unavailable.
|
|
257
|
+
"""
|
|
258
|
+
from sklearn.metrics import brier_score_loss
|
|
259
|
+
from h2ml.pipeline.base import TaskType
|
|
260
|
+
|
|
261
|
+
cv = result.best_cv_result
|
|
262
|
+
if cv is None or cv.task_type != TaskType.CLASSIFICATION:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
oof_pred = result.oof_predictions
|
|
266
|
+
oof_labels = result.oof_labels
|
|
267
|
+
if oof_pred is None or oof_labels is None:
|
|
268
|
+
return None
|
|
269
|
+
# Drop missing positions (failed folds); use pd.isnull to handle both
|
|
270
|
+
# float NaN (numeric labels) and None (string/object labels from oof_labels)
|
|
271
|
+
valid = ~(np.isnan(oof_pred) if oof_pred.ndim == 1 else np.isnan(oof_pred).any(axis=1))
|
|
272
|
+
valid &= ~pd.isnull(oof_labels)
|
|
273
|
+
if not valid.any():
|
|
274
|
+
return None
|
|
275
|
+
y_true = oof_labels[valid]
|
|
276
|
+
y_prob = oof_pred[valid]
|
|
277
|
+
if y_prob.ndim == 2:
|
|
278
|
+
# Multiclass Brier
|
|
279
|
+
from sklearn.preprocessing import label_binarize
|
|
280
|
+
classes = np.unique(y_true.astype(int))
|
|
281
|
+
y_bin = label_binarize(y_true, classes=classes)
|
|
282
|
+
return float(np.mean(np.sum((y_bin - y_prob) ** 2, axis=1)))
|
|
283
|
+
return float(brier_score_loss(y_true, y_prob))
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Consumes CVResult objects and produces metrics DataFrames.
|
|
3
|
+
Responsibilities:
|
|
4
|
+
- Compute per-fold metrics (classification or regression)
|
|
5
|
+
- Aggregate fold metrics (mean, std across folds)
|
|
6
|
+
- Attach domain metadata (species, setting, batch, schema)
|
|
7
|
+
- Detect overfitting gap between train and test metrics
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from sklearn.metrics import (
|
|
17
|
+
# Classification
|
|
18
|
+
average_precision_score,
|
|
19
|
+
brier_score_loss,
|
|
20
|
+
f1_score,
|
|
21
|
+
log_loss,
|
|
22
|
+
roc_auc_score,
|
|
23
|
+
# Regression
|
|
24
|
+
mean_absolute_error,
|
|
25
|
+
mean_squared_error,
|
|
26
|
+
r2_score
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from h2ml.pipeline.base import TaskType
|
|
30
|
+
from h2ml.pipeline.cv import CVResult, FoldResult
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Metadata container — replaces PipelineSpecs fields in results
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class RunMetadata:
|
|
38
|
+
"""
|
|
39
|
+
Domain-specific context attached to results after CV.
|
|
40
|
+
Keeps metadata out of the CV engine.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
schema: top-level folder / experiment name
|
|
44
|
+
target: target column name.
|
|
45
|
+
stage:
|
|
46
|
+
- "default": step 1 (all features, default params)
|
|
47
|
+
- "reduced": step 3 (reduced features, default params)
|
|
48
|
+
- "optimized": step 4 (reduced or full features, optimized params)
|
|
49
|
+
batch: Optional batch/run identifier.
|
|
50
|
+
schema: Optional feature schema label.
|
|
51
|
+
"""
|
|
52
|
+
# Experiment ID
|
|
53
|
+
schema: Optional[str] = None
|
|
54
|
+
target: Optional[str] = None
|
|
55
|
+
|
|
56
|
+
# Pipeline stage — set automatically by pipeline._metadata_with()
|
|
57
|
+
stage: str = "default" # "default" | "reduced" | "optimized"
|
|
58
|
+
|
|
59
|
+
# Optional grouping
|
|
60
|
+
batch: Optional[str] = None # run ID, date, or any grouping label
|
|
61
|
+
y_transform: Optional[str] = None # y-transform name, set by run_transform_sweep()
|
|
62
|
+
notes: Optional[str] = None # free text — useful for experiments log
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict:
|
|
65
|
+
return {k.capitalize(): v for k, v in self.__dict__.items() if v is not None}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Per-fold metric rows
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _classification_metrics(fold: FoldResult) -> dict:
|
|
73
|
+
"""Compute classification metrics for a single fold.
|
|
74
|
+
|
|
75
|
+
Supports both binary and multiclass targets. Detection is based on the shape
|
|
76
|
+
of y_prob_test: 1-D → binary, 2-D → multiclass (OvR weighted averages).
|
|
77
|
+
|
|
78
|
+
Degenerate folds (only one class in y_test or y_train) arise with spatial CV
|
|
79
|
+
on imbalanced targets. Rank-based metrics (AUC, AUC-PR) return NaN for those
|
|
80
|
+
folds; other metrics use explicit class labels to avoid ValueError.
|
|
81
|
+
"""
|
|
82
|
+
if fold.y_prob_test is None or fold.y_prob_train is None:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"[{fold.model_name}] Fold {fold.fold_id} has no probabilities. "
|
|
85
|
+
"Did you use a BaseClassifier?"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
is_multiclass = fold.y_prob_test.ndim == 2
|
|
89
|
+
|
|
90
|
+
# All classes seen across both splits — needed to keep metric calls stable
|
|
91
|
+
# even when one split is missing a class (common with spatial CV + imbalance).
|
|
92
|
+
all_classes = sorted(np.unique(np.concatenate([fold.y_train, fold.y_test])).tolist())
|
|
93
|
+
single_class_test = len(np.unique(fold.y_test)) < 2
|
|
94
|
+
single_class_train = len(np.unique(fold.y_train)) < 2
|
|
95
|
+
|
|
96
|
+
if is_multiclass:
|
|
97
|
+
from sklearn.preprocessing import label_binarize
|
|
98
|
+
y_test_bin = label_binarize(fold.y_test, classes=all_classes)
|
|
99
|
+
y_train_bin = label_binarize(fold.y_train, classes=all_classes)
|
|
100
|
+
auc_test = (np.nan if single_class_test else
|
|
101
|
+
roc_auc_score(fold.y_test, fold.y_prob_test, multi_class="ovr", average="weighted"))
|
|
102
|
+
auc_train = (np.nan if single_class_train else
|
|
103
|
+
roc_auc_score(fold.y_train, fold.y_prob_train, multi_class="ovr", average="weighted"))
|
|
104
|
+
auc_pr_test = (np.nan if single_class_test else
|
|
105
|
+
average_precision_score(y_test_bin, fold.y_prob_test, average="weighted"))
|
|
106
|
+
auc_pr_train = (np.nan if single_class_train else
|
|
107
|
+
average_precision_score(y_train_bin, fold.y_prob_train, average="weighted"))
|
|
108
|
+
logloss_test = log_loss(fold.y_test, fold.y_prob_test, labels=all_classes)
|
|
109
|
+
logloss_train = log_loss(fold.y_train, fold.y_prob_train, labels=all_classes)
|
|
110
|
+
f1_test = f1_score(fold.y_test, fold.y_pred_test, average="weighted", zero_division=0)
|
|
111
|
+
f1_train = f1_score(fold.y_train, fold.y_pred_train, average="weighted", zero_division=0)
|
|
112
|
+
# Multiclass Brier: mean over samples of sum-of-squared probability errors
|
|
113
|
+
brier_test = float(np.mean(np.sum((y_test_bin - fold.y_prob_test)**2, axis=1)))
|
|
114
|
+
brier_train = float(np.mean(np.sum((y_train_bin - fold.y_prob_train)**2, axis=1)))
|
|
115
|
+
else:
|
|
116
|
+
auc_test = (np.nan if single_class_test else roc_auc_score(fold.y_test, fold.y_prob_test))
|
|
117
|
+
auc_train = (np.nan if single_class_train else roc_auc_score(fold.y_train, fold.y_prob_train))
|
|
118
|
+
auc_pr_test = (np.nan if single_class_test else average_precision_score(fold.y_test, fold.y_prob_test))
|
|
119
|
+
auc_pr_train = (np.nan if single_class_train else average_precision_score(fold.y_train, fold.y_prob_train))
|
|
120
|
+
logloss_test = log_loss(fold.y_test, fold.y_prob_test, labels=all_classes)
|
|
121
|
+
logloss_train = log_loss(fold.y_train, fold.y_prob_train, labels=all_classes)
|
|
122
|
+
f1_test = f1_score(fold.y_test, fold.y_pred_test, zero_division=0, labels=all_classes)
|
|
123
|
+
f1_train = f1_score(fold.y_train, fold.y_pred_train, zero_division=0, labels=all_classes)
|
|
124
|
+
brier_test = float(brier_score_loss(fold.y_test, fold.y_prob_test))
|
|
125
|
+
brier_train = float(brier_score_loss(fold.y_train, fold.y_prob_train))
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
"AUC_Test": auc_test,
|
|
129
|
+
"AUC_Train": auc_train,
|
|
130
|
+
"AUC_PR_Test": auc_pr_test,
|
|
131
|
+
"AUC_PR_Train": auc_pr_train,
|
|
132
|
+
"LogLoss_Test": logloss_test,
|
|
133
|
+
"LogLoss_Train": logloss_train,
|
|
134
|
+
"F1_Test": f1_test,
|
|
135
|
+
"F1_Train": f1_train,
|
|
136
|
+
"Brier_Test": brier_test,
|
|
137
|
+
"Brier_Train": brier_train,
|
|
138
|
+
"Fit_Time": fold.fit_time,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _regression_metrics(fold: FoldResult) -> dict:
|
|
143
|
+
"""Compute regression metrics for a single fold."""
|
|
144
|
+
mse_test = mean_squared_error(fold.y_test, fold.y_pred_test)
|
|
145
|
+
mse_train = mean_squared_error(fold.y_train, fold.y_pred_train)
|
|
146
|
+
return {
|
|
147
|
+
"R2_Test": r2_score(fold.y_test, fold.y_pred_test),
|
|
148
|
+
"R2_Train": r2_score(fold.y_train, fold.y_pred_train),
|
|
149
|
+
"MAE_Test": mean_absolute_error(fold.y_test, fold.y_pred_test),
|
|
150
|
+
"MAE_Train": mean_absolute_error(fold.y_train, fold.y_pred_train),
|
|
151
|
+
"RMSE_Test": np.sqrt(mse_test),
|
|
152
|
+
"RMSE_Train": np.sqrt(mse_train),
|
|
153
|
+
"Fit_Time": fold.fit_time,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Dispatcher — routes to the right metric function by task type
|
|
158
|
+
_METRIC_FN = {
|
|
159
|
+
TaskType.CLASSIFICATION: _classification_metrics,
|
|
160
|
+
TaskType.REGRESSION: _regression_metrics,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Overfitting gap
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
# Metric triples (test_col, train_col, minimize).
|
|
169
|
+
# Gap convention: positive always means the model does worse on test than train (overfitting).
|
|
170
|
+
# maximize metrics (AUC, R2, F1): gap = train - test → positive = overfitting
|
|
171
|
+
# minimize metrics (LogLoss, MAE): gap = test - train → positive = overfitting
|
|
172
|
+
_OVERFIT_PAIRS = {
|
|
173
|
+
TaskType.CLASSIFICATION: [
|
|
174
|
+
("AUC_Test", "AUC_Train", False),
|
|
175
|
+
("AUC_PR_Test", "AUC_PR_Train", False),
|
|
176
|
+
("F1_Test", "F1_Train", False),
|
|
177
|
+
("LogLoss_Test", "LogLoss_Train", True),
|
|
178
|
+
("Brier_Test", "Brier_Train", True),
|
|
179
|
+
],
|
|
180
|
+
TaskType.REGRESSION: [
|
|
181
|
+
("R2_Test", "R2_Train", False),
|
|
182
|
+
("MAE_Test", "MAE_Train", True),
|
|
183
|
+
("RMSE_Test", "RMSE_Train", True),
|
|
184
|
+
],
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _add_overfit_gap(df: pd.DataFrame, task_type: TaskType) -> pd.DataFrame:
|
|
189
|
+
"""
|
|
190
|
+
Adds Gap columns for key metrics. A positive gap always means the model
|
|
191
|
+
performs worse on test than train, regardless of metric direction.
|
|
192
|
+
"""
|
|
193
|
+
for test_col, train_col, minimize in _OVERFIT_PAIRS.get(task_type, []):
|
|
194
|
+
if test_col in df.columns and train_col in df.columns:
|
|
195
|
+
metric_name = test_col.replace("_Test", "")
|
|
196
|
+
df[f"{metric_name}_Gap"] = (
|
|
197
|
+
df[test_col] - df[train_col] if minimize
|
|
198
|
+
else df[train_col] - df[test_col]
|
|
199
|
+
)
|
|
200
|
+
return df
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Main public API
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def compute_metrics(
|
|
208
|
+
cv_result: CVResult,
|
|
209
|
+
metadata: Optional[RunMetadata] = None,
|
|
210
|
+
) -> pd.DataFrame:
|
|
211
|
+
"""
|
|
212
|
+
Compute per-fold metrics for a single CVResult.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
cv_result: Output of CrossValidator.run() for one model.
|
|
216
|
+
metadata: Optional RunMetadata to attach to every row.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
DataFrame with one row per fold.
|
|
220
|
+
"""
|
|
221
|
+
metric_fn = _METRIC_FN.get(cv_result.task_type)
|
|
222
|
+
if metric_fn is None:
|
|
223
|
+
raise ValueError(f"Unsupported task type: {cv_result.task_type}")
|
|
224
|
+
|
|
225
|
+
rows = []
|
|
226
|
+
for fold in cv_result.folds:
|
|
227
|
+
row = {"Model": fold.model_name, "Fold": fold.fold_id}
|
|
228
|
+
row.update(metric_fn(fold))
|
|
229
|
+
if metadata:
|
|
230
|
+
row.update(metadata.to_dict())
|
|
231
|
+
rows.append(row)
|
|
232
|
+
|
|
233
|
+
df = pd.DataFrame(rows)
|
|
234
|
+
df = _add_overfit_gap(df, cv_result.task_type)
|
|
235
|
+
return df
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def compute_metrics_all(
|
|
239
|
+
cv_results: list[CVResult],
|
|
240
|
+
metadata: Optional[RunMetadata] = None,
|
|
241
|
+
) -> pd.DataFrame:
|
|
242
|
+
"""
|
|
243
|
+
Compute per-fold metrics for a list of CVResults (all models).
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
cv_results: Output of CrossValidator.run_all().
|
|
247
|
+
metadata: Optional RunMetadata to attach to every row.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
DataFrame with one row per fold per model.
|
|
251
|
+
"""
|
|
252
|
+
frames = [compute_metrics(r, metadata) for r in cv_results if r.folds]
|
|
253
|
+
if not frames:
|
|
254
|
+
raise RuntimeError("All CV results are empty — every model/fold failed.")
|
|
255
|
+
return pd.concat(frames, ignore_index=True)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def aggregate_metrics(
|
|
259
|
+
fold_df: pd.DataFrame,
|
|
260
|
+
group_by_stage: bool = True
|
|
261
|
+
) -> pd.DataFrame:
|
|
262
|
+
"""
|
|
263
|
+
Aggregate per-fold results into mean ± std per model.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
fold_df: Output of compute_metrics() or compute_metrics_all().
|
|
267
|
+
group_by_stage: If True (default), group by Stage alongside Model and other
|
|
268
|
+
metadata columns so each (model, stage) pair gets its own row.
|
|
269
|
+
Set False to collapse all stages per model — used in step 4
|
|
270
|
+
when the optimized run's folds should be averaged without a
|
|
271
|
+
Stage breakdown.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
DataFrame with one row per model, columns suffixed _Mean and _Std.
|
|
275
|
+
"""
|
|
276
|
+
# Columns to group by — model name + any metadata fields present in the df
|
|
277
|
+
group_cols = ["Model"]
|
|
278
|
+
meta_fields = ["Schema", "Target", "Batch", "Y_transform", "Stage", "Notes"]
|
|
279
|
+
|
|
280
|
+
if group_by_stage:
|
|
281
|
+
group_cols += [c for c in meta_fields if c in fold_df.columns]
|
|
282
|
+
else:
|
|
283
|
+
# Exclude Stage — pool all folds across stages per model/transform
|
|
284
|
+
group_cols += [c for c in meta_fields if c in fold_df.columns
|
|
285
|
+
and c != "Stage"]
|
|
286
|
+
|
|
287
|
+
numeric_cols = fold_df.select_dtypes(include=np.number).columns.tolist()
|
|
288
|
+
# Exclude fold id from aggregation
|
|
289
|
+
numeric_cols = [c for c in numeric_cols if c != "Fold"]
|
|
290
|
+
|
|
291
|
+
mean_df = fold_df.groupby(group_cols)[numeric_cols].mean().add_suffix("_Mean")
|
|
292
|
+
std_df = fold_df.groupby(group_cols)[numeric_cols].std().add_suffix("_Std")
|
|
293
|
+
|
|
294
|
+
agg_df = pd.concat([mean_df, std_df], axis=1).reset_index()
|
|
295
|
+
|
|
296
|
+
# Interleave Mean/Std columns for readability: AUC_Test_Mean, AUC_Test_Std, ...
|
|
297
|
+
metric_cols = []
|
|
298
|
+
for col in numeric_cols:
|
|
299
|
+
metric_cols += [f"{col}_Mean", f"{col}_Std"]
|
|
300
|
+
|
|
301
|
+
ordered_cols = group_cols + [c for c in metric_cols if c in agg_df.columns]
|
|
302
|
+
return agg_df[ordered_cols]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def select_best(
|
|
306
|
+
agg_df: pd.DataFrame,
|
|
307
|
+
metric: str = "AUC_Test_Mean",
|
|
308
|
+
minimize: bool = False,
|
|
309
|
+
n_folds: Optional[int] = None,
|
|
310
|
+
) -> dict:
|
|
311
|
+
"""
|
|
312
|
+
Select the best model from an aggregated metrics DataFrame.
|
|
313
|
+
|
|
314
|
+
When n_folds is provided, ranking uses a lower/upper confidence bound
|
|
315
|
+
(LCB for maximize metrics, UCB for minimize metrics):
|
|
316
|
+
|
|
317
|
+
maximize: score = mean - std / sqrt(n_folds) → idxmax
|
|
318
|
+
minimize: score = mean + std / sqrt(n_folds) → idxmin
|
|
319
|
+
|
|
320
|
+
This prefers models whose conservative-bound performance is highest,
|
|
321
|
+
penalising fold variance in proportion to 1/sqrt(n_folds).
|
|
322
|
+
When n_folds is None the raw mean is used (original behaviour).
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
agg_df: Output of aggregate_metrics().
|
|
326
|
+
metric: Column name to rank by (must end in _Mean, e.g. AUC_Test_Mean).
|
|
327
|
+
minimize: If True, select the row with the lowest value (e.g. RMSE, MAE).
|
|
328
|
+
n_folds: Number of CV folds used to produce agg_df. When provided,
|
|
329
|
+
selection uses confidence-bound scoring instead of raw mean.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Dict with keys model_name, metric, value, row.
|
|
333
|
+
"""
|
|
334
|
+
if metric not in agg_df.columns:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"Metric '{metric}' not found. Available: {list(agg_df.columns)}"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
std_col = metric.replace("_Mean", "_Std")
|
|
340
|
+
if n_folds is not None and std_col in agg_df.columns:
|
|
341
|
+
penalty = agg_df[std_col] / np.sqrt(n_folds)
|
|
342
|
+
score = agg_df[metric] + penalty if minimize else agg_df[metric] - penalty
|
|
343
|
+
else:
|
|
344
|
+
score = agg_df[metric]
|
|
345
|
+
|
|
346
|
+
if score.isna().all():
|
|
347
|
+
raise RuntimeError(
|
|
348
|
+
f"select_best: all values in column '{metric}' are NaN. "
|
|
349
|
+
"Every model may have failed all CV folds. Check cv_warnings on the result."
|
|
350
|
+
)
|
|
351
|
+
idx = score.idxmin() if minimize else score.idxmax()
|
|
352
|
+
best_row = agg_df.loc[idx]
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
"model_name": best_row["Model"],
|
|
356
|
+
"metric": metric,
|
|
357
|
+
"value": best_row[metric],
|
|
358
|
+
"row": best_row,
|
|
359
|
+
}
|
|
File without changes
|