Gridlock / src /evaluate.py
parvmittal07's picture
Initial Demo complete
01eb82e
Raw
History Blame Contribute Delete
7.45 kB
"""Skew-aware evaluation and explainability.
Accuracy is meaningless on a 7% positive class, so classification is judged on
PR-AUC (average precision), F-beta, MCC, balanced accuracy and calibration
(Brier). Duration is judged on MAE/RMSE in the original minute scale plus pinball
loss and interval coverage for the quantile predictions. SHAP summary plots are
saved for the deployable models.
"""
from __future__ import annotations
import json
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import (
average_precision_score, balanced_accuracy_score, brier_score_loss,
confusion_matrix, f1_score, fbeta_score, matthews_corrcoef,
mean_absolute_error, mean_squared_error, precision_score, r2_score,
recall_score, roc_auc_score,
)
from . import config as C
def classification_metrics(y_true, y_prob, threshold, beta=2.0) -> dict:
y_true = np.asarray(y_true)
y_pred = (y_prob >= threshold).astype(int)
pos_rate = float(y_true.mean())
out = {
"n": int(len(y_true)),
"positive_rate": pos_rate,
"average_precision": float(average_precision_score(y_true, y_prob)),
"ap_lift_over_base": float(average_precision_score(y_true, y_prob) / max(pos_rate, 1e-9)),
"roc_auc": float(roc_auc_score(y_true, y_prob)) if y_true.min() != y_true.max() else float("nan"),
"f1": float(f1_score(y_true, y_pred, zero_division=0)),
"f_beta": float(fbeta_score(y_true, y_pred, beta=beta, zero_division=0)),
"precision": float(precision_score(y_true, y_pred, zero_division=0)),
"recall": float(recall_score(y_true, y_pred, zero_division=0)),
"balanced_accuracy": float(balanced_accuracy_score(y_true, y_pred)),
"mcc": float(matthews_corrcoef(y_true, y_pred)) if len(np.unique(y_pred)) > 1 else 0.0,
"brier": float(brier_score_loss(y_true, y_prob)),
"threshold": float(threshold),
}
tn, fp, fn, tp = confusion_matrix(y_true, y_pred, labels=[0, 1]).ravel()
out["confusion"] = {"tn": int(tn), "fp": int(fp), "fn": int(fn), "tp": int(tp)}
return out
def operating_points(y_true, y_prob, recall_target=0.8) -> dict:
"""Report several decision thresholds so the precision/recall/MCC trade-off
is explicit.
A single F-beta threshold can make MCC look artificially low even when the
*ranking* (PR-AUC) is good - MCC and recall trade off against each other.
This returns the MCC-, F1- and F2-optimal thresholds plus the
highest-precision threshold that still hits ``recall_target``, so the
operator can choose where to sit on the curve.
"""
y_true = np.asarray(y_true)
grid = np.linspace(0.01, 0.95, 400)
def stats(t):
y_pred = (y_prob >= t).astype(int)
return {
"threshold": float(t),
"recall": float(recall_score(y_true, y_pred, zero_division=0)),
"precision": float(precision_score(y_true, y_pred, zero_division=0)),
"f1": float(f1_score(y_true, y_pred, zero_division=0)),
"f2": float(fbeta_score(y_true, y_pred, beta=2, zero_division=0)),
"mcc": float(matthews_corrcoef(y_true, y_pred)) if len(np.unique(y_pred)) > 1 else 0.0,
}
rows = [stats(t) for t in grid]
pts = {
"mcc_optimal": max(rows, key=lambda d: d["mcc"]),
"f1_optimal": max(rows, key=lambda d: d["f1"]),
"f2_optimal": max(rows, key=lambda d: d["f2"]),
}
hit = [r for r in rows if r["recall"] >= recall_target]
if hit:
pts[f"recall>={recall_target:g}"] = max(hit, key=lambda d: d["precision"])
return pts
def _pinball_loss(y_true, y_pred, q):
diff = y_true - y_pred
return float(np.mean(np.maximum(q * diff, (q - 1) * diff)))
def regression_metrics(y_true, y_pred, quantile_preds=None) -> dict:
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
mask = np.isfinite(y_true) & np.isfinite(y_pred)
y_true, y_pred = y_true[mask], y_pred[mask]
eps = 1e-6
out = {
"n": int(len(y_true)),
"mae_min": float(mean_absolute_error(y_true, y_pred)),
"rmse_min": float(np.sqrt(mean_squared_error(y_true, y_pred))),
"r2": float(r2_score(y_true, y_pred)) if len(y_true) > 2 else float("nan"),
"mape": float(np.mean(np.abs((y_true - y_pred) / np.clip(y_true, eps, None)))),
"median_ae_min": float(np.median(np.abs(y_true - y_pred))),
# Log-scale errors are more meaningful for a heavy-tailed target whose
# raw-minute R2 is dominated by a handful of multi-week outliers. The
# log-scale R2 is the honest goodness-of-fit for this skewed target.
"mae_log": float(mean_absolute_error(np.log1p(y_true), np.log1p(np.clip(y_pred, 0, None)))),
"r2_log": (float(r2_score(np.log1p(y_true), np.log1p(np.clip(y_pred, 0, None))))
if len(y_true) > 2 else float("nan")),
}
if quantile_preds is not None:
lo = np.asarray(quantile_preds[0.1])[mask]
hi = np.asarray(quantile_preds[0.9])[mask]
med = np.asarray(quantile_preds[0.5])[mask]
out["pinball_p50"] = _pinball_loss(y_true, med, 0.5)
out["interval_coverage_80"] = float(np.mean((y_true >= lo) & (y_true <= hi)))
out["interval_width_med_min"] = float(np.median(hi - lo))
return out
# --------------------------------------------------------------------------- #
# Plots
# --------------------------------------------------------------------------- #
def plot_pr_calibration(y_true, y_prob, name: str):
from sklearn.calibration import calibration_curve
from sklearn.metrics import precision_recall_curve
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
prec, rec, _ = precision_recall_curve(y_true, y_prob)
ap = average_precision_score(y_true, y_prob)
axes[0].plot(rec, prec, label=f"AP={ap:.3f}")
axes[0].axhline(np.mean(y_true), ls="--", c="grey", label="base rate")
axes[0].set(xlabel="Recall", ylabel="Precision", title=f"{name}: PR curve")
axes[0].legend()
frac_pos, mean_pred = calibration_curve(y_true, y_prob, n_bins=10, strategy="quantile")
axes[1].plot(mean_pred, frac_pos, "o-")
axes[1].plot([0, 1], [0, 1], ls="--", c="grey")
axes[1].set(xlabel="Predicted", ylabel="Observed", title=f"{name}: calibration")
fig.tight_layout()
path = C.FIGURES_DIR / f"{name}_pr_calibration.png"
fig.savefig(path, dpi=110)
plt.close(fig)
return path
def plot_shap_summary(model, X_sample, name: str, max_display=20):
try:
import shap
explainer = shap.TreeExplainer(model)
sv = explainer.shap_values(X_sample)
if isinstance(sv, list): # binary classifier -> take positive class
sv = sv[1] if len(sv) > 1 else sv[0]
shap.summary_plot(sv, X_sample, max_display=max_display, show=False)
fig = plt.gcf()
fig.tight_layout()
path = C.FIGURES_DIR / f"{name}_shap_summary.png"
fig.savefig(path, dpi=110, bbox_inches="tight")
plt.close(fig)
return path
except Exception as exc: # pragma: no cover - SHAP can be finicky
print(f"[evaluate] SHAP failed for {name}: {exc}")
return None
def save_metrics(metrics: dict, filename: str):
path = C.REPORTS_DIR / filename
with open(path, "w") as f:
json.dump(metrics, f, indent=2)
return path