Synav commited on
Commit
fb6c704
·
verified ·
1 Parent(s): 3c21b1b

Create calibration_utils.py

Browse files
Files changed (1) hide show
  1. src/calibration_utils.py +316 -0
src/calibration_utils.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/calibration_utils.py
2
+ """
3
+ Calibration assessment + bootstrap confidence intervals for binary
4
+ classifiers and Cox survival models.
5
+
6
+ Designed to slot into the existing manuscript pipeline:
7
+ - call signatures mirror inference_utils.compute_metrics()
8
+ - returns dicts that downstream code can merge into existing metric dicts
9
+ - matplotlib figures use the same style as the existing ROC/PR plots
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import matplotlib.pyplot as plt
17
+ from sklearn.metrics import roc_auc_score, brier_score_loss
18
+ from sklearn.calibration import calibration_curve
19
+ from scipy.special import logit
20
+ import statsmodels.api as sm
21
+ from lifelines.utils import concordance_index
22
+
23
+
24
+ # ----------------------------------------------------------------------
25
+ # Helpers
26
+ # ----------------------------------------------------------------------
27
+
28
+ def _clean_binary_inputs(y_true, y_prob):
29
+ """Mirror the NaN-handling pattern used in inference_utils.compute_metrics()."""
30
+ y_true = pd.to_numeric(pd.Series(y_true).astype(str).str.strip(), errors="coerce")
31
+ y_prob = pd.to_numeric(pd.Series(y_prob), errors="coerce")
32
+ valid = y_true.notna() & y_prob.notna()
33
+ y_true = y_true.loc[valid].astype(int).to_numpy()
34
+ y_prob = y_prob.loc[valid].astype(float).to_numpy()
35
+ return y_true, y_prob
36
+
37
+
38
+ def _safe_logit(p, eps=1e-6):
39
+ return logit(np.clip(p, eps, 1.0 - eps))
40
+
41
+
42
+ # ----------------------------------------------------------------------
43
+ # Bootstrap CI helpers
44
+ # ----------------------------------------------------------------------
45
+
46
+ def bootstrap_auroc_ci(y_true, y_prob, n_bootstraps=1000, seed=42):
47
+ """
48
+ Returns (auroc_point, ci_low, ci_high).
49
+ Stratified bootstrap (preserves event prevalence).
50
+ """
51
+ y_true, y_prob = _clean_binary_inputs(y_true, y_prob)
52
+ if len(y_true) == 0 or len(np.unique(y_true)) < 2:
53
+ return (np.nan, np.nan, np.nan)
54
+
55
+ point = float(roc_auc_score(y_true, y_prob))
56
+ pos_idx = np.where(y_true == 1)[0]
57
+ neg_idx = np.where(y_true == 0)[0]
58
+ rng = np.random.default_rng(seed)
59
+
60
+ boot = []
61
+ for _ in range(n_bootstraps):
62
+ pos_b = rng.choice(pos_idx, size=len(pos_idx), replace=True)
63
+ neg_b = rng.choice(neg_idx, size=len(neg_idx), replace=True)
64
+ idx = np.concatenate([pos_b, neg_b])
65
+ try:
66
+ boot.append(roc_auc_score(y_true[idx], y_prob[idx]))
67
+ except ValueError:
68
+ continue
69
+
70
+ if len(boot) == 0:
71
+ return (point, np.nan, np.nan)
72
+ lo, hi = np.percentile(boot, [2.5, 97.5])
73
+ return (point, float(lo), float(hi))
74
+
75
+
76
+ def bootstrap_c_index_ci(durations, events, risk_scores, n_bootstraps=1000, seed=42):
77
+ """
78
+ Bootstrap C-index for a Cox-style risk score (higher score = higher risk).
79
+ Returns (c_point, ci_low, ci_high).
80
+ """
81
+ durations = np.asarray(durations, dtype=float).ravel()
82
+ events = np.asarray(events, dtype=int).ravel()
83
+ risk_scores = np.asarray(risk_scores, dtype=float).ravel()
84
+
85
+ valid = ~(np.isnan(durations) | np.isnan(risk_scores)) & (durations > 0)
86
+ durations = durations[valid]
87
+ events = events[valid]
88
+ risk_scores = risk_scores[valid]
89
+
90
+ if len(durations) < 10 or events.sum() < 5:
91
+ return (np.nan, np.nan, np.nan)
92
+
93
+ try:
94
+ point = float(concordance_index(durations, -risk_scores, events))
95
+ except Exception:
96
+ return (np.nan, np.nan, np.nan)
97
+
98
+ rng = np.random.default_rng(seed)
99
+ n = len(durations)
100
+ boot = []
101
+ for _ in range(n_bootstraps):
102
+ idx = rng.choice(n, size=n, replace=True)
103
+ if np.asarray(events)[idx].sum() < 2:
104
+ continue
105
+ try:
106
+ boot.append(concordance_index(durations[idx], -risk_scores[idx], events[idx]))
107
+ except Exception:
108
+ continue
109
+
110
+ if len(boot) == 0:
111
+ return (point, np.nan, np.nan)
112
+ lo, hi = np.percentile(boot, [2.5, 97.5])
113
+ return (point, float(lo), float(hi))
114
+
115
+
116
+ # ----------------------------------------------------------------------
117
+ # Core calibration stats
118
+ # ----------------------------------------------------------------------
119
+
120
+ def compute_calibration_stats(y_true, y_prob, n_bins=10):
121
+ """
122
+ Compute calibration intercept, slope, decile-level points, and Brier score.
123
+
124
+ Returns a dict with the same flat-key style as compute_metrics():
125
+ {
126
+ 'N': int, 'Events': int, 'Prevalence': float,
127
+ 'Brier': float, 'CalibrationInTheLarge': float,
128
+ 'CalibrationIntercept': float,
129
+ 'CalibrationIntercept_CI_low': float,
130
+ 'CalibrationIntercept_CI_high': float,
131
+ 'CalibrationSlope': float,
132
+ 'CalibrationSlope_CI_low': float,
133
+ 'CalibrationSlope_CI_high': float,
134
+ 'BinProbTrue': np.ndarray, # observed event rate per bin
135
+ 'BinProbPred': np.ndarray, # mean predicted prob per bin
136
+ 'BinCounts': np.ndarray, # N per bin
137
+ }
138
+ """
139
+ y_true, y_prob = _clean_binary_inputs(y_true, y_prob)
140
+
141
+ out = {
142
+ "N": int(len(y_true)),
143
+ "Events": int(y_true.sum()) if len(y_true) else 0,
144
+ "Prevalence": float(y_true.mean()) if len(y_true) else np.nan,
145
+ "Brier": np.nan,
146
+ "CalibrationInTheLarge": np.nan,
147
+ "CalibrationIntercept": np.nan,
148
+ "CalibrationIntercept_CI_low": np.nan,
149
+ "CalibrationIntercept_CI_high": np.nan,
150
+ "CalibrationSlope": np.nan,
151
+ "CalibrationSlope_CI_low": np.nan,
152
+ "CalibrationSlope_CI_high": np.nan,
153
+ "BinProbTrue": np.array([]),
154
+ "BinProbPred": np.array([]),
155
+ "BinCounts": np.array([]),
156
+ }
157
+
158
+ if len(y_true) < 20 or len(np.unique(y_true)) < 2:
159
+ return out
160
+
161
+ # Brier
162
+ out["Brier"] = float(brier_score_loss(y_true, np.clip(y_prob, 1e-15, 1 - 1e-15)))
163
+
164
+ # Calibration-in-the-large
165
+ out["CalibrationInTheLarge"] = float(y_true.mean() - y_prob.mean())
166
+
167
+ # Decile points
168
+ try:
169
+ prob_true, prob_pred = calibration_curve(y_true, y_prob, n_bins=n_bins, strategy="quantile")
170
+ # Bin counts via quantile cut on predicted probabilities
171
+ bin_edges = np.quantile(y_prob, np.linspace(0, 1, n_bins + 1))
172
+ bin_edges[0] -= 1e-9
173
+ bin_edges[-1] += 1e-9
174
+ bin_idx = np.digitize(y_prob, bin_edges, right=True) - 1
175
+ bin_idx = np.clip(bin_idx, 0, n_bins - 1)
176
+ bin_counts = np.bincount(bin_idx, minlength=n_bins)[: len(prob_pred)]
177
+
178
+ out["BinProbTrue"] = prob_true
179
+ out["BinProbPred"] = prob_pred
180
+ out["BinCounts"] = bin_counts
181
+ except Exception:
182
+ pass
183
+
184
+ # Calibration intercept (offset) and slope via logistic recalibration:
185
+ # logit(p_obs) = intercept + slope * logit(p_pred)
186
+ try:
187
+ logits = _safe_logit(y_prob)
188
+ X = sm.add_constant(logits)
189
+ result = sm.Logit(y_true, X).fit(disp=0)
190
+ intercept = float(result.params[0])
191
+ slope = float(result.params[1])
192
+ ci = result.conf_int()
193
+ out["CalibrationIntercept"] = intercept
194
+ out["CalibrationIntercept_CI_low"] = float(ci.iloc[0, 0])
195
+ out["CalibrationIntercept_CI_high"] = float(ci.iloc[0, 1])
196
+ out["CalibrationSlope"] = slope
197
+ out["CalibrationSlope_CI_low"] = float(ci.iloc[1, 0])
198
+ out["CalibrationSlope_CI_high"] = float(ci.iloc[1, 1])
199
+ except Exception:
200
+ pass
201
+
202
+ return out
203
+
204
+
205
+ # ----------------------------------------------------------------------
206
+ # Plotting
207
+ # ----------------------------------------------------------------------
208
+
209
+ def plot_calibration_curve(y_true, y_prob, title="Calibration", n_bins=10,
210
+ ax=None, return_stats=False):
211
+ """
212
+ Calibration plot with:
213
+ - decile points (observed vs predicted), marker size proportional to N per bin
214
+ - diagonal reference line (perfect calibration)
215
+ - histogram of predicted probabilities along the bottom
216
+ - intercept and slope (with 95% CIs) annotated in the title
217
+ - Brier score annotated in the legend
218
+
219
+ If ax is provided, draws into that axis. Otherwise creates a new fig/ax.
220
+ Returns the matplotlib figure (and stats dict if return_stats=True).
221
+ """
222
+ stats = compute_calibration_stats(y_true, y_prob, n_bins=n_bins)
223
+
224
+ if ax is None:
225
+ fig, ax = plt.subplots(figsize=(6.5, 6.0))
226
+ else:
227
+ fig = ax.figure
228
+
229
+ # Diagonal reference
230
+ ax.plot([0, 1], [0, 1], linestyle="--", color="0.4", label="Perfect calibration")
231
+
232
+ # Decile points with marker size proportional to bin count
233
+ prob_true = stats["BinProbTrue"]
234
+ prob_pred = stats["BinProbPred"]
235
+ bin_counts = stats["BinCounts"]
236
+
237
+ if len(prob_true) and len(prob_pred):
238
+ if len(bin_counts) == len(prob_pred) and bin_counts.sum() > 0:
239
+ sizes = 40 + 200 * (bin_counts / bin_counts.max())
240
+ else:
241
+ sizes = np.full(len(prob_pred), 80.0)
242
+
243
+ ax.scatter(prob_pred, prob_true, s=sizes, color="#1F77B4",
244
+ edgecolor="white", linewidth=0.8, zorder=3, label="Model")
245
+ ax.plot(prob_pred, prob_true, color="#1F77B4", alpha=0.5, zorder=2)
246
+
247
+ # Annotation
248
+ icpt = stats["CalibrationIntercept"]
249
+ icpt_lo = stats["CalibrationIntercept_CI_low"]
250
+ icpt_hi = stats["CalibrationIntercept_CI_high"]
251
+ slope = stats["CalibrationSlope"]
252
+ slope_lo = stats["CalibrationSlope_CI_low"]
253
+ slope_hi = stats["CalibrationSlope_CI_high"]
254
+ brier = stats["Brier"]
255
+
256
+ def fmt(v):
257
+ return "NA" if (v is None or np.isnan(v)) else f"{v:.2f}"
258
+
259
+ full_title = (
260
+ f"{title}\n"
261
+ f"Intercept = {fmt(icpt)} ({fmt(icpt_lo)} to {fmt(icpt_hi)}) "
262
+ f"Slope = {fmt(slope)} ({fmt(slope_lo)} to {fmt(slope_hi)}) "
263
+ f"Brier = {fmt(brier)}"
264
+ )
265
+ ax.set_title(full_title, fontsize=10)
266
+ ax.set_xlabel("Mean predicted probability")
267
+ ax.set_ylabel("Observed event rate")
268
+ ax.set_xlim(-0.02, 1.02)
269
+ ax.set_ylim(-0.02, 1.02)
270
+ ax.grid(alpha=0.3, linestyle=":")
271
+ ax.legend(loc="upper left", fontsize=9, framealpha=0.85)
272
+
273
+ # Histogram of predicted probabilities at the bottom (twin axis)
274
+ y_true_arr, y_prob_arr = _clean_binary_inputs(y_true, y_prob)
275
+ if len(y_prob_arr) > 0:
276
+ ax2 = ax.twinx()
277
+ ax2.hist(y_prob_arr, bins=30, range=(0, 1), color="0.7", alpha=0.5,
278
+ edgecolor="white", linewidth=0.3)
279
+ ax2.set_ylabel("Count (predicted prob)", fontsize=9, color="0.5")
280
+ ax2.tick_params(axis="y", labelsize=8, colors="0.5")
281
+ # Keep histogram in the bottom third
282
+ hist_max = ax2.get_ylim()[1]
283
+ ax2.set_ylim(0, hist_max * 3)
284
+ ax2.set_zorder(0)
285
+ ax.set_zorder(1)
286
+ ax.patch.set_alpha(0)
287
+
288
+ fig.tight_layout()
289
+ if return_stats:
290
+ return fig, stats
291
+ return fig
292
+
293
+
294
+ def plot_multi_cohort_calibration(cohort_results, ncols=3, figsize=None):
295
+ """
296
+ Composite calibration figure across cohorts. Use this for the
297
+ supplementary figure referenced in the manuscript revision roadmap.
298
+
299
+ cohort_results: dict of {cohort_name: (y_true, y_prob)}
300
+ """
301
+ n = len(cohort_results)
302
+ if n == 0:
303
+ return None
304
+ nrows = int(np.ceil(n / ncols))
305
+ if figsize is None:
306
+ figsize = (5 * ncols, 4.5 * nrows)
307
+ fig, axes = plt.subplots(nrows, ncols, figsize=figsize, squeeze=False)
308
+ for i, (cohort_name, (y_true, y_prob)) in enumerate(cohort_results.items()):
309
+ r, c = divmod(i, ncols)
310
+ plot_calibration_curve(y_true, y_prob, title=cohort_name, ax=axes[r][c])
311
+ # Hide unused panels
312
+ for j in range(n, nrows * ncols):
313
+ r, c = divmod(j, ncols)
314
+ axes[r][c].axis("off")
315
+ fig.tight_layout()
316
+ return fig