Skip to content

Commit d34cb09

Browse files
committed
feat(report): compare backend artifacts
1 parent 703064b commit d34cb09

5 files changed

Lines changed: 215 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- Holdout-selected `decision_threshold` metadata from `quorabust-train`.
1818
- `quorabust-validate-report` release gate for machine-readable model-card JSON.
1919
- Optional `--feature-backend cross-encoder` for modern transformer pair scoring.
20+
- `quorabust-report --compare-model label=path` for same-holdout backend comparisons.
2021

2122
### Fixed
2223
- Capped NumPy below 2.5 so Mypy can parse dependency stubs with the configured

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ comparable model claims; the command accepts the same `question1`, `question2`,
8686
`is_duplicate` column contract as training.
8787
Use `quorabust-validate-report --require-holdout --require-calibration` to fail release
8888
jobs when a JSON model card is missing required review fields.
89+
Use repeated `--compare-model label=path` flags to compare TF-IDF, embedding, and
90+
cross-encoder artifacts against the same holdout split.
8991
See [docs/REPORTING.md](docs/REPORTING.md) for the CI smoke workflow and
9092
real-evaluation checklist.
9193

docs/REPORTING.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,24 @@ quorabust-validate-report \
9393
--require-calibration
9494
```
9595

96+
Use repeated `--compare-model label=path` arguments when you need to compare trained
97+
backends against the same holdout split:
98+
99+
```bash
100+
quorabust-report \
101+
--model models/quorabust-tfidf.pkl \
102+
--artifact-label quorabust-tfidf.pkl \
103+
--compare-model tfidf=models/quorabust-tfidf.pkl \
104+
--compare-model embedding=models/quorabust-embedding.pkl \
105+
--compare-model cross=models/quorabust-cross.pkl \
106+
--eval-csv data/processed/holdout.csv \
107+
--format json \
108+
--out reports/backend-comparison.json
109+
```
110+
111+
The comparison rows are sorted by F1. Treat the table as meaningful only when all
112+
artifacts use the same holdout CSV, threshold, and metric code.
113+
96114
When `quorabust-train` has a holdout split, it stores a selected `decision_threshold` in
97115
artifact metadata. The threshold is chosen from `--thresholds` by maximizing
98116
`--threshold-metric` (default `f1`). Serving uses that artifact threshold unless the

src/quorabust/report.py

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
393445
def 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+
450546
def 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,

tests/test_report.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
calibration_summary,
1010
evaluate_holdout,
1111
main,
12+
render_comparison_report,
1213
render_model_card,
1314
threshold_sweep,
1415
)
@@ -145,6 +146,37 @@ def test_threshold_sweep_returns_tradeoff_rows(tmp_path):
145146
assert all(0.0 <= row["recall"] <= 1.0 for row in rows)
146147

147148

149+
def test_render_comparison_report_sorts_by_f1():
150+
report = render_comparison_report(
151+
[
152+
{
153+
"artifact": "tfidf",
154+
"feature_backend": "tfidf",
155+
"threshold": 0.5,
156+
"accuracy": 0.7,
157+
"precision": 0.7,
158+
"recall": 0.7,
159+
"f1": 0.7,
160+
"roc_auc": 0.75,
161+
"log_loss": 0.5,
162+
},
163+
{
164+
"artifact": "cross",
165+
"feature_backend": "cross-encoder",
166+
"threshold": 0.5,
167+
"accuracy": 0.9,
168+
"precision": 0.9,
169+
"recall": 0.9,
170+
"f1": 0.9,
171+
"roc_auc": 0.95,
172+
"log_loss": 0.2,
173+
},
174+
]
175+
)
176+
assert report.index("| cross |") < report.index("| tfidf |")
177+
assert "## Backend Comparison" in report
178+
179+
148180
def test_report_cli_writes_model_card(tmp_path):
149181
model, _, _ = _artifact(tmp_path)
150182
eval_csv = tmp_path / "eval.csv"
@@ -208,6 +240,41 @@ def test_report_cli_writes_json_payload(tmp_path):
208240
assert len(payload["threshold_sweep"]) == 3
209241

210242

243+
def test_report_cli_writes_comparison_json(tmp_path):
244+
model, _, _ = _artifact(tmp_path)
245+
eval_csv = tmp_path / "eval.csv"
246+
_df().to_csv(eval_csv, index=False)
247+
out = tmp_path / "COMPARE.json"
248+
249+
assert (
250+
main(
251+
[
252+
"--model",
253+
str(model),
254+
"--compare-model",
255+
f"tfidf={model}",
256+
"--eval-csv",
257+
str(eval_csv),
258+
"--format",
259+
"json",
260+
"--out",
261+
str(out),
262+
]
263+
)
264+
== 0
265+
)
266+
267+
payload = json.loads(out.read_text(encoding="utf-8"))
268+
assert payload["artifact"] == str(model.resolve())
269+
assert payload["comparison"][0]["artifact"] == "tfidf"
270+
assert payload["comparison"][0]["feature_backend"] == "tfidf"
271+
272+
273+
def test_report_cli_rejects_comparison_without_eval_csv(tmp_path):
274+
model, _, _ = _artifact(tmp_path)
275+
assert main(["--model", str(model), "--compare-model", f"tfidf={model}"]) == 1
276+
277+
211278
def test_report_cli_rejects_bad_threshold(tmp_path):
212279
model, _, _ = _artifact(tmp_path)
213280
assert main(["--model", str(model), "--threshold", "1.5"]) == 1

0 commit comments

Comments
 (0)