tabml-guard 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.
@@ -0,0 +1,12 @@
1
+ """TabML-Guard: Explainable, Drift-Aware AutoML for Time-Ordered Tabular Data."""
2
+
3
+ from .__version__ import __version__
4
+ from .config import Config, load_config
5
+ from .pipeline import TabMLGuard
6
+
7
+ __all__ = [
8
+ "__version__",
9
+ "TabMLGuard",
10
+ "Config",
11
+ "load_config",
12
+ ]
@@ -0,0 +1,2 @@
1
+ __version__ = "0.1.0"
2
+
@@ -0,0 +1,416 @@
1
+ """Stage 1 — Regularized baselines (the bias-variance foundation).
2
+
3
+ Before any heavy model, establish an honest, interpretable yardstick: linear
4
+ models under different regularizers. This makes two teaching points concrete and
5
+ measurable:
6
+
7
+ * **L2 (ridge-style) shrinks** coefficients smoothly toward zero;
8
+ * **L1 (lasso-style) zeroes** coefficients outright -> built-in feature
9
+ selection (we report the resulting sparsity);
10
+ * **ElasticNet** blends the two.
11
+
12
+ The classification analog of Ridge/Lasso/ElasticNet is Logistic Regression with
13
+ ``penalty in {l2, l1, elasticnet}`` — identical regularization mathematics on the
14
+ linear term, just wrapped in a logistic loss. (Be ready to defend this to an
15
+ examiner: "Ridge/Lasso are regression names; the *penalty* is the idea, and it
16
+ transfers directly to logistic regression.")
17
+
18
+ Draws on Paper 1 (RegStack, ESWA 2025, DOI 10.1016/j.eswa.2025.127766): treating
19
+ regularization as a first-class ingredient, not an afterthought.
20
+
21
+ Classification -> LogisticRegression(penalty in {None, l2, l1, elasticnet})
22
+ Regression -> LinearRegression / Ridge / Lasso / ElasticNet
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import warnings
27
+ from typing import Optional
28
+
29
+ import numpy as np
30
+
31
+ from .config import Config
32
+ from .data import DataBundle
33
+ from .metrics import primary_metric_name
34
+ from .modeling import (
35
+ ModelResult,
36
+ feature_names_out,
37
+ fit_evaluate,
38
+ leaderboard,
39
+ log_result,
40
+ pick_best,
41
+ )
42
+
43
+
44
+ # --------------------------------------------------------------------------- #
45
+ # Model zoo (built lazily so an import problem surfaces as a clear message)
46
+ # --------------------------------------------------------------------------- #
47
+ def _sklearn_new_api() -> bool:
48
+ """True on scikit-learn >= 1.8, where ``penalty=`` is deprecated for ``l1_ratio``.
49
+
50
+ On 1.8+ a stray ``penalty=`` is silently overridden by ``l1_ratio``'s default
51
+ of 0.0 (pure L2) — which would make all four regularizers identical L2 fits.
52
+ Detecting the version lets us speak the right dialect.
53
+ """
54
+ import sklearn
55
+
56
+ try:
57
+ major, minor = (int(x) for x in sklearn.__version__.split(".")[:2])
58
+ return (major, minor) >= (1, 8)
59
+ except Exception:
60
+ return False
61
+
62
+
63
+ def _make_logreg(kind: str, C, rs: int, new_api: bool):
64
+ """LogisticRegression for kind in {none, l2, l1, elasticnet} at strength ``C``.
65
+
66
+ On 1.8+ the elasticnet spectrum is expressed via ``l1_ratio`` (0=L2, 1=L1,
67
+ 0.5=EN) and ``C=inf`` means unregularized; 'saga' is required once
68
+ ``l1_ratio > 0`` (lbfgs is fine and faster at 0). ``n_jobs`` is omitted: it has
69
+ no effect on LogisticRegression and was removed in 1.8.
70
+ """
71
+ from sklearn.linear_model import LogisticRegression
72
+
73
+ if new_api:
74
+ cfgs = {
75
+ "none": dict(C=np.inf, l1_ratio=0.0, solver="lbfgs", max_iter=3000),
76
+ "l2": dict(C=C, l1_ratio=0.0, solver="lbfgs", max_iter=3000),
77
+ "l1": dict(C=C, l1_ratio=1.0, solver="saga", max_iter=5000, tol=1e-3, random_state=rs),
78
+ "elasticnet": dict(C=C, l1_ratio=0.5, solver="saga", max_iter=5000, tol=1e-3, random_state=rs),
79
+ }
80
+ else:
81
+ cfgs = {
82
+ "none": dict(penalty=None, solver="lbfgs", max_iter=3000),
83
+ "l2": dict(penalty="l2", C=C, solver="lbfgs", max_iter=3000),
84
+ "l1": dict(penalty="l1", C=C, solver="saga", max_iter=5000, tol=1e-3, random_state=rs),
85
+ "elasticnet": dict(penalty="elasticnet", C=C, l1_ratio=0.5, solver="saga",
86
+ max_iter=5000, tol=1e-3, random_state=rs),
87
+ }
88
+ return LogisticRegression(**cfgs[kind])
89
+
90
+
91
+ def _classification_models(cfg: Config) -> dict:
92
+ """Four logistic-regression regularizers at a common C=1.0: none / L2 / L1 / EN.
93
+
94
+ Keeping C fixed across penalties makes the comparison fair. The
95
+ *regularization path* (below) is what reveals what each penalty actually does
96
+ as its strength varies — the headline four can look near-identical when the
97
+ data is abundant and low-dimensional, which is itself a finding.
98
+ """
99
+ rs = cfg.random_state
100
+ new_api = _sklearn_new_api()
101
+ return {
102
+ "logreg_none": _make_logreg("none", None, rs, new_api),
103
+ "logreg_l2": _make_logreg("l2", 1.0, rs, new_api),
104
+ "logreg_l1": _make_logreg("l1", 1.0, rs, new_api),
105
+ "logreg_elasticnet": _make_logreg("elasticnet", 1.0, rs, new_api),
106
+ }
107
+
108
+
109
+ def _regression_models(cfg: Config) -> dict:
110
+ from sklearn.linear_model import ElasticNet, Lasso, LinearRegression, Ridge
111
+
112
+ rs = cfg.random_state
113
+ return {
114
+ "linear": LinearRegression(),
115
+ "ridge": Ridge(alpha=1.0, random_state=rs),
116
+ "lasso": Lasso(alpha=0.001, max_iter=10000, random_state=rs),
117
+ "elasticnet": ElasticNet(
118
+ alpha=0.001, l1_ratio=0.5, max_iter=10000, random_state=rs
119
+ ),
120
+ }
121
+
122
+
123
+ # --------------------------------------------------------------------------- #
124
+ # Coefficient diagnostics — the "regularization actually did something" evidence
125
+ # --------------------------------------------------------------------------- #
126
+ def _coef_diagnostics(res: ModelResult) -> dict:
127
+ """Sparsity, coefficient norms, and the strongest features for one model."""
128
+ model = res.estimator.named_steps["model"]
129
+ coef = getattr(model, "coef_", None)
130
+ if coef is None:
131
+ return {}
132
+ coef = np.asarray(coef, dtype=float)
133
+ names = feature_names_out(res.estimator)
134
+
135
+ flat = coef.ravel()
136
+ n_total = int(flat.size)
137
+ n_zero = int(np.sum(np.abs(flat) < 1e-6))
138
+
139
+ # Rank features by importance. For multiclass, average |coef| across classes
140
+ # so each feature gets one score aligned with the feature-name list.
141
+ if coef.ndim > 1 and coef.shape[0] > 1:
142
+ strength = np.abs(coef).mean(axis=0)
143
+ else:
144
+ strength = np.abs(flat)
145
+ order = np.argsort(strength)[::-1][:12]
146
+ signed = flat if flat.size == strength.size else coef.mean(axis=0)
147
+ top = [
148
+ [names[i] if i < len(names) else f"f{i}", round(float(signed[i]), 5)]
149
+ for i in order
150
+ ]
151
+
152
+ return {
153
+ "n_coefficients": n_total,
154
+ "n_zero": n_zero,
155
+ "sparsity": round(n_zero / n_total, 4) if n_total else 0.0,
156
+ "l1_norm": round(float(np.abs(flat).sum()), 5),
157
+ "l2_norm": round(float(np.linalg.norm(flat)), 5),
158
+ "top_features": top,
159
+ }
160
+
161
+
162
+ def _regularization_note(results: list[ModelResult], task: str, path: Optional[dict] = None) -> str:
163
+ """A plain-English takeaway: why the headline looks flat, and what the path shows."""
164
+ metric = primary_metric_name(task)
165
+ vals = [r.metrics.get(metric, float("nan")) for r in results]
166
+ vals = [v for v in vals if v == v] # drop nan
167
+ best = pick_best(results, task)
168
+
169
+ parts = []
170
+ if best is not None and vals:
171
+ spread = max(vals) - min(vals)
172
+ parts.append(
173
+ f"the {len(results)} regularizers span only {spread:.4f} {metric} "
174
+ f"(best '{best.name}' {best.metrics.get(metric, float('nan')):.4f}) — "
175
+ f"with abundant rows and few features, penalties barely move the fit at the default strength"
176
+ )
177
+
178
+ if path:
179
+ F = path.get("n_features", 0)
180
+ if path.get("type") == "classification" and path.get("l1", {}).get("nonzero"):
181
+ nz, Cs = path["l1"]["nonzero"], path["C"]
182
+ i = int(np.argmin(nz))
183
+ parts.append(
184
+ f"L1 path: at C={Cs[i]:g} only {nz[i]}/{F} features survive (sparsity), "
185
+ f"rising to {max(nz)}/{F} as C grows"
186
+ )
187
+ elif path.get("type") == "regression" and path.get("lasso", {}).get("nonzero"):
188
+ nz, al = path["lasso"]["nonzero"], path["alpha"]
189
+ i = int(np.argmax(al)) # strongest penalty = largest alpha
190
+ parts.append(
191
+ f"Lasso path: at alpha={al[i]:g} only {nz[i]}/{F} features survive (sparsity)"
192
+ )
193
+
194
+ return "; ".join(parts) if parts else "regularized linear baselines fitted."
195
+
196
+
197
+ # --------------------------------------------------------------------------- #
198
+ # Regularization path — the "what does the penalty actually do?" demonstration
199
+ # --------------------------------------------------------------------------- #
200
+ def _regularization_path(bundle: DataBundle, cfg: Config) -> Optional[dict]:
201
+ """Sweep penalty strength to visualize L1 sparsity and L2 shrinkage.
202
+
203
+ Fits on an INTERNAL train/validation split of the *training* data only (the
204
+ held-out test set is never touched), so this is a clean diagnostic rather than
205
+ test-set peeking. Linear fits are cheap, so a 12-point sweep costs seconds.
206
+
207
+ Returns per-strength: number of surviving (non-zero) coefficients, the L2 norm
208
+ of the coefficient vector (shrinkage), and a validation score (the bias-variance
209
+ sweet spot). Classification sweeps C for L1 & L2; regression sweeps alpha for
210
+ Lasso & Ridge.
211
+ """
212
+ try:
213
+ from .modeling import make_pipeline
214
+
215
+ rs = cfg.random_state
216
+ n = len(bundle.X_train)
217
+ if n < 50:
218
+ return None
219
+ cut = int(n * 0.8)
220
+ Xtr, Xval = bundle.X_train.iloc[:cut], bundle.X_train.iloc[cut:]
221
+ ytr, yval = bundle.y_train[:cut], bundle.y_train[cut:]
222
+
223
+ def _fit_coef(estimator):
224
+ pipe = make_pipeline(bundle.preprocessor, estimator)
225
+ with warnings.catch_warnings():
226
+ warnings.simplefilter("ignore")
227
+ pipe.fit(Xtr, ytr)
228
+ coef = np.asarray(pipe.named_steps["model"].coef_, dtype=float).ravel()
229
+ return pipe, coef
230
+
231
+ if bundle.task == "classification":
232
+ from sklearn.metrics import roc_auc_score
233
+
234
+ new_api = _sklearn_new_api()
235
+ Cs = np.logspace(-3, 2, 12)
236
+ out: dict = {
237
+ "type": "classification",
238
+ "C": [round(float(c), 6) for c in Cs],
239
+ "l1": {"nonzero": [], "l2norm": [], "val_roc_auc": []},
240
+ "l2": {"nonzero": [], "l2norm": [], "val_roc_auc": []},
241
+ }
242
+ n_features = 0
243
+ for kind in ("l1", "l2"):
244
+ for c in Cs:
245
+ pipe, coef = _fit_coef(_make_logreg(kind, float(c), rs, new_api))
246
+ n_features = coef.size
247
+ out[kind]["nonzero"].append(int(np.sum(np.abs(coef) > 1e-6)))
248
+ out[kind]["l2norm"].append(round(float(np.linalg.norm(coef)), 5))
249
+ try:
250
+ proba = pipe.predict_proba(Xval)[:, 1]
251
+ out[kind]["val_roc_auc"].append(round(float(roc_auc_score(yval, proba)), 4))
252
+ except Exception:
253
+ out[kind]["val_roc_auc"].append(float("nan"))
254
+ out["n_features"] = int(n_features)
255
+ return out
256
+
257
+ # regression
258
+ from sklearn.linear_model import Lasso, Ridge
259
+ from sklearn.metrics import mean_squared_error
260
+
261
+ alphas = np.logspace(-4, 1, 12)
262
+ out = {
263
+ "type": "regression",
264
+ "alpha": [round(float(a), 6) for a in alphas],
265
+ "lasso": {"nonzero": [], "l2norm": [], "val_rmse": []},
266
+ "ridge": {"nonzero": [], "l2norm": [], "val_rmse": []},
267
+ }
268
+ n_features = 0
269
+ for kind in ("lasso", "ridge"):
270
+ for a in alphas:
271
+ est = (
272
+ Lasso(alpha=float(a), max_iter=10000, random_state=rs)
273
+ if kind == "lasso"
274
+ else Ridge(alpha=float(a), random_state=rs)
275
+ )
276
+ pipe, coef = _fit_coef(est)
277
+ n_features = coef.size
278
+ out[kind]["nonzero"].append(int(np.sum(np.abs(coef) > 1e-6)))
279
+ out[kind]["l2norm"].append(round(float(np.linalg.norm(coef)), 5))
280
+ pred = pipe.predict(Xval)
281
+ out[kind]["val_rmse"].append(round(float(np.sqrt(mean_squared_error(yval, pred))), 5))
282
+ out["n_features"] = int(n_features)
283
+ return out
284
+ except Exception as exc: # a diagnostic must never sink the pipeline
285
+ print(f"[stage1] regularization path skipped: {type(exc).__name__}: {exc}")
286
+ return None
287
+
288
+
289
+ def _print_path(path: Optional[dict]) -> None:
290
+ """Compact console view of the sweep so the mechanism is visible during a run."""
291
+ if not path:
292
+ return
293
+ F = path.get("n_features", 0)
294
+ if path.get("type") == "classification":
295
+ Cs = path["C"]
296
+ l1nz, l2n = path["l1"]["nonzero"], path["l2"]["l2norm"]
297
+ lo, hi = 0, len(Cs) - 1
298
+ print(
299
+ f"[stage1] reg-path (of {F} features): "
300
+ f"L1 keeps {l1nz[lo]}@C={Cs[lo]:g} -> {l1nz[hi]}@C={Cs[hi]:g} (sparsity); "
301
+ f"L2 |coef| grows {l2n[lo]:g} -> {l2n[hi]:g} (shrinkage)"
302
+ )
303
+ elif path.get("type") == "regression":
304
+ al = path["alpha"]
305
+ lnz = path["lasso"]["nonzero"]
306
+ lo, hi = 0, len(al) - 1
307
+ print(
308
+ f"[stage1] reg-path (of {F} features): "
309
+ f"Lasso keeps {lnz[hi]}@alpha={al[hi]:g} -> {lnz[lo]}@alpha={al[lo]:g} (sparsity)"
310
+ )
311
+
312
+
313
+ # --------------------------------------------------------------------------- #
314
+ # Learning curve — bias/variance on the strongest baseline only (cost-bounded)
315
+ # --------------------------------------------------------------------------- #
316
+ def _learning_curve(best: ModelResult, bundle: DataBundle, cfg: Config) -> Optional[dict]:
317
+ """Train/validation score vs. training-set size, for the best baseline.
318
+
319
+ Uses the pipeline (so preprocessing is refit inside each fold — no leakage),
320
+ a time-aware CV when we have a time column, and only 5 points on 3 folds to
321
+ stay cheap. ``learning_curve`` uses cross_validate internally, so
322
+ ``TimeSeriesSplit`` is fine here (unlike stacking's cross_val_predict).
323
+ """
324
+ try:
325
+ from sklearn.base import clone
326
+ from sklearn.model_selection import (
327
+ KFold,
328
+ StratifiedKFold,
329
+ TimeSeriesSplit,
330
+ learning_curve,
331
+ )
332
+
333
+ n_classes = len(bundle.target_classes) if bundle.target_classes else 2
334
+ if bundle.task == "classification":
335
+ scoring = "roc_auc" if n_classes == 2 else "roc_auc_ovr"
336
+ else:
337
+ scoring = "neg_root_mean_squared_error"
338
+
339
+ if bundle.has_time:
340
+ cv = TimeSeriesSplit(n_splits=3)
341
+ elif bundle.task == "classification":
342
+ cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=cfg.random_state)
343
+ else:
344
+ cv = KFold(n_splits=3, shuffle=True, random_state=cfg.random_state)
345
+
346
+ sizes, train_scores, val_scores = learning_curve(
347
+ clone(best.estimator),
348
+ bundle.X_train,
349
+ bundle.y_train,
350
+ train_sizes=np.linspace(0.1, 1.0, 5),
351
+ cv=cv,
352
+ scoring=scoring,
353
+ n_jobs=-1,
354
+ shuffle=False,
355
+ )
356
+ return {
357
+ "model": best.name,
358
+ "metric": scoring,
359
+ "train_sizes": [int(s) for s in sizes],
360
+ "train_mean": [round(float(v), 4) for v in train_scores.mean(axis=1)],
361
+ "val_mean": [round(float(v), 4) for v in val_scores.mean(axis=1)],
362
+ }
363
+ except Exception as exc: # never let a diagnostic sink the pipeline
364
+ print(f"[stage1] learning curve skipped: {type(exc).__name__}: {exc}")
365
+ return None
366
+
367
+
368
+ # --------------------------------------------------------------------------- #
369
+ # Entry point
370
+ # --------------------------------------------------------------------------- #
371
+ def run(bundle: DataBundle, cfg: Config, ctx: Optional[dict] = None) -> dict:
372
+ """Fit regularized baselines; return metrics + coefficient diagnostics.
373
+
374
+ Side effect: registers every fitted result in ``ctx['results']`` and records
375
+ ``ctx['baseline_best']`` so later stages (and Stage 2's overall-best pick) can
376
+ see the baselines.
377
+ """
378
+ ctx = ctx if ctx is not None else {}
379
+ task = bundle.task
380
+ models = _classification_models(cfg) if task == "classification" else _regression_models(cfg)
381
+
382
+ results: list[ModelResult] = []
383
+ for name, est in models.items():
384
+ try:
385
+ res = fit_evaluate(name, est, bundle, cfg)
386
+ res.extra["coefficients"] = _coef_diagnostics(res)
387
+ results.append(res)
388
+ log_result("stage1", res, task, cfg.report.fbeta)
389
+ except Exception as exc:
390
+ print(f"[stage1] {name} failed: {type(exc).__name__}: {exc} — skipped.")
391
+
392
+ if not results:
393
+ return {"status": "error", "stage": "baselines", "reason": "no baseline trained"}
394
+
395
+ best = pick_best(results, task)
396
+ reg = ctx.setdefault("results", {})
397
+ for r in results:
398
+ reg[r.name] = r
399
+ ctx["baseline_best"] = best
400
+
401
+ lc = _learning_curve(best, bundle, cfg)
402
+ path = _regularization_path(bundle, cfg)
403
+ _print_path(path)
404
+ note = _regularization_note(results, task, path)
405
+
406
+ return {
407
+ "status": "ok",
408
+ "stage": "baselines",
409
+ "primary_metric": primary_metric_name(task),
410
+ "best": best.name,
411
+ "models": [r.summary() for r in results],
412
+ "leaderboard": leaderboard(results, task),
413
+ "learning_curve": lc,
414
+ "regularization_path": path,
415
+ "note": note,
416
+ }
@@ -0,0 +1,260 @@
1
+ """Stage 2 — Boosting ensembles + a stacking meta-model (the workhorses).
2
+
3
+ Three gradient-boosting libraries (XGBoost, LightGBM, CatBoost), a RandomForest
4
+ as an older-ensemble reference point, and a stacking model whose meta-learner is
5
+ a regularized linear model — RegStack in spirit.
6
+
7
+ Draws on Paper 1 (RegStack, ESWA 2025 — regularized stacking) and Paper 2
8
+ (BO-XGBoost, ESWA 2026, DOI 10.1016/j.eswa.2026.131260 — XGBoost as the core
9
+ learner, later tuned in Stage 3).
10
+
11
+ Robustness choices worth knowing:
12
+ * **Optional dependencies.** Each booster is imported lazily; if a library
13
+ isn't installed (or fails to construct), that model is skipped with a message
14
+ and the pipeline carries on. The tool degrades gracefully instead of dying.
15
+ * **Stacking CV must be a partition.** scikit-learn's StackingClassifier builds
16
+ meta-features with ``cross_val_predict``, which requires every sample to fall
17
+ in exactly one validation fold. ``TimeSeriesSplit`` is *not* a partition
18
+ (its earliest rows never appear in a validation fold) and would raise
19
+ "cross_val_predict only works for partitions". So the meta-feature CV uses
20
+ stratified/plain k-fold. Time-honesty is preserved because the held-out
21
+ *temporal* test set is never part of this CV and is only scored at the end.
22
+ * **No nested oversubscription.** Base learners use all cores (n_jobs=-1);
23
+ stacking itself runs folds sequentially (n_jobs=1) so we don't spawn
24
+ cores^2 threads on a laptop.
25
+ * **Uniform preprocessing.** Trees don't need scaling/one-hot, but every model
26
+ shares the one leakage-safe preprocessor so the comparison is clean and the
27
+ system stays simple. (Scaling is harmless to trees.)
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from typing import Optional
32
+
33
+ import numpy as np
34
+
35
+ from .config import Config
36
+ from .data import DataBundle
37
+ from .metrics import primary_metric_name
38
+ from .modeling import (
39
+ ModelResult,
40
+ feature_names_out,
41
+ fit_evaluate,
42
+ leaderboard,
43
+ log_result,
44
+ pick_best,
45
+ )
46
+
47
+
48
+ # --------------------------------------------------------------------------- #
49
+ # Model builders (lazy imports: a missing library skips only that model)
50
+ # --------------------------------------------------------------------------- #
51
+ def _build_xgboost(task: str, rs: int):
52
+ import xgboost as xgb # noqa: F401 (import may raise ImportError -> skipped)
53
+
54
+ params = dict(
55
+ n_estimators=300, learning_rate=0.1, max_depth=6,
56
+ subsample=0.9, colsample_bytree=0.9,
57
+ tree_method="hist", # histogram splits -> scales to large data
58
+ random_state=rs, n_jobs=-1,
59
+ )
60
+ if task == "classification":
61
+ return xgb.XGBClassifier(eval_metric="logloss", **params)
62
+ return xgb.XGBRegressor(**params)
63
+
64
+
65
+ def _build_lightgbm(task: str, rs: int):
66
+ import lightgbm as lgb
67
+
68
+ params = dict(
69
+ n_estimators=300, learning_rate=0.1, num_leaves=31, max_depth=-1,
70
+ subsample=0.9, colsample_bytree=0.9,
71
+ random_state=rs, n_jobs=-1, verbose=-1, # verbose=-1 silences LightGBM
72
+ )
73
+ if task == "classification":
74
+ return lgb.LGBMClassifier(**params)
75
+ return lgb.LGBMRegressor(**params)
76
+
77
+
78
+ def _build_catboost(task: str, rs: int):
79
+ from catboost import CatBoostClassifier, CatBoostRegressor
80
+
81
+ params = dict(
82
+ iterations=300, learning_rate=0.1, depth=6, random_seed=rs,
83
+ verbose=0,
84
+ allow_writing_files=False, # don't litter a catboost_info/ dir (Windows!)
85
+ thread_count=-1,
86
+ )
87
+ if task == "classification":
88
+ return CatBoostClassifier(**params)
89
+ return CatBoostRegressor(**params)
90
+
91
+
92
+ def _build_random_forest(task: str, rs: int):
93
+ from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
94
+
95
+ params = dict(n_estimators=300, n_jobs=-1, random_state=rs)
96
+ if task == "classification":
97
+ return RandomForestClassifier(**params)
98
+ return RandomForestRegressor(**params)
99
+
100
+
101
+ _BUILDERS = {
102
+ "xgboost": _build_xgboost,
103
+ "lightgbm": _build_lightgbm,
104
+ "catboost": _build_catboost,
105
+ "random_forest": _build_random_forest,
106
+ }
107
+
108
+
109
+ # --------------------------------------------------------------------------- #
110
+ # Stacking meta-model
111
+ # --------------------------------------------------------------------------- #
112
+ def _build_stacking(task: str, available: list[str], rs: int, cv_splits: int):
113
+ """A stacking ensemble over the available boosters with a regularized meta-learner.
114
+
115
+ See the module docstring for why the internal CV is k-fold, not TimeSeriesSplit.
116
+ """
117
+ from sklearn.ensemble import StackingClassifier, StackingRegressor
118
+ from sklearn.linear_model import LogisticRegression, RidgeCV
119
+ from sklearn.model_selection import KFold, StratifiedKFold
120
+
121
+ # Fresh, unfitted base estimators (never reuse ones already fitted above).
122
+ estimators = [(name, _BUILDERS[name](task, rs)) for name in available]
123
+
124
+ if task == "classification":
125
+ cv = StratifiedKFold(n_splits=cv_splits, shuffle=True, random_state=rs)
126
+ # Regularized (L2 by default) linear meta-learner. No penalty= arg: it is
127
+ # the default on old sklearn and is deprecated on 1.8+ (would warn).
128
+ final = LogisticRegression(max_iter=2000)
129
+ return StackingClassifier(
130
+ estimators=estimators, final_estimator=final, cv=cv,
131
+ n_jobs=1, passthrough=False,
132
+ )
133
+ cv = KFold(n_splits=cv_splits, shuffle=True, random_state=rs)
134
+ final = RidgeCV() # regularized linear meta-learner for regression
135
+ return StackingRegressor(
136
+ estimators=estimators, final_estimator=final, cv=cv,
137
+ n_jobs=1, passthrough=False,
138
+ )
139
+
140
+
141
+ # --------------------------------------------------------------------------- #
142
+ # Feature-importance diagnostics (tree models expose feature_importances_)
143
+ # --------------------------------------------------------------------------- #
144
+ def _importance_diagnostics(res: ModelResult) -> dict:
145
+ model = res.estimator.named_steps["model"]
146
+ imp = getattr(model, "feature_importances_", None)
147
+ if imp is None:
148
+ return {}
149
+ imp = np.asarray(imp, dtype=float)
150
+ names = feature_names_out(res.estimator)
151
+ order = np.argsort(imp)[::-1][:15]
152
+ top = [
153
+ [names[i] if i < len(names) else f"f{i}", round(float(imp[i]), 5)]
154
+ for i in order
155
+ ]
156
+ return {"top_features": top}
157
+
158
+
159
+ # --------------------------------------------------------------------------- #
160
+ # Entry point
161
+ # --------------------------------------------------------------------------- #
162
+ def run(bundle: DataBundle, cfg: Config, ctx: Optional[dict] = None) -> dict:
163
+ """Train ensembles + stacking; return a leaderboard and stash the best models.
164
+
165
+ Side effects on ``ctx``:
166
+ * ``ctx['results']`` — every fitted ModelResult, keyed by name
167
+ * ``ctx['ensemble_best']`` — best Stage-2 model
168
+ * ``ctx['best_booster']`` / ``ctx['best_booster_name']`` — best *single*
169
+ booster (excludes stacking) -> what Stage 3 will tune
170
+ * ``ctx['best_overall']`` / ``ctx['best_model']`` — best across ALL stages
171
+ so far (baselines + ensembles) -> what Stages 4/5 explain & monitor
172
+ """
173
+ ctx = ctx if ctx is not None else {}
174
+ task = bundle.task
175
+ rs = cfg.random_state
176
+ requested = list(cfg.models.boosters)
177
+
178
+ results: list[ModelResult] = []
179
+ available: list[str] = []
180
+
181
+ for name in requested:
182
+ builder = _BUILDERS.get(name)
183
+ if builder is None:
184
+ print(f"[stage2] unknown booster '{name}' — skipped.")
185
+ continue
186
+ try:
187
+ est = builder(task, rs)
188
+ except ImportError as exc:
189
+ print(f"[stage2] '{name}' unavailable (library not installed): {exc} — skipped.")
190
+ continue
191
+ except Exception as exc:
192
+ print(f"[stage2] '{name}' could not be constructed: {exc} — skipped.")
193
+ continue
194
+ try:
195
+ res = fit_evaluate(name, est, bundle, cfg)
196
+ res.extra["importance"] = _importance_diagnostics(res)
197
+ results.append(res)
198
+ available.append(name)
199
+ log_result("stage2", res, task, cfg.report.fbeta)
200
+ except Exception as exc:
201
+ print(f"[stage2] '{name}' failed during fit/eval: {type(exc).__name__}: {exc} — skipped.")
202
+
203
+ # Stacking needs at least two working base learners to be meaningful.
204
+ if cfg.models.stacking:
205
+ if len(available) >= 2:
206
+ try:
207
+ stack = _build_stacking(task, available, rs, cfg.tuning.cv_splits)
208
+ res = fit_evaluate("stacking", stack, bundle, cfg)
209
+ results.append(res)
210
+ log_result("stage2", res, task, cfg.report.fbeta)
211
+ except Exception as exc:
212
+ print(f"[stage2] stacking failed: {type(exc).__name__}: {exc} — skipped.")
213
+ else:
214
+ print(f"[stage2] stacking skipped: needs >=2 base learners, have {len(available)}.")
215
+
216
+ if not results:
217
+ # Every booster was skipped (e.g. none of the libraries are installed).
218
+ # Don't starve downstream stages: fall back to the best baseline that
219
+ # Stage 1 already registered in the shared context.
220
+ reg = ctx.setdefault("results", {})
221
+ fallback = pick_best(list(reg.values()), task) if reg else None
222
+ if fallback is not None:
223
+ ctx["best_overall"] = fallback
224
+ ctx["best_model"] = fallback.estimator
225
+ return {
226
+ "status": "error",
227
+ "stage": "boosting",
228
+ "reason": "no ensemble model trained — are xgboost/lightgbm/catboost installed?",
229
+ "requested": requested,
230
+ "fell_back_to": fallback.name if fallback else None,
231
+ }
232
+
233
+ # Register in shared context.
234
+ reg = ctx.setdefault("results", {})
235
+ for r in results:
236
+ reg[r.name] = r
237
+
238
+ best_ensemble = pick_best(results, task)
239
+ single = [r for r in results if r.name in _BUILDERS] # exclude stacking
240
+ best_booster = pick_best(single, task) if single else best_ensemble
241
+ best_overall = pick_best(list(reg.values()), task) # incl. baselines
242
+
243
+ ctx["ensemble_best"] = best_ensemble
244
+ ctx["best_booster"] = best_booster
245
+ ctx["best_booster_name"] = best_booster.name if best_booster else None
246
+ ctx["best_overall"] = best_overall
247
+ ctx["best_model"] = best_overall.estimator if best_overall else None
248
+
249
+ return {
250
+ "status": "ok",
251
+ "stage": "boosting",
252
+ "primary_metric": primary_metric_name(task),
253
+ "available_boosters": available,
254
+ "skipped_boosters": [n for n in requested if n not in available],
255
+ "best": best_ensemble.name if best_ensemble else None,
256
+ "best_booster": best_booster.name if best_booster else None,
257
+ "best_overall": best_overall.name if best_overall else None,
258
+ "models": [r.summary() for r in results],
259
+ "leaderboard": leaderboard(list(reg.values()), task),
260
+ }