Spaces:
Running
Running
| """Train the single best-performing **road-closure** model and lay its full | |
| operating-point trade-off bare. | |
| Run: ``python -m src.train_best`` (add ``GRIDLOCK_NO_TUNE=1`` for a fast pass). | |
| Why a dedicated script? Road closure is the genuinely hard, high-value task | |
| (rare ~7% positive, partly-discretionary, leakage-prone). This entry point | |
| distils everything the experiments in this project established as *best*: | |
| 1. **No positive-class reweighting** (``scale_pos_weight = 1``). The classic | |
| neg/pos weighting *hurts* ranking on this rare target - it inflates recall at | |
| the cost of a distorted probability surface and lower PR-AUC. Leaving the loss | |
| unweighted and handling imbalance purely at the decision threshold ranks | |
| better (verified on the temporal hold-out: AP 0.302 -> 0.319, MCC 0.34 -> 0.41). | |
| 2. **Stacked ensemble** - LightGBM + XGBoost + CatBoost combined by a logistic | |
| meta-learner on out-of-fold predictions, then isotonic-calibrated. Beats any | |
| single base model on the future test set. | |
| 3. **The decision threshold is a policy choice, not a model property.** We print | |
| the recall-, F1-, F2- and MCC-optimal operating points so the control room can | |
| choose where to sit on the precision/recall curve. A single recall-favoured | |
| threshold makes MCC look low even though the ranking is unchanged - this table | |
| makes that explicit. | |
| Outputs: | |
| models/closure_model_best.joblib deployable calibrated stack | |
| models/preprocessor_closure_best.joblib matching feature pipeline | |
| reports/closure_best_operating_points.json metrics + every operating point | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import time | |
| import joblib | |
| import numpy as np | |
| import pandas as pd | |
| from . import config as C | |
| from . import evaluate as E | |
| from . import models as M | |
| from .feature_engineering import build_features | |
| from .preprocessing import Preprocessor | |
| from .splits import split_report, stratified_folds, temporal_split, time_series_folds | |
| from .targets import build_targets | |
| from .text_features import compute_embeddings | |
| TUNE = os.environ.get("GRIDLOCK_NO_TUNE", "0") != "1" | |
| # scale_pos_weight = 1.0 ranks best; raise toward ~3 to buy recall at some AP cost. | |
| BEST_SCALE_POS_WEIGHT = float(os.environ.get("GRIDLOCK_CLOSURE_SPW", "1.0")) | |
| def _fmt(p: dict) -> str: | |
| return (f"thr={p['threshold']:.3f} recall={p['recall']:.3f} " | |
| f"precision={p['precision']:.3f} F1={p['f1']:.3f} " | |
| f"F2={p['f2']:.3f} MCC={p['mcc']:.3f}") | |
| def main(): | |
| print(f"[train_best] target=road_closure tuning={'ON' if TUNE else 'OFF'} " | |
| f"scale_pos_weight={BEST_SCALE_POS_WEIGHT}") | |
| df = pd.read_parquet(C.CLEAN_PARQUET) | |
| if C.TARGET_CLOSURE not in df.columns: | |
| df = build_targets(df, save=False) | |
| if "event_family" not in df.columns: | |
| df = build_features(df, save=False) | |
| df = df.reset_index(drop=True) | |
| emb = compute_embeddings(df) | |
| train_mask, test_mask = temporal_split(df) | |
| print(split_report(df, train_mask, test_mask)) | |
| prep = Preprocessor(target=C.TARGET_CLOSURE, | |
| n_emb_components=C.CLOSURE_EMB_PCA_COMPONENTS) | |
| prep.fit(df[train_mask], emb[train_mask]) | |
| X_tr = prep.transform(df[train_mask], emb[train_mask]) | |
| X_te = prep.transform(df[test_mask], emb[test_mask]) | |
| y_tr = df.loc[train_mask, C.TARGET_CLOSURE].to_numpy() | |
| y_te = df.loc[test_mask, C.TARGET_CLOSURE].to_numpy() | |
| oof_folds = stratified_folds(y_tr) | |
| tune_folds = time_series_folds(len(y_tr)) | |
| t0 = time.time() | |
| artifact, diag = M.train_classification_task( | |
| X_tr, y_tr, X_te, prep.categorical_feature_names, oof_folds, tune_folds, | |
| target=C.TARGET_CLOSURE, tune=TUNE, beta=2.0, | |
| scale_pos_weight=BEST_SCALE_POS_WEIGHT, | |
| ) | |
| test_prob = artifact.predict_proba(X_te) | |
| metrics = E.classification_metrics(y_te, test_prob, artifact.threshold, beta=2.0) | |
| points = E.operating_points(y_te, test_prob) | |
| metrics["operating_points"] = points | |
| metrics["oof"] = diag | |
| metrics["train_seconds"] = round(time.time() - t0, 1) | |
| print("\n[train_best] === road-closure (best config) ===") | |
| print(f" test PR-AUC (AP) = {metrics['average_precision']:.3f} " | |
| f"(lift x{metrics['ap_lift_over_base']:.1f} over {metrics['positive_rate']:.3f} base)") | |
| print(f" ROC-AUC = {metrics['roc_auc']:.3f} Brier = {metrics['brier']:.3f}") | |
| print(f" base OOF AP: " + ", ".join(f"{k}={v:.3f}" for k, v in diag["base_oof_ap"].items())) | |
| print("\n Operating points (pick one as the deployment policy):") | |
| for label, p in points.items(): | |
| print(f" {label:14s} {_fmt(p)}") | |
| print("\n -> 'recall>=0.8' best for never missing a closure (pre-stage barricades);") | |
| print(" 'mcc_optimal' best for balanced precision/recall.") | |
| # Default the deployable threshold to MCC-optimal (balanced) - operators can | |
| # override by reading any operating point from the saved JSON. | |
| artifact.threshold = float(points["mcc_optimal"]["threshold"]) | |
| joblib.dump(artifact, C.MODELS_DIR / "closure_model_best.joblib") | |
| joblib.dump(prep, C.MODELS_DIR / "preprocessor_closure_best.joblib") | |
| E.save_metrics(metrics, "closure_best_operating_points.json") | |
| E.plot_pr_calibration(y_te, test_prob, "closure_best") | |
| print("\n[train_best] saved closure_model_best.joblib + operating points JSON.") | |
| return metrics | |
| if __name__ == "__main__": | |
| main() | |