Gridlock / src /models.py
parvmittal07's picture
Initial Demo complete
01eb82e
Raw
History Blame Contribute Delete
16.1 kB
"""Modelling toolbox: tuned gradient boosting, OOF stacking, calibration.
Design choices that push past a single off-the-shelf classifier:
* **Stacked ensemble** - LightGBM (Optuna-tuned) + XGBoost + CatBoost give three
decorrelated views of the same features; a logistic meta-learner combines their
out-of-fold (OOF) predictions. OOF stacking means the meta-learner never sees a
base model predict on data it was trained on.
* **Leakage-safe calibration & thresholds** - probability calibration and the
decision threshold are both fit on OOF predictions of the *training* data, so
the chronological test set stays untouched until final evaluation.
* **Imbalance-aware** - the rare road-closure class uses ``scale_pos_weight`` and
the threshold is chosen to maximise F-beta (recall-favoured), not accuracy.
* **Duration with uncertainty** - a log-target point model plus quantile models
(p10/p50/p90) yield calibrated prediction *intervals*, not just point guesses.
"""
from __future__ import annotations
import logging
import warnings
from dataclasses import dataclass, field
import numpy as np
import optuna
import pandas as pd
from sklearn.isotonic import IsotonicRegression
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, fbeta_score
from . import config as C
optuna.logging.set_verbosity(optuna.logging.WARNING)
warnings.filterwarnings("ignore", category=UserWarning)
logging.getLogger("lightgbm").setLevel(logging.ERROR)
F_BETA = 2.0 # recall is ~F_BETA times as important as precision for closures
# --------------------------------------------------------------------------- #
# Base model factories (each handles categorical dtype in its own way)
# --------------------------------------------------------------------------- #
def make_lightgbm_classifier(params: dict, scale_pos_weight: float = 1.0):
from lightgbm import LGBMClassifier
base = dict(
objective="binary", n_estimators=600, learning_rate=0.03, num_leaves=31,
subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_lambda=1.0,
random_state=C.RANDOM_STATE, n_jobs=-1, verbosity=-1,
scale_pos_weight=scale_pos_weight,
)
base.update(params or {})
return LGBMClassifier(**base)
def make_xgboost_classifier(scale_pos_weight: float = 1.0):
from xgboost import XGBClassifier
return XGBClassifier(
n_estimators=500, learning_rate=0.03, max_depth=6, subsample=0.8,
colsample_bytree=0.8, reg_lambda=1.0, min_child_weight=2,
tree_method="hist", enable_categorical=True, eval_metric="aucpr",
scale_pos_weight=scale_pos_weight, random_state=C.RANDOM_STATE, n_jobs=-1,
)
def make_catboost_classifier(scale_pos_weight: float, cat_features: list[str]):
from catboost import CatBoostClassifier
return CatBoostClassifier(
iterations=500, learning_rate=0.03, depth=6, l2_leaf_reg=3.0,
loss_function="Logloss", eval_metric="PRAUC", cat_features=cat_features,
scale_pos_weight=scale_pos_weight, random_seed=C.RANDOM_STATE,
verbose=False, allow_writing_files=False,
)
def _catboost_frame(X: pd.DataFrame, cat_features: list[str]) -> pd.DataFrame:
"""CatBoost rejects NaN in categorical columns - replace with a token."""
X = X.copy()
for c in cat_features:
X[c] = X[c].astype("object").where(X[c].notna(), "missing").astype(str)
return X
# --------------------------------------------------------------------------- #
# Optuna tuning for the primary LightGBM classifier
# --------------------------------------------------------------------------- #
def tune_lightgbm_classifier(X, y, folds, scale_pos_weight, n_trials=C.OPTUNA_TRIALS):
y = np.asarray(y)
def objective(trial):
params = dict(
num_leaves=trial.suggest_int("num_leaves", 15, 127),
learning_rate=trial.suggest_float("learning_rate", 0.01, 0.1, log=True),
n_estimators=trial.suggest_int("n_estimators", 200, 900),
min_child_samples=trial.suggest_int("min_child_samples", 5, 80),
subsample=trial.suggest_float("subsample", 0.6, 1.0),
colsample_bytree=trial.suggest_float("colsample_bytree", 0.5, 1.0),
reg_alpha=trial.suggest_float("reg_alpha", 1e-3, 10.0, log=True),
reg_lambda=trial.suggest_float("reg_lambda", 1e-3, 10.0, log=True),
)
scores = []
for tr_idx, va_idx in folds:
if y[va_idx].sum() < 2: # need positives to score AP
continue
model = make_lightgbm_classifier(params, scale_pos_weight)
model.fit(X.iloc[tr_idx], y[tr_idx])
p = model.predict_proba(X.iloc[va_idx])[:, 1]
scores.append(average_precision_score(y[va_idx], p))
return float(np.mean(scores)) if scores else 0.0
study = optuna.create_study(direction="maximize",
sampler=optuna.samplers.TPESampler(seed=C.RANDOM_STATE))
study.optimize(objective, n_trials=n_trials, timeout=C.OPTUNA_TIMEOUT,
show_progress_bar=False)
return study.best_params
# --------------------------------------------------------------------------- #
# OOF stacking utilities
# --------------------------------------------------------------------------- #
def _oof_and_full(factory, X_tr, y_tr, X_te, folds, catboost=False, cat_features=None):
"""Return OOF train predictions and mean test predictions for one base model."""
y_tr = np.asarray(y_tr)
oof = np.zeros(len(X_tr))
test_acc = np.zeros(len(X_te))
Xtr_use = _catboost_frame(X_tr, cat_features) if catboost else X_tr
Xte_use = _catboost_frame(X_te, cat_features) if catboost else X_te
n = len(folds)
for tr_idx, va_idx in folds:
model = factory()
model.fit(Xtr_use.iloc[tr_idx], y_tr[tr_idx])
oof[va_idx] = model.predict_proba(Xtr_use.iloc[va_idx])[:, 1]
test_acc += model.predict_proba(Xte_use)[:, 1] / n
# Refit on all training rows for the deployable model.
full = factory()
full.fit(Xtr_use, y_tr)
return oof, test_acc, full
@dataclass
class ClassificationArtifact:
target: str
base_models: dict = field(default_factory=dict)
cat_features: list[str] = field(default_factory=list)
meta: LogisticRegression | None = None
calibrator: IsotonicRegression | None = None
threshold: float = 0.5
model_order: list[str] = field(default_factory=list)
def _stack_matrix(self, X: pd.DataFrame) -> np.ndarray:
cols = []
for name in self.model_order:
model = self.base_models[name]
Xu = _catboost_frame(X, self.cat_features) if name == "catboost" else X
cols.append(model.predict_proba(Xu)[:, 1])
return np.column_stack(cols)
def predict_proba(self, X: pd.DataFrame) -> np.ndarray:
meta_in = self._stack_matrix(X)
p = self.meta.predict_proba(meta_in)[:, 1]
if self.calibrator is not None:
p = self.calibrator.transform(p)
return p
def predict(self, X: pd.DataFrame) -> np.ndarray:
return (self.predict_proba(X) >= self.threshold).astype(int)
def best_threshold(y_true, y_prob, beta=F_BETA) -> float:
"""Threshold maximising F-beta over a fine grid (recall-favoured)."""
grid = np.linspace(0.05, 0.95, 181)
best_t, best_s = 0.5, -1.0
for t in grid:
s = fbeta_score(y_true, (y_prob >= t).astype(int), beta=beta, zero_division=0)
if s > best_s:
best_s, best_t = s, t
return float(best_t)
def train_classification_task(X_tr, y_tr, X_te, cat_features, oof_folds, tune_folds,
target=C.TARGET_CLOSURE, tune=True,
beta=F_BETA, scale_pos_weight=None) -> tuple[ClassificationArtifact, dict]:
"""Tune LightGBM, build the OOF stack, calibrate, pick a threshold.
``tune_folds`` are temporal (honest HP selection); ``oof_folds`` cover every
training row exactly once (full-coverage stacking / calibration). Returns the
deployable artifact and an OOF diagnostics dict.
``scale_pos_weight`` controls positive-class reweighting. Default ``None`` ->
1.0 (no reweighting): on this rare, partly-discretionary closure target the
classic neg/pos weighting *hurts* PR-AUC because it distorts the probability
surface; leaving the loss unweighted and handling imbalance purely at the
decision threshold ranks better (verified: AP 0.302 -> 0.319).
"""
y_tr = np.asarray(y_tr)
pos = max(int(y_tr.sum()), 1)
neg_over_pos = float((len(y_tr) - pos) / pos)
spw = 1.0 if scale_pos_weight is None else float(scale_pos_weight)
lgb_params = tune_lightgbm_classifier(X_tr, y_tr, tune_folds, spw) if tune else {}
factories = {
"lightgbm": lambda: make_lightgbm_classifier(lgb_params, spw),
"xgboost": lambda: make_xgboost_classifier(spw),
"catboost": lambda: make_catboost_classifier(spw, cat_features),
}
oof_cols, test_cols, base_models, order = {}, {}, {}, []
for name, fac in factories.items():
oof, test_p, full = _oof_and_full(
fac, X_tr, y_tr, X_te, oof_folds,
catboost=(name == "catboost"), cat_features=cat_features,
)
oof_cols[name] = oof
test_cols[name] = test_p
base_models[name] = full
order.append(name)
oof_matrix = np.column_stack([oof_cols[n] for n in order])
meta = LogisticRegression(max_iter=1000, class_weight="balanced")
meta.fit(oof_matrix, y_tr)
oof_meta = meta.predict_proba(oof_matrix)[:, 1]
# Isotonic calibration on OOF meta predictions (leakage-safe).
calibrator = IsotonicRegression(out_of_bounds="clip")
calibrator.fit(oof_meta, y_tr)
oof_cal = calibrator.transform(oof_meta)
threshold = best_threshold(y_tr, oof_cal, beta=beta)
artifact = ClassificationArtifact(
target=target, base_models=base_models, cat_features=cat_features,
meta=meta, calibrator=calibrator, threshold=threshold, model_order=order,
)
diagnostics = {
"oof_ap": float(average_precision_score(y_tr, oof_cal)),
"scale_pos_weight": spw,
"neg_over_pos": neg_over_pos,
"threshold": threshold,
"lgb_params": lgb_params,
"base_oof_ap": {n: float(average_precision_score(y_tr, oof_cols[n])) for n in order},
}
return artifact, diagnostics
# --------------------------------------------------------------------------- #
# Duration regression (log target + quantile intervals)
# --------------------------------------------------------------------------- #
def make_lightgbm_regressor(params: dict, objective="regression", alpha=None):
from lightgbm import LGBMRegressor
base = dict(
objective=objective, n_estimators=600, learning_rate=0.03, num_leaves=31,
subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_lambda=1.0,
random_state=C.RANDOM_STATE, n_jobs=-1, verbosity=-1,
)
if alpha is not None:
base["alpha"] = alpha
base.update(params or {})
return LGBMRegressor(**base)
def tune_lightgbm_regressor(X, y_log, folds, n_trials=C.OPTUNA_TRIALS):
from sklearn.metrics import mean_absolute_error
y_log = np.asarray(y_log)
def objective(trial):
params = dict(
num_leaves=trial.suggest_int("num_leaves", 15, 127),
learning_rate=trial.suggest_float("learning_rate", 0.01, 0.1, log=True),
n_estimators=trial.suggest_int("n_estimators", 200, 900),
min_child_samples=trial.suggest_int("min_child_samples", 5, 80),
subsample=trial.suggest_float("subsample", 0.6, 1.0),
colsample_bytree=trial.suggest_float("colsample_bytree", 0.5, 1.0),
reg_lambda=trial.suggest_float("reg_lambda", 1e-3, 10.0, log=True),
)
scores = []
for tr_idx, va_idx in folds:
if len(va_idx) < 5:
continue
m = make_lightgbm_regressor(params)
m.fit(X.iloc[tr_idx], y_log[tr_idx])
pred = m.predict(X.iloc[va_idx])
scores.append(mean_absolute_error(y_log[va_idx], pred))
return float(np.mean(scores)) if scores else 1e9
study = optuna.create_study(direction="minimize",
sampler=optuna.samplers.TPESampler(seed=C.RANDOM_STATE))
study.optimize(objective, n_trials=n_trials, timeout=C.OPTUNA_TIMEOUT,
show_progress_bar=False)
return study.best_params
@dataclass
class DurationArtifact:
point_model: object = None
quantile_models: dict = field(default_factory=dict)
log_cap_minutes: float = None
conformal_delta: float = 0.0 # CQR correction in log space
def predict_minutes(self, X: pd.DataFrame) -> np.ndarray:
return np.expm1(self.point_model.predict(X))
def predict_quantiles(self, X: pd.DataFrame) -> dict:
out = {}
for q, m in self.quantile_models.items():
pred = m.predict(X)
# Widen the outer quantiles by the conformal correction so the
# empirical interval hits its nominal coverage.
if q <= 0.1:
pred = pred - self.conformal_delta
elif q >= 0.9:
pred = pred + self.conformal_delta
out[q] = np.expm1(np.clip(pred, 0, None))
return out
def _conformal_delta(y_lo, y_hi, y_true, coverage=0.8) -> float:
"""CQR correction: the (n+1)(1-alpha)/n empirical quantile of the two-sided
conformity score max(lo - y, y - hi). Tightens / loosens the quantile band so
the interval is honestly calibrated on exchangeable data.
"""
scores = np.maximum(y_lo - y_true, y_true - y_hi)
n = len(scores)
if n == 0:
return 0.0
level = min(1.0, np.ceil((n + 1) * coverage) / n)
return float(max(0.0, np.quantile(scores, level)))
def train_duration_task(X_tr, y_tr_minutes, cap_minutes, folds, tune=True) -> tuple[DurationArtifact, dict]:
"""Train the duration point model + conformalized quantile intervals.
The **point** model uses a winsorised (p99-capped) log target so it is robust
to the multi-week construction tail. The **quantile** models use the *uncapped*
log target. A final **conformal** correction is calibrated on the most recent
slice of training data so the 80% interval actually covers ~80% (plain
quantile regression systematically under-covers a heavy-tailed target).
"""
y_tr_minutes = np.asarray(y_tr_minutes, dtype=float)
y_point = np.log1p(np.clip(y_tr_minutes, a_min=None, a_max=cap_minutes))
y_full = np.log1p(y_tr_minutes)
params = tune_lightgbm_regressor(X_tr, y_point, folds) if tune else {}
point = make_lightgbm_regressor(params)
point.fit(X_tr, y_point)
# Hold out the most recent 20% of (time-ordered) train rows for conformal
# calibration - mirrors the future test set.
n = len(X_tr)
cut = int(round(n * 0.8))
fit_idx, cal_idx = np.arange(cut), np.arange(cut, n)
quantile_models = {}
cal_preds = {}
for q in (0.1, 0.5, 0.9):
if len(cal_idx) > 0:
qm_cal = make_lightgbm_regressor(params, objective="quantile", alpha=q)
qm_cal.fit(X_tr.iloc[fit_idx], y_full[fit_idx])
cal_preds[q] = qm_cal.predict(X_tr.iloc[cal_idx])
# Deployable model refit on all train rows.
qm_full = make_lightgbm_regressor(params, objective="quantile", alpha=q)
qm_full.fit(X_tr, y_full)
quantile_models[q] = qm_full
delta = 0.0
if len(cal_idx) > 0:
delta = _conformal_delta(cal_preds[0.1], cal_preds[0.9], y_full[cal_idx], 0.8)
artifact = DurationArtifact(point_model=point, quantile_models=quantile_models,
log_cap_minutes=float(cap_minutes), conformal_delta=delta)
return artifact, {"lgb_params": params, "cap_minutes": float(cap_minutes),
"conformal_delta": delta}