99
1010import numpy as np
1111import pandas as pd
12- from sklearn .metrics import accuracy_score , confusion_matrix , log_loss , roc_auc_score
12+ from sklearn .metrics import (
13+ accuracy_score ,
14+ confusion_matrix ,
15+ f1_score ,
16+ log_loss ,
17+ precision_score ,
18+ recall_score ,
19+ roc_auc_score ,
20+ )
1321
1422from quorabust .model import predict_proba_duplicate
1523from quorabust .persist import load_classifier
@@ -75,46 +83,95 @@ def _persisted_metrics_from_meta(meta: dict[str, Any]) -> dict[str, float]:
7583 }
7684
7785
78- def evaluate_holdout (
86+ def _parse_thresholds (raw : str ) -> list [float ]:
87+ out : list [float ] = []
88+ for part in raw .split ("," ):
89+ value = part .strip ()
90+ if not value :
91+ continue
92+ try :
93+ threshold = float (value )
94+ except ValueError as exc :
95+ raise ValueError (f"Invalid threshold: { value } " ) from exc
96+ if not 0.0 < threshold < 1.0 :
97+ raise ValueError ("thresholds must be between 0 and 1" )
98+ out .append (threshold )
99+ if not out :
100+ raise ValueError ("at least one threshold is required" )
101+ return out
102+
103+
104+ def _metrics_at_threshold (y : np .ndarray , proba : np .ndarray , threshold : float ) -> dict [str , Any ]:
105+ pred = (proba >= threshold ).astype (int )
106+ tn , fp , fn , tp = confusion_matrix (y , pred , labels = [0 , 1 ]).ravel ()
107+ return {
108+ "threshold" : float (threshold ),
109+ "accuracy" : float (accuracy_score (y , pred )),
110+ "precision" : float (precision_score (y , pred , zero_division = 0 )),
111+ "recall" : float (recall_score (y , pred , zero_division = 0 )),
112+ "f1" : float (f1_score (y , pred , zero_division = 0 )),
113+ "tn" : int (tn ),
114+ "fp" : int (fp ),
115+ "fn" : int (fn ),
116+ "tp" : int (tp ),
117+ "predicted_positive_rate" : float (np .mean (pred )),
118+ }
119+
120+
121+ def _holdout_proba (
79122 builder : Any ,
80123 clf : Any ,
81124 df : pd .DataFrame ,
82- * ,
83- threshold : float = 0.5 ,
84- ) -> dict [str , Any ]:
85- """Evaluate a loaded artifact against a labeled Quora-style dataframe."""
125+ ) -> tuple [np .ndarray , np .ndarray ]:
86126 y = df ["is_duplicate" ].astype (int ).to_numpy ()
87127 proba = predict_proba_duplicate (
88128 builder ,
89129 clf ,
90130 df ["question1" ].astype (str ).tolist (),
91131 df ["question2" ].astype (str ).tolist (),
92132 )[:, 1 ]
93- pred = ( proba >= threshold ). astype ( int )
94- tn , fp , fn , tp = confusion_matrix ( y , pred , labels = [ 0 , 1 ]). ravel ()
133+ return y , proba
134+
95135
136+ def evaluate_holdout (
137+ builder : Any ,
138+ clf : Any ,
139+ df : pd .DataFrame ,
140+ * ,
141+ threshold : float = 0.5 ,
142+ ) -> dict [str , Any ]:
143+ """Evaluate a loaded artifact against a labeled Quora-style dataframe."""
144+ y , proba = _holdout_proba (builder , clf , df )
145+ threshold_metrics = _metrics_at_threshold (y , proba , threshold )
96146 metrics : dict [str , Any ] = {
97147 "n" : int (len (df )),
98- "threshold" : float (threshold ),
99- "accuracy" : float (accuracy_score (y , pred )),
100148 "log_loss" : float (log_loss (y , proba , labels = [0 , 1 ])),
101- "tn" : int (tn ),
102- "fp" : int (fp ),
103- "fn" : int (fn ),
104- "tp" : int (tp ),
105149 "positive_rate" : float (np .mean (y )),
106- "predicted_positive_rate" : float ( np . mean ( pred )) ,
150+ ** threshold_metrics ,
107151 }
108152 if len (np .unique (y )) > 1 :
109153 metrics ["roc_auc" ] = float (roc_auc_score (y , proba ))
110154 return metrics
111155
112156
157+ def threshold_sweep (
158+ builder : Any ,
159+ clf : Any ,
160+ df : pd .DataFrame ,
161+ * ,
162+ thresholds : list [float ],
163+ ) -> list [dict [str , Any ]]:
164+ """Evaluate precision/recall tradeoffs across decision thresholds."""
165+ y , proba = _holdout_proba (builder , clf , df )
166+ return [_metrics_at_threshold (y , proba , threshold ) for threshold in thresholds ]
167+
168+
113169def render_model_card (
114170 * ,
115171 artifact : str ,
116172 meta : dict [str , Any ],
117173 holdout_metrics : dict [str , Any ] | None = None ,
174+ sweep_metrics : list [dict [str , Any ]] | None = None ,
118175) -> str :
119176 """Render artifact metadata and optional holdout metrics as Markdown."""
120177 artifact_rows = [
@@ -162,6 +219,9 @@ def render_model_card(
162219 "n" ,
163220 "threshold" ,
164221 "accuracy" ,
222+ "precision" ,
223+ "recall" ,
224+ "f1" ,
165225 "log_loss" ,
166226 "roc_auc" ,
167227 "positive_rate" ,
@@ -184,6 +244,36 @@ def render_model_card(
184244 _markdown_table (["" , "predicted 0" , "predicted 1" ], confusion_rows ),
185245 ]
186246 )
247+ if sweep_metrics :
248+ sweep_rows = [
249+ [
250+ row ["threshold" ],
251+ row ["precision" ],
252+ row ["recall" ],
253+ row ["f1" ],
254+ row ["accuracy" ],
255+ row ["predicted_positive_rate" ],
256+ ]
257+ for row in sweep_metrics
258+ ]
259+ parts .extend (
260+ [
261+ "" ,
262+ "## Threshold Sweep" ,
263+ "" ,
264+ _markdown_table (
265+ [
266+ "threshold" ,
267+ "precision" ,
268+ "recall" ,
269+ "f1" ,
270+ "accuracy" ,
271+ "predicted_positive_rate" ,
272+ ],
273+ sweep_rows ,
274+ ),
275+ ]
276+ )
187277
188278 parts .extend (
189279 [
@@ -213,6 +303,7 @@ def build_report_payload(
213303 artifact : str ,
214304 meta : dict [str , Any ],
215305 holdout_metrics : dict [str , Any ] | None = None ,
306+ sweep_metrics : list [dict [str , Any ]] | None = None ,
216307) -> dict [str , Any ]:
217308 """Build a machine-readable report payload for CI and model comparisons."""
218309 payload : dict [str , Any ] = {
@@ -252,6 +343,8 @@ def build_report_payload(
252343 "predicted_1" : holdout_metrics ["tp" ],
253344 },
254345 }
346+ if sweep_metrics :
347+ payload ["threshold_sweep" ] = sweep_metrics
255348 return payload
256349
257350
@@ -272,6 +365,11 @@ def main(argv: list[str] | None = None) -> int:
272365 default = 0.5 ,
273366 help = "Decision threshold for the optional confusion matrix" ,
274367 )
368+ parser .add_argument (
369+ "--thresholds" ,
370+ default = "0.3,0.5,0.7" ,
371+ help = "Comma-separated thresholds for the optional holdout sweep" ,
372+ )
275373 parser .add_argument (
276374 "--artifact-label" ,
277375 default = None ,
@@ -292,20 +390,33 @@ def main(argv: list[str] | None = None) -> int:
292390 if not 0.0 < args .threshold < 1.0 :
293391 print ("--threshold must be between 0 and 1" , file = sys .stderr )
294392 return 1
393+ try :
394+ thresholds = _parse_thresholds (args .thresholds )
395+ except ValueError as exc :
396+ print (str (exc ), file = sys .stderr )
397+ return 1
295398
296399 builder , clf , meta = load_classifier (args .model )
297400 holdout_metrics = None
401+ sweep_metrics = None
298402 if args .eval_csv is not None :
299403 if not args .eval_csv .is_file ():
300404 print (f"File not found: { args .eval_csv } " , file = sys .stderr )
301405 return 1
302406 try :
407+ eval_df = _load_eval_csv (args .eval_csv )
303408 holdout_metrics = evaluate_holdout (
304409 builder ,
305410 clf ,
306- _load_eval_csv ( args . eval_csv ) ,
411+ eval_df ,
307412 threshold = args .threshold ,
308413 )
414+ sweep_metrics = threshold_sweep (
415+ builder ,
416+ clf ,
417+ eval_df ,
418+ thresholds = thresholds ,
419+ )
309420 except ValueError as exc :
310421 print (str (exc ), file = sys .stderr )
311422 return 1
@@ -317,6 +428,7 @@ def main(argv: list[str] | None = None) -> int:
317428 artifact = artifact ,
318429 meta = meta ,
319430 holdout_metrics = holdout_metrics ,
431+ sweep_metrics = sweep_metrics ,
320432 ),
321433 indent = 2 ,
322434 sort_keys = True ,
@@ -326,6 +438,7 @@ def main(argv: list[str] | None = None) -> int:
326438 artifact = artifact ,
327439 meta = meta ,
328440 holdout_metrics = holdout_metrics ,
441+ sweep_metrics = sweep_metrics ,
329442 )
330443 if args .out is None :
331444 print (report )
0 commit comments