"""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