Skip to content

Commit 8130884

Browse files
committed
feat(report): support json output
1 parent 1162038 commit 8130884

6 files changed

Lines changed: 175 additions & 30 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,11 @@ jobs:
4444
--artifact-label quorabust-smoke.pkl \
4545
--eval-csv examples/smoke_pairs.csv \
4646
--out /tmp/quorabust-smoke-model-card.md
47+
quorabust-report \
48+
--model /tmp/quorabust-smoke.pkl \
49+
--artifact-label quorabust-smoke.pkl \
50+
--eval-csv examples/smoke_pairs.csv \
51+
--format json \
52+
--out /tmp/quorabust-smoke-model-card.json
4753
test -s /tmp/quorabust-smoke-model-card.md
54+
test -s /tmp/quorabust-smoke-model-card.json

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
optional labeled holdout CSVs.
88
- `GET /models` serving endpoint for safe loaded-model metadata.
99
- `examples/smoke_pairs.csv` and CI coverage for the train-to-report workflow.
10+
- JSON output for `quorabust-report --format json`.
1011

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

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,11 @@ quorabust-report \
7171
```
7272

7373
The report includes artifact metadata, persisted training/eval metrics, optional holdout
74-
metrics, and a threshold confusion matrix. Use a real held-out CSV for comparable model
75-
claims; the command accepts the same `question1`, `question2`, `is_duplicate` column
76-
contract as training. See [docs/REPORTING.md](docs/REPORTING.md) for the CI smoke
77-
workflow and real-evaluation checklist.
74+
metrics, and a threshold confusion matrix. Use `--format json` for machine-readable CI or
75+
release artifacts. Use a real held-out CSV for comparable model claims; the command
76+
accepts the same `question1`, `question2`, `is_duplicate` column contract as training.
77+
See [docs/REPORTING.md](docs/REPORTING.md) for the CI smoke workflow and
78+
real-evaluation checklist.
7879

7980
### Load a saved model
8081

docs/REPORTING.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ quorabust-report \
2121
--artifact-label quorabust-smoke.pkl \
2222
--eval-csv examples/smoke_pairs.csv \
2323
--out /tmp/quorabust-smoke-model-card.md
24+
25+
quorabust-report \
26+
--model /tmp/quorabust-smoke.pkl \
27+
--artifact-label quorabust-smoke.pkl \
28+
--eval-csv examples/smoke_pairs.csv \
29+
--format json \
30+
--out /tmp/quorabust-smoke-model-card.json
2431
```
2532

2633
The smoke dataset proves the command path works. It is not a benchmark and should not be
@@ -48,3 +55,6 @@ quorabust-report \
4855
Record the dataset source, split method, command, commit SHA, and date next to any
4956
published result. Do not compare artifacts unless they use the same holdout split and
5057
threshold.
58+
59+
Use `--format json` when you want CI or release tooling to compare metrics without
60+
scraping Markdown.

src/quorabust/report.py

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@
1515
from quorabust.persist import load_classifier
1616

1717
_REQUIRED_COLUMNS = {"question1", "question2", "is_duplicate"}
18+
_METADATA_KEYS = [
19+
"feature_backend",
20+
"feature_schema",
21+
"n_train",
22+
"n_eval",
23+
"seed",
24+
"quorabust_version",
25+
"git_revision",
26+
"csv_sha256",
27+
]
1828

1929

2030
def _package_version() -> str:
@@ -53,6 +63,18 @@ def _markdown_table(headers: list[str], rows: list[list[Any]]) -> str:
5363
return "\n".join(out)
5464

5565

66+
def _metadata_from_meta(meta: dict[str, Any]) -> dict[str, Any]:
67+
return {k: meta[k] for k in _METADATA_KEYS if k in meta}
68+
69+
70+
def _persisted_metrics_from_meta(meta: dict[str, Any]) -> dict[str, float]:
71+
return {
72+
k.removeprefix("eval_"): float(v)
73+
for k, v in sorted(meta.items())
74+
if k.startswith("eval_") and isinstance(v, int | float)
75+
}
76+
77+
5678
def evaluate_holdout(
5779
builder: Any,
5880
clf: Any,
@@ -100,22 +122,8 @@ def render_model_card(
100122
["generated_by", f"Quorabust {_package_version()}"],
101123
]
102124

103-
metadata_keys = [
104-
"feature_backend",
105-
"feature_schema",
106-
"n_train",
107-
"n_eval",
108-
"seed",
109-
"quorabust_version",
110-
"git_revision",
111-
"csv_sha256",
112-
]
113-
metadata_rows = [[k, meta[k]] for k in metadata_keys if k in meta]
114-
persisted_metric_rows = [
115-
[k.removeprefix("eval_"), meta[k]]
116-
for k in sorted(meta)
117-
if k.startswith("eval_") and isinstance(meta[k], int | float)
118-
]
125+
metadata_rows = [[k, v] for k, v in _metadata_from_meta(meta).items()]
126+
persisted_metric_rows = [[k, v] for k, v in _persisted_metrics_from_meta(meta).items()]
119127

120128
parts = [
121129
"# Quorabust Model Card",
@@ -200,9 +208,56 @@ def render_model_card(
200208
return "\n".join(parts)
201209

202210

211+
def build_report_payload(
212+
*,
213+
artifact: str,
214+
meta: dict[str, Any],
215+
holdout_metrics: dict[str, Any] | None = None,
216+
) -> dict[str, Any]:
217+
"""Build a machine-readable report payload for CI and model comparisons."""
218+
payload: dict[str, Any] = {
219+
"artifact": artifact,
220+
"generated_by": f"Quorabust {_package_version()}",
221+
"intended_use": (
222+
"Scores pairs of short natural-language questions and returns the probability "
223+
"that the pair is semantically duplicate."
224+
),
225+
"training_metadata": _metadata_from_meta(meta),
226+
"persisted_evaluation": _persisted_metrics_from_meta(meta),
227+
"serving_contract": {
228+
"predict": "POST /predict",
229+
"metrics": "GET /metrics",
230+
"input": {"question1": "list[str]", "question2": "list[str]"},
231+
"output": {"proba_duplicate": "list[float]"},
232+
},
233+
"caveats": [
234+
"Performance depends on the training data distribution and threshold.",
235+
"Re-run on a current holdout set before comparing artifacts.",
236+
],
237+
}
238+
if holdout_metrics is not None:
239+
payload["holdout_evaluation"] = {
240+
k: v
241+
for k, v in holdout_metrics.items()
242+
if k not in {"tn", "fp", "fn", "tp"}
243+
}
244+
payload["confusion_matrix"] = {
245+
"labels": ["not_duplicate", "duplicate"],
246+
"actual_0": {
247+
"predicted_0": holdout_metrics["tn"],
248+
"predicted_1": holdout_metrics["fp"],
249+
},
250+
"actual_1": {
251+
"predicted_0": holdout_metrics["fn"],
252+
"predicted_1": holdout_metrics["tp"],
253+
},
254+
}
255+
return payload
256+
257+
203258
def main(argv: list[str] | None = None) -> int:
204259
parser = argparse.ArgumentParser(
205-
description="Generate a Markdown model card for a Quorabust artifact.",
260+
description="Generate a model report for a Quorabust artifact.",
206261
)
207262
parser.add_argument("--model", type=Path, required=True, help="Saved .pkl artifact")
208263
parser.add_argument(
@@ -222,7 +277,13 @@ def main(argv: list[str] | None = None) -> int:
222277
default=None,
223278
help="Public artifact label to print instead of the local model path",
224279
)
225-
parser.add_argument("--out", type=Path, default=None, help="Write Markdown here")
280+
parser.add_argument(
281+
"--format",
282+
choices=["markdown", "json"],
283+
default="markdown",
284+
help="Report format to print or write",
285+
)
286+
parser.add_argument("--out", type=Path, default=None, help="Write report here")
226287
args = parser.parse_args(argv)
227288

228289
if not args.model.is_file():
@@ -249,16 +310,28 @@ def main(argv: list[str] | None = None) -> int:
249310
print(str(exc), file=sys.stderr)
250311
return 1
251312

252-
card = render_model_card(
253-
artifact=args.artifact_label or str(args.model.resolve()),
254-
meta=meta,
255-
holdout_metrics=holdout_metrics,
256-
)
313+
artifact = args.artifact_label or str(args.model.resolve())
314+
if args.format == "json":
315+
report = json.dumps(
316+
build_report_payload(
317+
artifact=artifact,
318+
meta=meta,
319+
holdout_metrics=holdout_metrics,
320+
),
321+
indent=2,
322+
sort_keys=True,
323+
)
324+
else:
325+
report = render_model_card(
326+
artifact=artifact,
327+
meta=meta,
328+
holdout_metrics=holdout_metrics,
329+
)
257330
if args.out is None:
258-
print(card)
331+
print(report)
259332
else:
260333
args.out.parent.mkdir(parents=True, exist_ok=True)
261-
args.out.write_text(card, encoding="utf-8")
334+
args.out.write_text(report + "\n", encoding="utf-8")
262335
print(f"wrote {args.out.resolve()}")
263336
return 0
264337

tests/test_report.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import json
2+
13
import pandas as pd
24

35
from quorabust.model import train_duplicate_classifier
46
from quorabust.persist import save_classifier
5-
from quorabust.report import evaluate_holdout, main, render_model_card
7+
from quorabust.report import build_report_payload, evaluate_holdout, main, render_model_card
68

79

810
def _df():
@@ -47,6 +49,27 @@ def test_render_model_card_includes_metadata_and_persisted_metrics():
4749
assert "| log_loss | 0.6100 |" in card
4850

4951

52+
def test_build_report_payload_is_machine_readable():
53+
payload = build_report_payload(
54+
artifact="model.pkl",
55+
meta={"feature_backend": "tfidf", "eval_accuracy": 0.75, "csv": "/private/train.csv"},
56+
holdout_metrics={
57+
"n": 10,
58+
"threshold": 0.5,
59+
"accuracy": 0.8,
60+
"tn": 4,
61+
"fp": 1,
62+
"fn": 1,
63+
"tp": 4,
64+
},
65+
)
66+
assert payload["artifact"] == "model.pkl"
67+
assert payload["training_metadata"] == {"feature_backend": "tfidf"}
68+
assert payload["persisted_evaluation"]["accuracy"] == 0.75
69+
assert payload["confusion_matrix"]["actual_1"]["predicted_1"] == 4
70+
assert "csv" not in payload["training_metadata"]
71+
72+
5073
def test_evaluate_holdout_returns_confusion_counts(tmp_path):
5174
_, builder, clf = _artifact(tmp_path)
5275
metrics = evaluate_holdout(builder, clf, _df(), threshold=0.5)
@@ -83,6 +106,36 @@ def test_report_cli_writes_model_card(tmp_path):
83106
assert "## Confusion Matrix" in card
84107

85108

109+
def test_report_cli_writes_json_payload(tmp_path):
110+
model, _, _ = _artifact(tmp_path)
111+
eval_csv = tmp_path / "eval.csv"
112+
_df().to_csv(eval_csv, index=False)
113+
out = tmp_path / "MODEL_CARD.json"
114+
115+
assert (
116+
main(
117+
[
118+
"--model",
119+
str(model),
120+
"--artifact-label",
121+
"smoke-model.pkl",
122+
"--eval-csv",
123+
str(eval_csv),
124+
"--format",
125+
"json",
126+
"--out",
127+
str(out),
128+
]
129+
)
130+
== 0
131+
)
132+
133+
payload = json.loads(out.read_text(encoding="utf-8"))
134+
assert payload["artifact"] == "smoke-model.pkl"
135+
assert payload["holdout_evaluation"]["n"] == 30
136+
assert payload["confusion_matrix"]["labels"] == ["not_duplicate", "duplicate"]
137+
138+
86139
def test_report_cli_rejects_bad_threshold(tmp_path):
87140
model, _, _ = _artifact(tmp_path)
88141
assert main(["--model", str(model), "--threshold", "1.5"]) == 1

0 commit comments

Comments
 (0)