Skip to content

Commit 4fce240

Browse files
committed
feat(report): add threshold sweep metrics
1 parent bb7c63c commit 4fce240

6 files changed

Lines changed: 196 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- `GET /models` serving endpoint for safe loaded-model metadata.
99
- `examples/smoke_pairs.csv` and CI coverage for the train-to-report workflow.
1010
- JSON output for `quorabust-report --format json`.
11+
- Precision, recall, F1, and configurable threshold sweeps in model reports.
1112

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

README.md

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

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

docs/REPORTING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ quorabust-report \
2020
--model /tmp/quorabust-smoke.pkl \
2121
--artifact-label quorabust-smoke.pkl \
2222
--eval-csv examples/smoke_pairs.csv \
23+
--thresholds 0.3,0.5,0.7 \
2324
--out /tmp/quorabust-smoke-model-card.md
2425

2526
quorabust-report \
@@ -33,6 +34,10 @@ quorabust-report \
3334
The smoke dataset proves the command path works. It is not a benchmark and should not be
3435
used for public model-quality claims.
3536

37+
Reports include precision, recall, F1, accuracy, and predicted-positive rate at the
38+
selected threshold plus a threshold sweep. Use `--thresholds` to compare operating
39+
points before choosing one.
40+
3641
## Real Evaluation
3742

3843
For comparable numbers, generate the report from a held-out CSV that was not used for
@@ -49,6 +54,7 @@ quorabust-report \
4954
--model models/quorabust.pkl \
5055
--artifact-label quorabust-tfidf-v1.pkl \
5156
--eval-csv data/processed/holdout.csv \
57+
--thresholds 0.2,0.3,0.4,0.5,0.6,0.7,0.8 \
5258
--out reports/quorabust-tfidf-v1.md
5359
```
5460

docs/SAMPLE_MODEL_CARD.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Scores pairs of short natural-language questions and returns the probability tha
2929
| n_eval | 0 |
3030
| seed | 7 |
3131
| quorabust_version | 0.3.2 |
32-
| git_revision | 669dd7dffb715e2d5d84c7ec3ce8315219115057 |
32+
| git_revision | bb7c63ccdf8b22f8ed963dd312013eb2bd6f6719 |
3333
| csv_sha256 | 0a7b4ad6c6bb12fd038d5d7673eaced0646a8914c52dbdf502d2476b29ad6132 |
3434

3535
## Persisted Evaluation
@@ -47,6 +47,9 @@ Scores pairs of short natural-language questions and returns the probability tha
4747
| n | 32 |
4848
| threshold | 0.5000 |
4949
| accuracy | 0.9375 |
50+
| precision | 0.9444 |
51+
| recall | 0.9444 |
52+
| f1 | 0.9444 |
5053
| log_loss | 0.1958 |
5154
| roc_auc | 0.9841 |
5255
| positive_rate | 0.5625 |
@@ -59,6 +62,14 @@ Scores pairs of short natural-language questions and returns the probability tha
5962
| actual 0 | 13 | 1 |
6063
| actual 1 | 1 | 17 |
6164

65+
## Threshold Sweep
66+
67+
| threshold | precision | recall | f1 | accuracy | predicted_positive_rate |
68+
| --- | --- | --- | --- | --- | --- |
69+
| 0.3000 | 0.8182 | 1.0000 | 0.9000 | 0.8750 | 0.6875 |
70+
| 0.5000 | 0.9444 | 0.9444 | 0.9444 | 0.9375 | 0.5625 |
71+
| 0.7000 | 1.0000 | 0.8333 | 0.9091 | 0.9062 | 0.4688 |
72+
6273
## Serving Contract
6374

6475
`POST /predict` accepts `question1` and `question2` arrays of equal length and returns `proba_duplicate` in the same order. `GET /metrics` exposes Prometheus counters and latency histograms.
@@ -72,8 +83,7 @@ Performance depends on the training data distribution and threshold. Re-run this
7283
```bash
7384
pip install -e ".[dev]"
7485
quorabust-train --csv examples/smoke_pairs.csv --out /tmp/quorabust-smoke.pkl --eval-fraction 0 --seed 7
75-
quorabust-report --model /tmp/quorabust-smoke.pkl --artifact-label quorabust-smoke.pkl --eval-csv examples/smoke_pairs.csv --out docs/SAMPLE_MODEL_CARD.md
86+
quorabust-report --model /tmp/quorabust-smoke.pkl --artifact-label quorabust-smoke.pkl --eval-csv examples/smoke_pairs.csv --thresholds 0.3,0.5,0.7 --out docs/SAMPLE_MODEL_CARD.md
7687
```
7788

7889
For a real model card, run the same `train`/`report` commands against the Kaggle Quora Question Pairs CSV instead of the smoke fixture.
79-

src/quorabust/report.py

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@
99

1010
import numpy as np
1111
import 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

1422
from quorabust.model import predict_proba_duplicate
1523
from 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+
113169
def 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

Comments
 (0)