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 ADDED
@@ -0,0 +1,2 @@
1
+ from h2ml.pipeline.pipeline import H2MLPipeline, PipelineConfig, PipelineData, PipelineResult # noqa: F401
2
+ from h2ml.pipeline.base import TaskType # noqa: F401
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