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.
- tabml_guard/__init__.py +12 -0
- tabml_guard/__version__.py +2 -0
- tabml_guard/baselines.py +416 -0
- tabml_guard/boosting.py +260 -0
- tabml_guard/cli.py +165 -0
- tabml_guard/config.py +149 -0
- tabml_guard/configs/appliances_energy.yaml +50 -0
- tabml_guard/configs/bike_sharing.yaml +51 -0
- tabml_guard/configs/default.yaml +37 -0
- tabml_guard/configs/metro_traffic.yaml +49 -0
- tabml_guard/configs/occupancy_detection.yaml +48 -0
- tabml_guard/data.py +297 -0
- tabml_guard/drift.py +452 -0
- tabml_guard/explain.py +281 -0
- tabml_guard/make_index.py +198 -0
- tabml_guard/metrics.py +107 -0
- tabml_guard/modeling.py +221 -0
- tabml_guard/paths.py +80 -0
- tabml_guard/pdf_report.py +1264 -0
- tabml_guard/pipeline.py +228 -0
- tabml_guard/plots.py +79 -0
- tabml_guard/py.typed +1 -0
- tabml_guard/report.py +1053 -0
- tabml_guard/stats.py +155 -0
- tabml_guard/tuning.py +440 -0
- tabml_guard-0.1.0.dist-info/METADATA +510 -0
- tabml_guard-0.1.0.dist-info/RECORD +31 -0
- tabml_guard-0.1.0.dist-info/WHEEL +5 -0
- tabml_guard-0.1.0.dist-info/entry_points.txt +2 -0
- tabml_guard-0.1.0.dist-info/licenses/LICENSE +21 -0
- tabml_guard-0.1.0.dist-info/top_level.txt +1 -0
tabml_guard/__init__.py
ADDED
|
@@ -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
|
+
]
|
tabml_guard/baselines.py
ADDED
|
@@ -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
|
+
}
|
tabml_guard/boosting.py
ADDED
|
@@ -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
|
+
}
|