# src/calibration_utils.py """ Calibration assessment + bootstrap confidence intervals for binary classifiers and Cox survival models. Designed to slot into the existing manuscript pipeline: - call signatures mirror inference_utils.compute_metrics() - returns dicts that downstream code can merge into existing metric dicts - matplotlib figures use the same style as the existing ROC/PR plots """ from __future__ import annotations import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.metrics import roc_auc_score, brier_score_loss from sklearn.calibration import calibration_curve from scipy.special import logit import statsmodels.api as sm from lifelines.utils import concordance_index # ---------------------------------------------------------------------- # Helpers # ---------------------------------------------------------------------- def _clean_binary_inputs(y_true, y_prob): """Mirror the NaN-handling pattern used in inference_utils.compute_metrics().""" y_true = pd.to_numeric(pd.Series(y_true).astype(str).str.strip(), errors="coerce") y_prob = pd.to_numeric(pd.Series(y_prob), errors="coerce") valid = y_true.notna() & y_prob.notna() y_true = y_true.loc[valid].astype(int).to_numpy() y_prob = y_prob.loc[valid].astype(float).to_numpy() return y_true, y_prob def _safe_logit(p, eps=1e-6): return logit(np.clip(p, eps, 1.0 - eps)) # ---------------------------------------------------------------------- # Bootstrap CI helpers # ---------------------------------------------------------------------- def bootstrap_auroc_ci(y_true, y_prob, n_bootstraps=1000, seed=42): """ Returns (auroc_point, ci_low, ci_high). Stratified bootstrap (preserves event prevalence). """ y_true, y_prob = _clean_binary_inputs(y_true, y_prob) if len(y_true) == 0 or len(np.unique(y_true)) < 2: return (np.nan, np.nan, np.nan) point = float(roc_auc_score(y_true, y_prob)) pos_idx = np.where(y_true == 1)[0] neg_idx = np.where(y_true == 0)[0] rng = np.random.default_rng(seed) boot = [] for _ in range(n_bootstraps): pos_b = rng.choice(pos_idx, size=len(pos_idx), replace=True) neg_b = rng.choice(neg_idx, size=len(neg_idx), replace=True) idx = np.concatenate([pos_b, neg_b]) try: boot.append(roc_auc_score(y_true[idx], y_prob[idx])) except ValueError: continue if len(boot) == 0: return (point, np.nan, np.nan) lo, hi = np.percentile(boot, [2.5, 97.5]) return (point, float(lo), float(hi)) def bootstrap_c_index_ci(durations, events, risk_scores, n_bootstraps=1000, seed=42): """ Bootstrap C-index for a Cox-style risk score (higher score = higher risk). Returns (c_point, ci_low, ci_high). """ durations = np.asarray(durations, dtype=float).ravel() events = np.asarray(events, dtype=int).ravel() risk_scores = np.asarray(risk_scores, dtype=float).ravel() valid = ~(np.isnan(durations) | np.isnan(risk_scores)) & (durations > 0) durations = durations[valid] events = events[valid] risk_scores = risk_scores[valid] if len(durations) < 10 or events.sum() < 5: return (np.nan, np.nan, np.nan) try: point = float(concordance_index(durations, -risk_scores, events)) except Exception: return (np.nan, np.nan, np.nan) rng = np.random.default_rng(seed) n = len(durations) boot = [] for _ in range(n_bootstraps): idx = rng.choice(n, size=n, replace=True) if np.asarray(events)[idx].sum() < 2: continue try: boot.append(concordance_index(durations[idx], -risk_scores[idx], events[idx])) except Exception: continue if len(boot) == 0: return (point, np.nan, np.nan) lo, hi = np.percentile(boot, [2.5, 97.5]) return (point, float(lo), float(hi)) # ---------------------------------------------------------------------- # Core calibration stats # ---------------------------------------------------------------------- def compute_calibration_stats(y_true, y_prob, n_bins=10): """ Compute calibration intercept, slope, decile-level points, and Brier score. Returns a dict with the same flat-key style as compute_metrics(): { 'N': int, 'Events': int, 'Prevalence': float, 'Brier': float, 'CalibrationInTheLarge': float, 'CalibrationIntercept': float, 'CalibrationIntercept_CI_low': float, 'CalibrationIntercept_CI_high': float, 'CalibrationSlope': float, 'CalibrationSlope_CI_low': float, 'CalibrationSlope_CI_high': float, 'BinProbTrue': np.ndarray, # observed event rate per bin 'BinProbPred': np.ndarray, # mean predicted prob per bin 'BinCounts': np.ndarray, # N per bin } """ y_true, y_prob = _clean_binary_inputs(y_true, y_prob) out = { "N": int(len(y_true)), "Events": int(y_true.sum()) if len(y_true) else 0, "Prevalence": float(y_true.mean()) if len(y_true) else np.nan, "Brier": np.nan, "CalibrationInTheLarge": np.nan, "CalibrationIntercept": np.nan, "CalibrationIntercept_CI_low": np.nan, "CalibrationIntercept_CI_high": np.nan, "CalibrationSlope": np.nan, "CalibrationSlope_CI_low": np.nan, "CalibrationSlope_CI_high": np.nan, "BinProbTrue": np.array([]), "BinProbPred": np.array([]), "BinCounts": np.array([]), } if len(y_true) < 20 or len(np.unique(y_true)) < 2: return out # Brier out["Brier"] = float(brier_score_loss(y_true, np.clip(y_prob, 1e-15, 1 - 1e-15))) # Calibration-in-the-large out["CalibrationInTheLarge"] = float(y_true.mean() - y_prob.mean()) # Decile points try: prob_true, prob_pred = calibration_curve(y_true, y_prob, n_bins=n_bins, strategy="quantile") # Bin counts via quantile cut on predicted probabilities bin_edges = np.quantile(y_prob, np.linspace(0, 1, n_bins + 1)) bin_edges[0] -= 1e-9 bin_edges[-1] += 1e-9 bin_idx = np.digitize(y_prob, bin_edges, right=True) - 1 bin_idx = np.clip(bin_idx, 0, n_bins - 1) bin_counts = np.bincount(bin_idx, minlength=n_bins)[: len(prob_pred)] out["BinProbTrue"] = prob_true out["BinProbPred"] = prob_pred out["BinCounts"] = bin_counts except Exception: pass # Calibration intercept (offset) and slope via logistic recalibration: # logit(p_obs) = intercept + slope * logit(p_pred) try: logits = _safe_logit(y_prob) X = sm.add_constant(logits, has_constant='add') # force constant column # statsmodels expects float64 numpy arrays y_arr = np.asarray(y_true, dtype=np.float64).ravel() X_arr = np.asarray(X, dtype=np.float64) # Diagnostic: print shapes print(f"[calibration] X shape: {X_arr.shape}, y shape: {y_arr.shape}") print(f"[calibration] X has constant? first col unique: {np.unique(X_arr[:, 0])[:5]}") print(f"[calibration] logit range: {logits.min():.3f} to {logits.max():.3f}") result = sm.Logit(y_arr, X_arr).fit(disp=0, method='bfgs', maxiter=200) print(f"[calibration] params: {result.params}") print(f"[calibration] conf_int:\n{result.conf_int()}") intercept = float(result.params[0]) slope = float(result.params[1]) ci_arr = np.asarray(result.conf_int()) out["CalibrationIntercept"] = intercept out["CalibrationIntercept_CI_low"] = float(ci_arr[0, 0]) out["CalibrationIntercept_CI_high"] = float(ci_arr[0, 1]) out["CalibrationSlope"] = slope out["CalibrationSlope_CI_low"] = float(ci_arr[1, 0]) out["CalibrationSlope_CI_high"] = float(ci_arr[1, 1]) except Exception as e: print(f"[calibration] FIT FAILED: {type(e).__name__}: {e}") import traceback traceback.print_exc() return out # ---------------------------------------------------------------------- # Plotting # ---------------------------------------------------------------------- def plot_calibration_curve(y_true, y_prob, title="Calibration", n_bins=10, ax=None, return_stats=False): """ Calibration plot with: - decile points (observed vs predicted), marker size proportional to N per bin - diagonal reference line (perfect calibration) - histogram of predicted probabilities along the bottom - intercept and slope (with 95% CIs) annotated in the title - Brier score annotated in the legend If ax is provided, draws into that axis. Otherwise creates a new fig/ax. Returns the matplotlib figure (and stats dict if return_stats=True). """ stats = compute_calibration_stats(y_true, y_prob, n_bins=n_bins) if ax is None: fig, ax = plt.subplots(figsize=(6.5, 6.0)) else: fig = ax.figure # Diagonal reference ax.plot([0, 1], [0, 1], linestyle="--", color="0.4", label="Perfect calibration") # Decile points with marker size proportional to bin count prob_true = stats["BinProbTrue"] prob_pred = stats["BinProbPred"] bin_counts = stats["BinCounts"] if len(prob_true) and len(prob_pred): if len(bin_counts) == len(prob_pred) and bin_counts.sum() > 0: sizes = 40 + 200 * (bin_counts / bin_counts.max()) else: sizes = np.full(len(prob_pred), 80.0) ax.scatter(prob_pred, prob_true, s=sizes, color="#1F77B4", edgecolor="white", linewidth=0.8, zorder=3, label="Model") ax.plot(prob_pred, prob_true, color="#1F77B4", alpha=0.5, zorder=2) # Annotation icpt = stats["CalibrationIntercept"] icpt_lo = stats["CalibrationIntercept_CI_low"] icpt_hi = stats["CalibrationIntercept_CI_high"] slope = stats["CalibrationSlope"] slope_lo = stats["CalibrationSlope_CI_low"] slope_hi = stats["CalibrationSlope_CI_high"] brier = stats["Brier"] def fmt(v): return "NA" if (v is None or np.isnan(v)) else f"{v:.2f}" full_title = ( f"{title}\n" f"Intercept = {fmt(icpt)} ({fmt(icpt_lo)} to {fmt(icpt_hi)}) " f"Slope = {fmt(slope)} ({fmt(slope_lo)} to {fmt(slope_hi)}) " f"Brier = {fmt(brier)}" ) ax.set_title(full_title, fontsize=10) ax.set_xlabel("Mean predicted probability") ax.set_ylabel("Observed event rate") ax.set_xlim(-0.02, 1.02) ax.set_ylim(-0.02, 1.02) ax.grid(alpha=0.3, linestyle=":") ax.legend(loc="upper left", fontsize=9, framealpha=0.85) # Histogram of predicted probabilities at the bottom (twin axis) y_true_arr, y_prob_arr = _clean_binary_inputs(y_true, y_prob) if len(y_prob_arr) > 0: ax2 = ax.twinx() ax2.hist(y_prob_arr, bins=30, range=(0, 1), color="0.7", alpha=0.5, edgecolor="white", linewidth=0.3) ax2.set_ylabel("Count (predicted prob)", fontsize=9, color="0.5") ax2.tick_params(axis="y", labelsize=8, colors="0.5") # Keep histogram in the bottom third hist_max = ax2.get_ylim()[1] ax2.set_ylim(0, hist_max * 3) ax2.set_zorder(0) ax.set_zorder(1) ax.patch.set_alpha(0) fig.tight_layout() if return_stats: return fig, stats return fig def plot_multi_cohort_calibration(cohort_results, ncols=3, figsize=None): """ Composite calibration figure across cohorts. Use this for the supplementary figure referenced in the manuscript revision roadmap. cohort_results: dict of {cohort_name: (y_true, y_prob)} """ n = len(cohort_results) if n == 0: return None nrows = int(np.ceil(n / ncols)) if figsize is None: figsize = (5 * ncols, 4.5 * nrows) fig, axes = plt.subplots(nrows, ncols, figsize=figsize, squeeze=False) for i, (cohort_name, (y_true, y_prob)) in enumerate(cohort_results.items()): r, c = divmod(i, ncols) plot_calibration_curve(y_true, y_prob, title=cohort_name, ax=axes[r][c]) # Hide unused panels for j in range(n, nrows * ncols): r, c = divmod(j, ncols) axes[r][c].axis("off") fig.tight_layout() return fig