Spaces:
Sleeping
Sleeping
File size: 8,438 Bytes
906c392 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | """
Calibration analysis for LightGBM (reviewer priority — Brier alone is not
enough evidence of good calibration).
Produces:
1. calibration_diagnostics.csv — ECE, calibration slope, calibration
intercept, Brier score, alongside the reliability-diagram bins.
2. extended_metrics_table.csv — Table 3 (all 11 models) extended with
PR-AUC, Balanced Accuracy, and MCC, computed from the same OOF
predictions stored in models/training_results.json (no retraining).
Usage:
python -m app.training.calibration_analysis
"""
from __future__ import annotations
import csv
import json
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from sklearn.calibration import calibration_curve
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
average_precision_score,
balanced_accuracy_score,
matthews_corrcoef,
)
MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
TABLES_DIR = Path(__file__).resolve().parents[3] / "docs/academic/paper/real_tables"
def _expected_calibration_error(y_true: np.ndarray, y_prob: np.ndarray, n_bins: int = 10) -> float:
bin_edges = np.linspace(0.0, 1.0, n_bins + 1)
bin_indices = np.digitize(y_prob, bin_edges[1:-1])
ece = 0.0
n = len(y_true)
for b in range(n_bins):
mask = bin_indices == b
if not np.any(mask):
continue
bin_conf = float(np.mean(y_prob[mask]))
bin_acc = float(np.mean(y_true[mask]))
ece += (np.sum(mask) / n) * abs(bin_conf - bin_acc)
return ece
def _calibration_slope_intercept(y_true: np.ndarray, y_prob: np.ndarray) -> tuple[float, float]:
"""Logistic recalibration: y ~ sigmoid(slope * logit(p) + intercept).
slope=1, intercept=0 is perfect calibration."""
eps = 1e-6
p_clipped = np.clip(y_prob, eps, 1 - eps)
logit_p = np.log(p_clipped / (1 - p_clipped)).reshape(-1, 1)
lr = LogisticRegression()
lr.fit(logit_p, y_true)
slope = float(lr.coef_[0][0])
intercept = float(lr.intercept_[0])
return slope, intercept
def run() -> None:
results_path = MODELS_DIR / "training_results.json"
with open(results_path, "r", encoding="utf-8") as f:
training_results = json.load(f)
# ── Extended metrics table (Table 3 + PR-AUC/BalAcc/MCC) ──
# training_results.json's per-model entries were saved without the
# y_true/y_pred/y_prob arrays (train_classifier.py strips those before
# writing JSON) — so PR-AUC/BalAcc/MCC must come from re-running
# cross_val_predict, matching evaluate_predictions()'s new metrics.
# Simpler and fully consistent: reuse train_classifier's train() output
# in-process is out of scope here; instead recompute from the model
# pickles + features.csv using the same OOF fold logic as ensemble_model.py.
print("Computing extended metrics (PR-AUC, Balanced Accuracy, MCC) for all 11 models...")
from sklearn.model_selection import StratifiedKFold
from sklearn.preprocessing import StandardScaler
from sklearn.base import clone
from sklearn.metrics import roc_auc_score, f1_score, accuracy_score, roc_curve
import pickle
import warnings
from sklearn.exceptions import ConvergenceWarning
from app.training.evaluate import load_features_csv
FEATURES_CSV = Path("D:/CrownCode/DataSet/features.csv")
DL_OOF_NPZ = MODELS_DIR / "dl_oof_probs.npz"
X, y = load_features_csv(FEATURES_CSV)
X = np.nan_to_num(X, nan=0.0, posinf=1.0, neginf=-1.0)
ml_files = {
"Logistic Regression": "model_logistic_regression.pkl",
"Random Forest": "model_random_forest.pkl",
"Gradient Boosting": "model_gradient_boosting.pkl",
"SVM (RBF)": "model_svm_rbf.pkl",
"MLP Neural Network": "model_mlp_neural_network.pkl",
"XGBoost": "model_xgboost.pkl",
"LightGBM": "model_lightgbm.pkl",
}
dl_npz_keys = {
"Deep MLP (512-256-128-64)": "Deep_MLP_512_256_128_64",
"1D-CNN": "1D_CNN",
"Residual MLP (3 blocks)": "Residual_MLP_3_blocks",
"Attention MLP": "Attention_MLP",
}
if not DL_OOF_NPZ.exists():
raise RuntimeError(f"{DL_OOF_NPZ} not found — run dump_dl_oof.py first.")
dl_npz = np.load(DL_OOF_NPZ)
if not np.array_equal(dl_npz["y"], y):
raise RuntimeError("dl_oof_probs.npz label order does not match features.csv load order.")
ml_models_raw = {}
for name, fname in ml_files.items():
with open(MODELS_DIR / fname, "rb") as f:
ml_models_raw[name] = pickle.load(f)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
fold_assignments = list(cv.split(X, y))
n = len(y)
oof_probs: dict[str, np.ndarray] = {name: np.zeros(n) for name in list(ml_files) + list(dl_npz_keys)}
for name, npz_key in dl_npz_keys.items():
oof_probs[name] = dl_npz[npz_key]
for fold_idx, (train_idx, test_idx) in enumerate(fold_assignments, start=1):
print(f" Fold {fold_idx}/5 (ML models) ...")
X_train, y_train = X[train_idx], y[train_idx]
X_test, y_test = X[test_idx], y[test_idx]
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
for name in ml_files:
model = clone(ml_models_raw[name])
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=ConvergenceWarning)
model.fit(X_train_scaled, y_train)
oof_probs[name][test_idx] = model.predict_proba(X_test_scaled)[:, 1]
extended_rows = []
for name, probs in oof_probs.items():
fpr, tpr, thr = roc_curve(y, probs)
threshold = float(thr[np.argmax(tpr - fpr)])
y_pred = (probs >= threshold).astype(int)
extended_rows.append({
"model": name,
"roc_auc": round(float(roc_auc_score(y, probs)), 4),
"pr_auc": round(float(average_precision_score(y, probs)), 4),
"accuracy": round(float(accuracy_score(y, y_pred)), 4),
"f1": round(float(f1_score(y, y_pred, zero_division=0)), 4),
"balanced_accuracy": round(float(balanced_accuracy_score(y, y_pred)), 4),
"mcc": round(float(matthews_corrcoef(y, y_pred)), 4),
"threshold": round(threshold, 4),
})
extended_rows.sort(key=lambda r: -r["roc_auc"])
TABLES_DIR.mkdir(parents=True, exist_ok=True)
ext_path = TABLES_DIR / "extended_metrics_table.csv"
with open(ext_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(extended_rows[0].keys()))
writer.writeheader()
writer.writerows(extended_rows)
print(f"\nExtended metrics table written: {ext_path}")
for r in extended_rows:
print(f" {r['model']:28s} AUC={r['roc_auc']:.4f} PR-AUC={r['pr_auc']:.4f} "
f"BalAcc={r['balanced_accuracy']:.4f} MCC={r['mcc']:.4f}")
# ── Calibration diagnostics for LightGBM specifically ──
lgbm_probs = oof_probs["LightGBM"]
prob_true, prob_pred = calibration_curve(y, lgbm_probs, n_bins=10, strategy="uniform")
ece = _expected_calibration_error(y, lgbm_probs, n_bins=10)
slope, intercept = _calibration_slope_intercept(y, lgbm_probs)
brier = float(np.mean((lgbm_probs - y) ** 2))
calib_path = TABLES_DIR / "calibration_diagnostics.csv"
with open(calib_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["metric", "value"])
writer.writerow(["brier_score", round(brier, 4)])
writer.writerow(["ece_10bin", round(ece, 4)])
writer.writerow(["calibration_slope", round(slope, 4)])
writer.writerow(["calibration_intercept", round(intercept, 4)])
writer.writerow([])
writer.writerow(["bin_mean_predicted", "bin_fraction_positive"])
for pp, pt in zip(prob_pred, prob_true):
writer.writerow([round(float(pp), 4), round(float(pt), 4)])
print(f"\nCalibration diagnostics written: {calib_path}")
print(f" Brier score: {brier:.4f}")
print(f" ECE (10-bin): {ece:.4f}")
print(f" Calibration slope: {slope:.4f} (1.0 = perfect)")
print(f" Calibration intercept: {intercept:.4f} (0.0 = perfect)")
if __name__ == "__main__":
run()
|