1111import pandas as pd
1212from sklearn .metrics import (
1313 accuracy_score ,
14+ brier_score_loss ,
1415 confusion_matrix ,
1516 f1_score ,
1617 log_loss ,
@@ -118,6 +119,54 @@ def _metrics_at_threshold(y: np.ndarray, proba: np.ndarray, threshold: float) ->
118119 }
119120
120121
122+ def calibration_summary (
123+ y : np .ndarray ,
124+ proba : np .ndarray ,
125+ * ,
126+ n_bins : int = 10 ,
127+ ) -> dict [str , Any ]:
128+ """Summarize whether predicted probabilities match observed positive rates."""
129+ if n_bins < 1 :
130+ raise ValueError ("n_bins must be at least 1" )
131+
132+ bins : list [dict [str , Any ]] = []
133+ total = len (y )
134+ expected_calibration_error = 0.0
135+ for idx in range (n_bins ):
136+ lower = idx / n_bins
137+ upper = (idx + 1 ) / n_bins
138+ if idx == n_bins - 1 :
139+ mask = (proba >= lower ) & (proba <= upper )
140+ else :
141+ mask = (proba >= lower ) & (proba < upper )
142+ count = int (np .sum (mask ))
143+ if count == 0 :
144+ continue
145+ mean_predicted = float (np .mean (proba [mask ]))
146+ observed_rate = float (np .mean (y [mask ]))
147+ absolute_error = abs (mean_predicted - observed_rate )
148+ expected_calibration_error += (count / total ) * absolute_error
149+ bins .append (
150+ {
151+ "lower" : float (lower ),
152+ "upper" : float (upper ),
153+ "count" : count ,
154+ "mean_predicted_probability" : mean_predicted ,
155+ "observed_positive_rate" : observed_rate ,
156+ "absolute_error" : float (absolute_error ),
157+ }
158+ )
159+
160+ return {
161+ "n_bins" : int (n_bins ),
162+ "brier_score" : float (brier_score_loss (y , proba )),
163+ "expected_calibration_error" : float (expected_calibration_error ),
164+ "mean_predicted_probability" : float (np .mean (proba )),
165+ "mean_observed_rate" : float (np .mean (y )),
166+ "bins" : bins ,
167+ }
168+
169+
121170def _holdout_proba (
122171 builder : Any ,
123172 clf : Any ,
@@ -139,6 +188,7 @@ def evaluate_holdout(
139188 df : pd .DataFrame ,
140189 * ,
141190 threshold : float = 0.5 ,
191+ calibration_bins : int = 10 ,
142192) -> dict [str , Any ]:
143193 """Evaluate a loaded artifact against a labeled Quora-style dataframe."""
144194 y , proba = _holdout_proba (builder , clf , df )
@@ -147,6 +197,7 @@ def evaluate_holdout(
147197 "n" : int (len (df )),
148198 "log_loss" : float (log_loss (y , proba , labels = [0 , 1 ])),
149199 "positive_rate" : float (np .mean (y )),
200+ "calibration" : calibration_summary (y , proba , n_bins = calibration_bins ),
150201 ** threshold_metrics ,
151202 }
152203 if len (np .unique (y )) > 1 :
@@ -215,6 +266,7 @@ def render_model_card(
215266 ]
216267
217268 if holdout_metrics is not None :
269+ calibration = holdout_metrics .get ("calibration" )
218270 metric_keys = [
219271 "n" ,
220272 "threshold" ,
@@ -244,6 +296,45 @@ def render_model_card(
244296 _markdown_table (["" , "predicted 0" , "predicted 1" ], confusion_rows ),
245297 ]
246298 )
299+ if isinstance (calibration , dict ):
300+ calibration_rows = [
301+ ["n_bins" , calibration ["n_bins" ]],
302+ ["brier_score" , calibration ["brier_score" ]],
303+ ["expected_calibration_error" , calibration ["expected_calibration_error" ]],
304+ ["mean_predicted_probability" , calibration ["mean_predicted_probability" ]],
305+ ["mean_observed_rate" , calibration ["mean_observed_rate" ]],
306+ ]
307+ bin_rows = [
308+ [
309+ f"{ row ['lower' ]:.2f} -{ row ['upper' ]:.2f} " ,
310+ row ["count" ],
311+ row ["mean_predicted_probability" ],
312+ row ["observed_positive_rate" ],
313+ row ["absolute_error" ],
314+ ]
315+ for row in calibration .get ("bins" , [])
316+ ]
317+ parts .extend (
318+ [
319+ "" ,
320+ "## Calibration Summary" ,
321+ "" ,
322+ _markdown_table (["metric" , "value" ], calibration_rows ),
323+ "" ,
324+ "## Calibration Bins" ,
325+ "" ,
326+ _markdown_table (
327+ [
328+ "probability_bin" ,
329+ "count" ,
330+ "mean_predicted_probability" ,
331+ "observed_positive_rate" ,
332+ "absolute_error" ,
333+ ],
334+ bin_rows ,
335+ ),
336+ ]
337+ )
247338 if sweep_metrics :
248339 sweep_rows = [
249340 [
@@ -282,8 +373,9 @@ def render_model_card(
282373 "" ,
283374 (
284375 "`POST /predict` accepts `question1` and `question2` arrays of equal length "
285- "and returns `proba_duplicate` in the same order. `GET /metrics` exposes "
286- "Prometheus counters and latency histograms."
376+ "and returns `proba_duplicate`, `is_duplicate`, and `decision_threshold` "
377+ "in the same order. `GET /metrics` exposes Prometheus counters and "
378+ "latency histograms."
287379 ),
288380 "" ,
289381 "## Caveats" ,
@@ -319,7 +411,11 @@ def build_report_payload(
319411 "predict" : "POST /predict" ,
320412 "metrics" : "GET /metrics" ,
321413 "input" : {"question1" : "list[str]" , "question2" : "list[str]" },
322- "output" : {"proba_duplicate" : "list[float]" },
414+ "output" : {
415+ "proba_duplicate" : "list[float]" ,
416+ "is_duplicate" : "list[bool]" ,
417+ "decision_threshold" : "float" ,
418+ },
323419 },
324420 "caveats" : [
325421 "Performance depends on the training data distribution and threshold." ,
@@ -330,8 +426,11 @@ def build_report_payload(
330426 payload ["holdout_evaluation" ] = {
331427 k : v
332428 for k , v in holdout_metrics .items ()
333- if k not in {"tn" , "fp" , "fn" , "tp" }
429+ if k not in {"tn" , "fp" , "fn" , "tp" , "calibration" }
334430 }
431+ calibration = holdout_metrics .get ("calibration" )
432+ if calibration is not None :
433+ payload ["calibration" ] = calibration
335434 payload ["confusion_matrix" ] = {
336435 "labels" : ["not_duplicate" , "duplicate" ],
337436 "actual_0" : {
@@ -370,6 +469,12 @@ def main(argv: list[str] | None = None) -> int:
370469 default = "0.3,0.5,0.7" ,
371470 help = "Comma-separated thresholds for the optional holdout sweep" ,
372471 )
472+ parser .add_argument (
473+ "--calibration-bins" ,
474+ type = int ,
475+ default = 10 ,
476+ help = "Number of probability bins for the optional calibration report" ,
477+ )
373478 parser .add_argument (
374479 "--artifact-label" ,
375480 default = None ,
@@ -390,6 +495,9 @@ def main(argv: list[str] | None = None) -> int:
390495 if not 0.0 < args .threshold < 1.0 :
391496 print ("--threshold must be between 0 and 1" , file = sys .stderr )
392497 return 1
498+ if args .calibration_bins < 1 :
499+ print ("--calibration-bins must be at least 1" , file = sys .stderr )
500+ return 1
393501 try :
394502 thresholds = _parse_thresholds (args .thresholds )
395503 except ValueError as exc :
@@ -410,6 +518,7 @@ def main(argv: list[str] | None = None) -> int:
410518 clf ,
411519 eval_df ,
412520 threshold = args .threshold ,
521+ calibration_bins = args .calibration_bins ,
413522 )
414523 sweep_metrics = threshold_sweep (
415524 builder ,
0 commit comments