"""Confidence calibration (ECE) on the seeded quick IMDB subset. Measures the Expected Calibration Error for each probability model: when a model says it is X percent confident, is it right about X percent of the time. The classical models were calibrated on TRAIN (CalibratedClassifierCV), so the TEST set is held out from that fit and these numbers are honest. DistilBERT softmax is not temperature-scaled, so it is expected to be overconfident. Saves artifacts/calibration.json. """ import os import sys import json import numpy as np THIS_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_ROOT = os.path.dirname(os.path.dirname(THIS_DIR)) for _p in (PROJECT_ROOT, THIS_DIR): if _p not in sys.path: sys.path.insert(0, _p) import evaluate as ev import stack_ensemble as se import finetune_distilbert as fd CLASSICAL = ["naive_bayes", "logistic_regression", "linear_svc", "nbsvm"] N_BINS = 15 def ece_bins(p, labels, n_bins=N_BINS): """Expected Calibration Error and the per-bin reliability table (Guo et al.).""" p = np.asarray(p, dtype=float) labels = np.asarray(labels) conf = np.maximum(p, 1 - p) pred = (p >= 0.5).astype(int) correct = (pred == labels).astype(float) edges = np.linspace(0.5, 1.0, n_bins + 1) width = 0.5 / n_bins idx = np.minimum(((conf - 0.5) / width).astype(int), n_bins - 1) n = len(p) ece = 0.0 table = [] for b in range(n_bins): m = idx == b cnt = int(m.sum()) if cnt == 0: continue c = float(conf[m].mean()) a = float(correct[m].mean()) ece += (cnt / n) * abs(c - a) table.append({"bin": [round(float(edges[b]), 3), round(float(edges[b + 1]), 3)], "conf": round(c, 4), "acc": round(a, 4), "count": cnt}) return round(ece, 4), table def main(): texts, labels = ev.load_imdb_test(max_per_class=1000, seed=42) labels = np.asarray(labels) print(f"quick subset: {len(texts)} reviews") ensemble = ev.load_ensemble() processed = ev.preprocess_texts(ensemble, texts) ppos = {} for m in CLASSICAL: X = ev.build_features(ensemble, m, processed) ppos[m] = ensemble.models[m].predict_proba(X)[:, 1] print(f"{m} done") ppos["distilbert"] = fd.predict_proba(texts)[:, 1] print("distilbert done") ppos["stack"] = se.predict_proba(texts)[:, 1] print("stack done") order = CLASSICAL + ["distilbert", "stack"] results = {m: dict(zip(("ece", "reliability"), ece_bins(ppos[m], labels))) for m in order} ranked = sorted(order, key=lambda m: results[m]["ece"]) best, worst = ranked[0], ranked[-1] out = {"subset": {"name": "imdb_quick_seeded", "seed": 42, "n": len(texts)}, "n_bins": N_BINS, "bin_range": [0.5, 1.0], "definition": "ECE (Guo et al.): confidence = max(p, 1-p); 15 equal-width bins on " "[0.5, 1.0]; ECE = sum (bin_count / N) * |mean_conf - acc|", "ece": {m: results[m]["ece"] for m in order}, "reliability_best": {"model": best, "bins": results[best]["reliability"]}, "reliability_worst": {"model": worst, "bins": results[worst]["reliability"]}, "models": results} out_path = os.path.join(PROJECT_ROOT, "artifacts", "calibration.json") os.makedirs(os.path.dirname(out_path), exist_ok=True) with open(out_path, "w", encoding="utf-8") as f: json.dump(out, f, indent=2) print("\nECE per model (best to worst, lower = better calibrated):") for m in ranked: print(f" {m:20s} ECE {results[m]['ece']:.4f}") for tag, m in [("BEST", best), ("WORST", worst)]: print(f"\nReliability ({tag} = {m}):") for row in results[m]["reliability"]: print(f" [{row['bin'][0]:.3f}, {row['bin'][1]:.3f}] conf {row['conf']:.3f} " f"acc {row['acc']:.3f} n {row['count']}") print(f"\nsaved {out_path}") if __name__ == "__main__": main()