Skip to content

Commit d389700

Browse files
committed
feat(report): add calibration diagnostics
1 parent f86b91a commit d389700

6 files changed

Lines changed: 205 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- `quorabust-train --metadata-out` for JSON metadata sidecars.
1414
- Thresholded `/predict` decisions with artifact/env defaults and per-request override.
1515
- Enterprise product-positioning guide for semantic matching use cases and production gaps.
16+
- Calibration summary and probability-bin diagnostics in `quorabust-report`.
1617

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

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ quorabust-report \
7878
```
7979

8080
The report includes artifact metadata, persisted training/eval metrics, optional holdout
81-
metrics, a confusion matrix, and a precision/recall/F1 threshold sweep. Use
81+
metrics, a confusion matrix, a precision/recall/F1 threshold sweep, and probability
82+
calibration diagnostics. Use `--calibration-bins` to tune the calibration table and
8283
`--format json` for machine-readable CI or release artifacts. Use a real held-out CSV for
8384
comparable model claims; the command accepts the same `question1`, `question2`,
8485
`is_duplicate` column contract as training.

docs/REPORTING.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
Use `quorabust-report` to turn a saved artifact into a Markdown model card. The report
44
is meant for artifact review: it shows lineage, feature schema, persisted metrics, and
5-
optional holdout metrics with a threshold confusion matrix.
5+
optional holdout metrics with a threshold confusion matrix, threshold sweep, and
6+
probability calibration summary.
67

78
## Smoke Workflow
89

@@ -22,6 +23,7 @@ quorabust-report \
2223
--artifact-label quorabust-smoke.pkl \
2324
--eval-csv examples/smoke_pairs.csv \
2425
--thresholds 0.3,0.5,0.7 \
26+
--calibration-bins 5 \
2527
--out /tmp/quorabust-smoke-model-card.md
2628

2729
quorabust-report \
@@ -39,6 +41,11 @@ Reports include precision, recall, F1, accuracy, and predicted-positive rate at
3941
selected threshold plus a threshold sweep. Use `--thresholds` to compare operating
4042
points before choosing one.
4143

44+
Reports also include calibration diagnostics: Brier score, expected calibration error,
45+
mean predicted probability, mean observed rate, and probability-bin rows. Use
46+
`--calibration-bins` to control how many bins are printed. Calibration helps decide
47+
whether probabilities are fit for thresholding or only useful for ranking.
48+
4249
## Real Evaluation
4350

4451
For comparable numbers, generate the report from a held-out CSV that was not used for
@@ -57,6 +64,7 @@ quorabust-report \
5764
--artifact-label quorabust-tfidf-v1.pkl \
5865
--eval-csv data/processed/holdout.csv \
5966
--thresholds 0.2,0.3,0.4,0.5,0.6,0.7,0.8 \
67+
--calibration-bins 10 \
6068
--out reports/quorabust-tfidf-v1.md
6169
```
6270

docs/SAMPLE_MODEL_CARD.md

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
# Quorabust Model Card
22

3-
> ⚠️ **Smoke demo, not a benchmark.** This card was generated by `quorabust-report`
4-
> from the tiny synthetic [`examples/smoke_pairs.csv`](../examples/smoke_pairs.csv)
5-
> (32 rows) — the same fixture CI uses to exercise the train → report path. Its
6-
> classes are trivially separable, so the scores below describe that toy data and say
7-
> **nothing** about real-world quality on the Kaggle Quora Question Pairs set. It
8-
> exists only to show the shape of a generated card. Regenerate with the commands in
9-
> [Reproduce](#reproduce).
3+
> Smoke demo, not a benchmark. This card was generated by `quorabust-report` from the
4+
> tiny synthetic [`examples/smoke_pairs.csv`](../examples/smoke_pairs.csv) fixture. The
5+
> scores below describe that toy data and say nothing about real-world quality on the
6+
> Kaggle Quora Question Pairs set. It exists only to show the shape of a generated card.
107
118
## Artifact
129

@@ -29,7 +26,7 @@ Scores pairs of short natural-language questions and returns the probability tha
2926
| n_eval | 0 |
3027
| seed | 7 |
3128
| quorabust_version | 0.3.2 |
32-
| git_revision | bb7c63ccdf8b22f8ed963dd312013eb2bd6f6719 |
29+
| git_revision | f86b91a0b6166591d9c366de8ce54d187cb0c3cd |
3330
| csv_sha256 | 0a7b4ad6c6bb12fd038d5d7673eaced0646a8914c52dbdf502d2476b29ad6132 |
3431

3532
## Persisted Evaluation
@@ -62,6 +59,26 @@ Scores pairs of short natural-language questions and returns the probability tha
6259
| actual 0 | 13 | 1 |
6360
| actual 1 | 1 | 17 |
6461

62+
## Calibration Summary
63+
64+
| metric | value |
65+
| --- | --- |
66+
| n_bins | 5 |
67+
| brier_score | 0.0533 |
68+
| expected_calibration_error | 0.0916 |
69+
| mean_predicted_probability | 0.5647 |
70+
| mean_observed_rate | 0.5625 |
71+
72+
## Calibration Bins
73+
74+
| probability_bin | count | mean_predicted_probability | observed_positive_rate | absolute_error |
75+
| --- | --- | --- | --- | --- |
76+
| 0.00-0.20 | 10 | 0.0741 | 0.0000 | 0.0741 |
77+
| 0.20-0.40 | 4 | 0.3557 | 0.2500 | 0.1057 |
78+
| 0.40-0.60 | 1 | 0.5334 | 1.0000 | 0.4666 |
79+
| 0.60-0.80 | 2 | 0.6685 | 0.5000 | 0.1685 |
80+
| 0.80-1.00 | 15 | 0.9358 | 1.0000 | 0.0642 |
81+
6582
## Threshold Sweep
6683

6784
| threshold | precision | recall | f1 | accuracy | predicted_positive_rate |
@@ -72,18 +89,33 @@ Scores pairs of short natural-language questions and returns the probability tha
7289

7390
## Serving Contract
7491

75-
`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.
92+
`POST /predict` accepts `question1` and `question2` arrays of equal length and returns `proba_duplicate`, `is_duplicate`, and `decision_threshold` in the same order. `GET /metrics` exposes Prometheus counters and latency histograms.
7693

7794
## Caveats
7895

7996
Performance depends on the training data distribution and threshold. Re-run this card on a current holdout set before comparing artifacts.
8097

8198
## Reproduce
8299

100+
Generated from the checked-in smoke fixture:
101+
83102
```bash
84-
pip install -e ".[dev]"
85-
quorabust-train --csv examples/smoke_pairs.csv --out /tmp/quorabust-smoke.pkl --eval-fraction 0 --seed 7
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
103+
quorabust-train \
104+
--csv examples/smoke_pairs.csv \
105+
--out /tmp/quorabust-smoke.pkl \
106+
--metadata-out /tmp/quorabust-smoke.meta.json \
107+
--eval-fraction 0 \
108+
--seed 7
109+
110+
quorabust-report \
111+
--model /tmp/quorabust-smoke.pkl \
112+
--artifact-label quorabust-smoke.pkl \
113+
--eval-csv examples/smoke_pairs.csv \
114+
--thresholds 0.3,0.5,0.7 \
115+
--calibration-bins 5 \
116+
--out docs/SAMPLE_MODEL_CARD.md
87117
```
88118

89-
For a real model card, run the same `train`/`report` commands against the Kaggle Quora Question Pairs CSV instead of the smoke fixture.
119+
For a real model card, run the same `train` and `report` commands against the Kaggle
120+
Quora Question Pairs CSV or a labeled customer-domain holdout instead of the smoke
121+
fixture.

src/quorabust/report.py

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import pandas as pd
1212
from sklearn.metrics import (
1313
accuracy_score,
14+
brier_score_loss,
1415
confusion_matrix,
1516
f1_score,
1617
log_loss,
@@ -118,6 +119,54 @@ def _metrics_at_threshold(y: np.ndarray, proba: np.ndarray, threshold: float) ->
118119
}
119120

120121

122+
def calibration_summary(
123+
y: np.ndarray,
124+
proba: np.ndarray,
125+
*,
126+
n_bins: int = 10,
127+
) -> dict[str, Any]:
128+
"""Summarize whether predicted probabilities match observed positive rates."""
129+
if n_bins < 1:
130+
raise ValueError("n_bins must be at least 1")
131+
132+
bins: list[dict[str, Any]] = []
133+
total = len(y)
134+
expected_calibration_error = 0.0
135+
for idx in range(n_bins):
136+
lower = idx / n_bins
137+
upper = (idx + 1) / n_bins
138+
if idx == n_bins - 1:
139+
mask = (proba >= lower) & (proba <= upper)
140+
else:
141+
mask = (proba >= lower) & (proba < upper)
142+
count = int(np.sum(mask))
143+
if count == 0:
144+
continue
145+
mean_predicted = float(np.mean(proba[mask]))
146+
observed_rate = float(np.mean(y[mask]))
147+
absolute_error = abs(mean_predicted - observed_rate)
148+
expected_calibration_error += (count / total) * absolute_error
149+
bins.append(
150+
{
151+
"lower": float(lower),
152+
"upper": float(upper),
153+
"count": count,
154+
"mean_predicted_probability": mean_predicted,
155+
"observed_positive_rate": observed_rate,
156+
"absolute_error": float(absolute_error),
157+
}
158+
)
159+
160+
return {
161+
"n_bins": int(n_bins),
162+
"brier_score": float(brier_score_loss(y, proba)),
163+
"expected_calibration_error": float(expected_calibration_error),
164+
"mean_predicted_probability": float(np.mean(proba)),
165+
"mean_observed_rate": float(np.mean(y)),
166+
"bins": bins,
167+
}
168+
169+
121170
def _holdout_proba(
122171
builder: Any,
123172
clf: Any,
@@ -139,6 +188,7 @@ def evaluate_holdout(
139188
df: pd.DataFrame,
140189
*,
141190
threshold: float = 0.5,
191+
calibration_bins: int = 10,
142192
) -> dict[str, Any]:
143193
"""Evaluate a loaded artifact against a labeled Quora-style dataframe."""
144194
y, proba = _holdout_proba(builder, clf, df)
@@ -147,6 +197,7 @@ def evaluate_holdout(
147197
"n": int(len(df)),
148198
"log_loss": float(log_loss(y, proba, labels=[0, 1])),
149199
"positive_rate": float(np.mean(y)),
200+
"calibration": calibration_summary(y, proba, n_bins=calibration_bins),
150201
**threshold_metrics,
151202
}
152203
if len(np.unique(y)) > 1:
@@ -215,6 +266,7 @@ def render_model_card(
215266
]
216267

217268
if holdout_metrics is not None:
269+
calibration = holdout_metrics.get("calibration")
218270
metric_keys = [
219271
"n",
220272
"threshold",
@@ -244,6 +296,45 @@ def render_model_card(
244296
_markdown_table(["", "predicted 0", "predicted 1"], confusion_rows),
245297
]
246298
)
299+
if isinstance(calibration, dict):
300+
calibration_rows = [
301+
["n_bins", calibration["n_bins"]],
302+
["brier_score", calibration["brier_score"]],
303+
["expected_calibration_error", calibration["expected_calibration_error"]],
304+
["mean_predicted_probability", calibration["mean_predicted_probability"]],
305+
["mean_observed_rate", calibration["mean_observed_rate"]],
306+
]
307+
bin_rows = [
308+
[
309+
f"{row['lower']:.2f}-{row['upper']:.2f}",
310+
row["count"],
311+
row["mean_predicted_probability"],
312+
row["observed_positive_rate"],
313+
row["absolute_error"],
314+
]
315+
for row in calibration.get("bins", [])
316+
]
317+
parts.extend(
318+
[
319+
"",
320+
"## Calibration Summary",
321+
"",
322+
_markdown_table(["metric", "value"], calibration_rows),
323+
"",
324+
"## Calibration Bins",
325+
"",
326+
_markdown_table(
327+
[
328+
"probability_bin",
329+
"count",
330+
"mean_predicted_probability",
331+
"observed_positive_rate",
332+
"absolute_error",
333+
],
334+
bin_rows,
335+
),
336+
]
337+
)
247338
if sweep_metrics:
248339
sweep_rows = [
249340
[
@@ -282,8 +373,9 @@ def render_model_card(
282373
"",
283374
(
284375
"`POST /predict` accepts `question1` and `question2` arrays of equal length "
285-
"and returns `proba_duplicate` in the same order. `GET /metrics` exposes "
286-
"Prometheus counters and latency histograms."
376+
"and returns `proba_duplicate`, `is_duplicate`, and `decision_threshold` "
377+
"in the same order. `GET /metrics` exposes Prometheus counters and "
378+
"latency histograms."
287379
),
288380
"",
289381
"## Caveats",
@@ -319,7 +411,11 @@ def build_report_payload(
319411
"predict": "POST /predict",
320412
"metrics": "GET /metrics",
321413
"input": {"question1": "list[str]", "question2": "list[str]"},
322-
"output": {"proba_duplicate": "list[float]"},
414+
"output": {
415+
"proba_duplicate": "list[float]",
416+
"is_duplicate": "list[bool]",
417+
"decision_threshold": "float",
418+
},
323419
},
324420
"caveats": [
325421
"Performance depends on the training data distribution and threshold.",
@@ -330,8 +426,11 @@ def build_report_payload(
330426
payload["holdout_evaluation"] = {
331427
k: v
332428
for k, v in holdout_metrics.items()
333-
if k not in {"tn", "fp", "fn", "tp"}
429+
if k not in {"tn", "fp", "fn", "tp", "calibration"}
334430
}
431+
calibration = holdout_metrics.get("calibration")
432+
if calibration is not None:
433+
payload["calibration"] = calibration
335434
payload["confusion_matrix"] = {
336435
"labels": ["not_duplicate", "duplicate"],
337436
"actual_0": {
@@ -370,6 +469,12 @@ def main(argv: list[str] | None = None) -> int:
370469
default="0.3,0.5,0.7",
371470
help="Comma-separated thresholds for the optional holdout sweep",
372471
)
472+
parser.add_argument(
473+
"--calibration-bins",
474+
type=int,
475+
default=10,
476+
help="Number of probability bins for the optional calibration report",
477+
)
373478
parser.add_argument(
374479
"--artifact-label",
375480
default=None,
@@ -390,6 +495,9 @@ def main(argv: list[str] | None = None) -> int:
390495
if not 0.0 < args.threshold < 1.0:
391496
print("--threshold must be between 0 and 1", file=sys.stderr)
392497
return 1
498+
if args.calibration_bins < 1:
499+
print("--calibration-bins must be at least 1", file=sys.stderr)
500+
return 1
393501
try:
394502
thresholds = _parse_thresholds(args.thresholds)
395503
except ValueError as exc:
@@ -410,6 +518,7 @@ def main(argv: list[str] | None = None) -> int:
410518
clf,
411519
eval_df,
412520
threshold=args.threshold,
521+
calibration_bins=args.calibration_bins,
413522
)
414523
sweep_metrics = threshold_sweep(
415524
builder,

0 commit comments

Comments
 (0)