Gridlock / src /train_best.py
parvmittal07's picture
Initial Demo complete
01eb82e
Raw
History Blame Contribute Delete
5.43 kB
"""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()