@@ -390,6 +390,58 @@ def render_model_card(
390390 return "\n " .join (parts )
391391
392392
393+ def _comparison_rows (rows : list [dict [str , Any ]]) -> list [dict [str , Any ]]:
394+ return sorted (rows , key = lambda row : row .get ("f1" , 0.0 ), reverse = True )
395+
396+
397+ def render_comparison_report (rows : list [dict [str , Any ]]) -> str :
398+ """Render comparable artifact metrics as a Markdown table."""
399+ metric_rows = [
400+ [
401+ row ["artifact" ],
402+ row .get ("feature_backend" , "" ),
403+ row .get ("threshold" , "" ),
404+ row .get ("f1" , "" ),
405+ row .get ("precision" , "" ),
406+ row .get ("recall" , "" ),
407+ row .get ("accuracy" , "" ),
408+ row .get ("roc_auc" , "" ),
409+ row .get ("log_loss" , "" ),
410+ ]
411+ for row in _comparison_rows (rows )
412+ ]
413+ return "\n " .join (
414+ [
415+ "# Quorabust Model Comparison" ,
416+ "" ,
417+ "## Backend Comparison" ,
418+ "" ,
419+ _markdown_table (
420+ [
421+ "artifact" ,
422+ "feature_backend" ,
423+ "threshold" ,
424+ "f1" ,
425+ "precision" ,
426+ "recall" ,
427+ "accuracy" ,
428+ "roc_auc" ,
429+ "log_loss" ,
430+ ],
431+ metric_rows ,
432+ ),
433+ "" ,
434+ "## Caveats" ,
435+ "" ,
436+ (
437+ "Compare rows only when every artifact was evaluated against the same "
438+ "holdout CSV, threshold policy, and metric code."
439+ ),
440+ "" ,
441+ ]
442+ )
443+
444+
393445def build_report_payload (
394446 * ,
395447 artifact : str ,
@@ -447,6 +499,50 @@ def build_report_payload(
447499 return payload
448500
449501
502+ def _parse_compare_model (raw : str ) -> tuple [str , Path ]:
503+ if "=" not in raw :
504+ raise ValueError ("--compare-model must use label=path" )
505+ label , value = raw .split ("=" , 1 )
506+ label = label .strip ()
507+ path = Path (value .strip ())
508+ if not label :
509+ raise ValueError ("--compare-model label cannot be empty" )
510+ if not path .is_file ():
511+ raise ValueError (f"File not found: { path } " )
512+ return label , path
513+
514+
515+ def _comparison_metrics (
516+ label : str ,
517+ path : Path ,
518+ eval_df : pd .DataFrame ,
519+ * ,
520+ threshold : float ,
521+ calibration_bins : int ,
522+ ) -> dict [str , Any ]:
523+ builder , clf , meta = load_classifier (path )
524+ metrics = evaluate_holdout (
525+ builder ,
526+ clf ,
527+ eval_df ,
528+ threshold = threshold ,
529+ calibration_bins = calibration_bins ,
530+ )
531+ return {
532+ "artifact" : label ,
533+ "feature_backend" : meta .get ("feature_backend" , "" ),
534+ "threshold" : metrics .get ("threshold" ),
535+ "accuracy" : metrics .get ("accuracy" ),
536+ "precision" : metrics .get ("precision" ),
537+ "recall" : metrics .get ("recall" ),
538+ "f1" : metrics .get ("f1" ),
539+ "roc_auc" : metrics .get ("roc_auc" ),
540+ "log_loss" : metrics .get ("log_loss" ),
541+ "positive_rate" : metrics .get ("positive_rate" ),
542+ "predicted_positive_rate" : metrics .get ("predicted_positive_rate" ),
543+ }
544+
545+
450546def main (argv : list [str ] | None = None ) -> int :
451547 parser = argparse .ArgumentParser (
452548 description = "Generate a model report for a Quorabust artifact." ,
@@ -480,6 +576,12 @@ def main(argv: list[str] | None = None) -> int:
480576 default = None ,
481577 help = "Public artifact label to print instead of the local model path" ,
482578 )
579+ parser .add_argument (
580+ "--compare-model" ,
581+ action = "append" ,
582+ default = [],
583+ help = "Compare another artifact on the same eval CSV, formatted as label=path" ,
584+ )
483585 parser .add_argument (
484586 "--format" ,
485587 choices = ["markdown" , "json" ],
@@ -507,6 +609,7 @@ def main(argv: list[str] | None = None) -> int:
507609 builder , clf , meta = load_classifier (args .model )
508610 holdout_metrics = None
509611 sweep_metrics = None
612+ comparison_metrics = None
510613 if args .eval_csv is not None :
511614 if not args .eval_csv .is_file ():
512615 print (f"File not found: { args .eval_csv } " , file = sys .stderr )
@@ -526,22 +629,37 @@ def main(argv: list[str] | None = None) -> int:
526629 eval_df ,
527630 thresholds = thresholds ,
528631 )
632+ if args .compare_model :
633+ comparison_metrics = [
634+ _comparison_metrics (
635+ label ,
636+ path ,
637+ eval_df ,
638+ threshold = args .threshold ,
639+ calibration_bins = args .calibration_bins ,
640+ )
641+ for label , path in (_parse_compare_model (raw ) for raw in args .compare_model )
642+ ]
529643 except ValueError as exc :
530644 print (str (exc ), file = sys .stderr )
531645 return 1
646+ elif args .compare_model :
647+ print ("--compare-model requires --eval-csv" , file = sys .stderr )
648+ return 1
532649
533650 artifact = args .artifact_label or str (args .model .resolve ())
534651 if args .format == "json" :
535- report = json .dumps (
536- build_report_payload (
537- artifact = artifact ,
538- meta = meta ,
539- holdout_metrics = holdout_metrics ,
540- sweep_metrics = sweep_metrics ,
541- ),
542- indent = 2 ,
543- sort_keys = True ,
652+ payload = build_report_payload (
653+ artifact = artifact ,
654+ meta = meta ,
655+ holdout_metrics = holdout_metrics ,
656+ sweep_metrics = sweep_metrics ,
544657 )
658+ if comparison_metrics is not None :
659+ payload ["comparison" ] = _comparison_rows (comparison_metrics )
660+ report = json .dumps (payload , indent = 2 , sort_keys = True )
661+ elif comparison_metrics is not None :
662+ report = render_comparison_report (comparison_metrics )
545663 else :
546664 report = render_model_card (
547665 artifact = artifact ,
0 commit comments