Skip to content

Commit 9b2fa21

Browse files
committed
feat(model): persist selected decision threshold
1 parent d389700 commit 9b2fa21

11 files changed

Lines changed: 232 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
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.
1616
- Calibration summary and probability-bin diagnostics in `quorabust-report`.
17+
- Holdout-selected `decision_threshold` metadata from `quorabust-train`.
1718

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

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ quorabust-train --csv data/raw/train.csv --out models/quorabust.pkl
6666
python -m quorabust --csv data/raw/train.csv --out models/quorabust.pkl # equivalent
6767
```
6868

69-
Options: `--max-rows N`, `--eval-fraction 0.1` (default), `--eval-fraction 0` to train on all rows without a holdout, `--seed`, `--feature-backend {tfidf,embedding}`, `--embedding-model …`, `--registry-dir` (JSONL registry), `--metadata-out` (JSON sidecar for reviewing artifact lineage without loading the pickle).
69+
Options: `--max-rows N`, `--eval-fraction 0.1` (default), `--eval-fraction 0` to train on all rows without a holdout, `--seed`, `--feature-backend {tfidf,embedding}`, `--embedding-model …`, `--thresholds`, `--threshold-metric {accuracy,precision,recall,f1}` for holdout-based decision-threshold selection, `--registry-dir` (JSONL registry), `--metadata-out` (JSON sidecar for reviewing artifact lineage without loading the pickle).
7070

7171
### Generate a model card
7272

@@ -103,7 +103,7 @@ quorabust-serve --host 0.0.0.0 --port 8000
103103
# optional second artifact: export QUORABUST_MODEL_B=models/other.pkl
104104
```
105105

106-
`GET /metrics` exposes Prometheus text; `POST /predict` accepts `{"question1":[...],"question2":[...]}` and optional header `X-Quorabust-Variant: b`. Responses include `proba_duplicate`, thresholded `is_duplicate`, and `decision_threshold`. Add `?threshold=0.7` to override the duplicate cutoff for one request, or set artifact metadata `decision_threshold` / `QUORABUST_DECISION_THRESHOLD` for the default. Add `?explain=true` to return per-pair input feature values. Interactive docs: **`/docs`**. Load testing: [docs/LOAD_TESTING.md](docs/LOAD_TESTING.md). Grafana: [docs/GRAFANA.md](docs/GRAFANA.md).
106+
`GET /metrics` exposes Prometheus text; `POST /predict` accepts `{"question1":[...],"question2":[...]}` and optional header `X-Quorabust-Variant: b`. Responses include `proba_duplicate`, thresholded `is_duplicate`, and `decision_threshold`. Add `?threshold=0.7` to override the duplicate cutoff for one request; otherwise serving uses the holdout-selected artifact `decision_threshold` when present, then `QUORABUST_DECISION_THRESHOLD`, then `0.5`. Add `?explain=true` to return per-pair input feature values. Interactive docs: **`/docs`**. Load testing: [docs/LOAD_TESTING.md](docs/LOAD_TESTING.md). Grafana: [docs/GRAFANA.md](docs/GRAFANA.md).
107107
`GET /models` returns allowlisted metadata for loaded variants without leaking local
108108
artifact paths or training CSV paths.
109109

docs/ENTERPRISE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414

1515
## Lineage and artifacts
1616

17-
Training writes `csv_sha256`, `git_revision`, `quorabust_version`, `feature_schema`, `reference_feature_means` (for drift checks), and metric fields into the pickle `meta` dict. Treat `.pkl` files as **trusted** (pickle); load only from controlled storage.
17+
Training writes `csv_sha256`, `git_revision`, `quorabust_version`, `feature_schema`,
18+
`reference_feature_means` (for drift checks), holdout-selected `decision_threshold`
19+
when an eval split exists, and metric fields into the pickle `meta` dict. Treat `.pkl`
20+
files as **trusted** (pickle); load only from controlled storage.
1821

1922
Use `quorabust-train --metadata-out models/quorabust.meta.json` to write the same
2023
lineage and metric metadata as JSON. Reviewers and release tooling can inspect that
@@ -26,8 +29,9 @@ prefer a non-pickle format such as `skops` or ONNX in a future release.
2629

2730
- **`quorabust-serve`**: FastAPI with `/health`, `/ready`, `/predict`, `/metrics` (Prometheus). Configure **`QUORABUST_MODEL_PATH`** and optional **`QUORABUST_MODEL_B`** for A/B; clients may send **`X-Quorabust-Variant: b`**.
2831
- **Decisioning**: `/predict` returns both `proba_duplicate` and thresholded
29-
`is_duplicate`. Clients can pass `?threshold=0.7`; otherwise serving uses artifact
30-
metadata `decision_threshold`, then `QUORABUST_DECISION_THRESHOLD`, then `0.5`.
32+
`is_duplicate`. Clients can pass `?threshold=0.7`; otherwise serving uses the
33+
holdout-selected artifact metadata `decision_threshold`, then
34+
`QUORABUST_DECISION_THRESHOLD`, then `0.5`.
3135
- Wire ingress timeouts and autoscaling to your **latency** SLO using the histogram in `/metrics`. See [LOAD_TESTING.md](LOAD_TESTING.md) for k6 and [GRAFANA.md](GRAFANA.md) for a starter dashboard JSON.
3236

3337
## Scale and NLP

docs/PRODUCT.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ It does this with:
2020
- Reproducible offline training.
2121
- A deployable FastAPI scoring service.
2222
- Thresholded decisions, not only raw probabilities.
23+
- Holdout-selected serving thresholds persisted in artifact metadata.
2324
- Model-card reporting with threshold sweeps.
2425
- Artifact metadata, JSON sidecars, and safe public model metadata.
2526
- Prometheus metrics, A/B model routing, drift helpers, and load-test assets.
@@ -54,7 +55,7 @@ dataset or customer-domain labels before making performance claims.
5455
Decision threshold precedence:
5556

5657
1. Request query parameter: `?threshold=0.7`.
57-
2. Artifact metadata: `decision_threshold`.
58+
2. Holdout-selected artifact metadata: `decision_threshold`.
5859
3. Environment: `QUORABUST_DECISION_THRESHOLD`.
5960
4. Default: `0.5`.
6061

docs/REPORTING.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ quorabust-train \
5757
--out models/quorabust.pkl \
5858
--metadata-out models/quorabust.meta.json \
5959
--eval-fraction 0.1 \
60+
--thresholds 0.2,0.3,0.4,0.5,0.6,0.7,0.8 \
61+
--threshold-metric f1 \
6062
--seed 42
6163

6264
quorabust-report \
@@ -75,6 +77,11 @@ threshold.
7577
Use `--format json` when you want CI or release tooling to compare metrics without
7678
scraping Markdown.
7779

80+
When `quorabust-train` has a holdout split, it stores a selected `decision_threshold` in
81+
artifact metadata. The threshold is chosen from `--thresholds` by maximizing
82+
`--threshold-metric` (default `f1`). Serving uses that artifact threshold unless the
83+
request overrides it with `?threshold=...`.
84+
7885
## Sample model card
7986

8087
A checked-in example produced from the [`examples/smoke_pairs.csv`](../examples/smoke_pairs.csv)

src/quorabust/cli.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010

1111
from quorabust.drift import feature_means_from_matrix
1212
from quorabust.lineage import git_revision, sha256_file
13-
from quorabust.model import eval_classification_metrics, train_duplicate_classifier
13+
from quorabust.model import (
14+
eval_classification_metrics,
15+
predict_proba_duplicate,
16+
select_decision_threshold,
17+
train_duplicate_classifier,
18+
)
1419
from quorabust.persist import save_classifier, save_metadata_sidecar
1520
from quorabust.registry import append_model_record
1621

@@ -32,6 +37,24 @@ def _load_quora_csv(path: Path) -> pd.DataFrame:
3237
return df
3338

3439

40+
def _parse_thresholds(raw: str) -> list[float]:
41+
thresholds: list[float] = []
42+
for part in raw.split(","):
43+
value = part.strip()
44+
if not value:
45+
continue
46+
try:
47+
threshold = float(value)
48+
except ValueError as exc:
49+
raise ValueError(f"Invalid threshold: {value}") from exc
50+
if not 0.0 < threshold < 1.0:
51+
raise ValueError("thresholds must be between 0 and 1")
52+
thresholds.append(threshold)
53+
if not thresholds:
54+
raise ValueError("at least one threshold is required")
55+
return thresholds
56+
57+
3558
def main(argv: list[str] | None = None) -> int:
3659
p = argparse.ArgumentParser(
3760
description="Train Quorabust duplicate classifier from a Quora-style CSV.",
@@ -79,11 +102,27 @@ def main(argv: list[str] | None = None) -> int:
79102
default=None,
80103
help="If set, write artifact metadata JSON here without requiring pickle loading",
81104
)
105+
p.add_argument(
106+
"--thresholds",
107+
default="0.2,0.3,0.4,0.5,0.6,0.7,0.8",
108+
help="Comma-separated candidate thresholds for holdout-based decision selection",
109+
)
110+
p.add_argument(
111+
"--threshold-metric",
112+
choices=["accuracy", "precision", "recall", "f1"],
113+
default="f1",
114+
help="Metric to optimize when selecting a decision threshold from the holdout",
115+
)
82116
args = p.parse_args(argv)
83117

84118
if not args.csv.is_file():
85119
print(f"File not found: {args.csv}", file=sys.stderr)
86120
return 1
121+
try:
122+
threshold_candidates = _parse_thresholds(args.thresholds)
123+
except ValueError as e:
124+
print(e, file=sys.stderr)
125+
return 1
87126

88127
try:
89128
df = _load_quora_csv(args.csv)
@@ -135,6 +174,26 @@ def main(argv: list[str] | None = None) -> int:
135174
m = eval_classification_metrics(builder, clf, eval_target)
136175
for k, v in m.items():
137176
meta[f"eval_{k}"] = v
177+
if eval_df is not None:
178+
y_eval = eval_df["is_duplicate"].astype(int).to_numpy()
179+
p_eval = predict_proba_duplicate(
180+
builder,
181+
clf,
182+
eval_df["question1"].astype(str).tolist(),
183+
eval_df["question2"].astype(str).tolist(),
184+
)[:, 1]
185+
selected_threshold = select_decision_threshold(
186+
y_eval,
187+
p_eval,
188+
thresholds=threshold_candidates,
189+
optimize_for=args.threshold_metric,
190+
)
191+
meta["decision_threshold"] = selected_threshold["threshold"]
192+
meta["decision_threshold_source"] = "eval_holdout"
193+
meta["decision_threshold_metric"] = args.threshold_metric
194+
meta["decision_threshold_metrics"] = {
195+
k: v for k, v in selected_threshold.items() if k != "threshold"
196+
}
138197
print(
139198
"metrics: "
140199
+ ", ".join(f"{k}={v:.4f}" for k, v in sorted(m.items())),
@@ -155,6 +214,8 @@ def main(argv: list[str] | None = None) -> int:
155214
"feature_backend": args.feature_backend,
156215
"git_revision": meta.get("git_revision"),
157216
"quorabust_version": meta.get("quorabust_version"),
217+
"decision_threshold": meta.get("decision_threshold"),
218+
"decision_threshold_metric": meta.get("decision_threshold_metric"),
158219
"eval_metrics": {k: meta[k] for k in meta if k.startswith("eval_")},
159220
},
160221
)

src/quorabust/model.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44

55
import numpy as np
66
import pandas as pd
7-
from sklearn.metrics import accuracy_score, log_loss, roc_auc_score
7+
from sklearn.metrics import (
8+
accuracy_score,
9+
f1_score,
10+
log_loss,
11+
precision_score,
12+
recall_score,
13+
roc_auc_score,
14+
)
815
from xgboost import XGBClassifier
916

1017
from quorabust.features import PairFeatureBuilder
@@ -89,7 +96,7 @@ def eval_log_loss(
8996
X = builder.transform_frame(df, col_q1=col_q1, col_q2=col_q2)
9097
y = df[label_col].astype(int).to_numpy()
9198
proba = clf.predict_proba(X)[:, 1]
92-
return float(log_loss(y, proba))
99+
return float(log_loss(y, proba, labels=[0, 1]))
93100

94101

95102
def eval_classification_metrics(
@@ -107,8 +114,52 @@ def eval_classification_metrics(
107114
y_hat = (proba >= 0.5).astype(int)
108115
out: dict[str, float] = {
109116
"accuracy": float(accuracy_score(y, y_hat)),
110-
"log_loss": float(log_loss(y, proba)),
117+
"log_loss": float(log_loss(y, proba, labels=[0, 1])),
111118
}
112119
if len(np.unique(y)) > 1:
113120
out["roc_auc"] = float(roc_auc_score(y, proba))
114121
return out
122+
123+
124+
def select_decision_threshold(
125+
y: np.ndarray,
126+
proba: np.ndarray,
127+
*,
128+
thresholds: list[float] | None = None,
129+
optimize_for: str = "f1",
130+
) -> dict[str, float]:
131+
"""Choose a probability threshold from labeled probabilities."""
132+
candidates = thresholds or [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]
133+
if optimize_for not in {"accuracy", "precision", "recall", "f1"}:
134+
raise ValueError("optimize_for must be one of: accuracy, precision, recall, f1")
135+
if not candidates:
136+
raise ValueError("at least one threshold is required")
137+
138+
best: dict[str, float] | None = None
139+
for threshold in candidates:
140+
if not 0.0 < threshold < 1.0:
141+
raise ValueError("thresholds must be between 0 and 1")
142+
pred = (proba >= threshold).astype(int)
143+
row = {
144+
"threshold": float(threshold),
145+
"accuracy": float(accuracy_score(y, pred)),
146+
"precision": float(precision_score(y, pred, zero_division=0)),
147+
"recall": float(recall_score(y, pred, zero_division=0)),
148+
"f1": float(f1_score(y, pred, zero_division=0)),
149+
}
150+
if best is None:
151+
best = row
152+
continue
153+
current_key = (row[optimize_for], row["f1"], row["accuracy"], -abs(row["threshold"] - 0.5))
154+
best_key = (
155+
best[optimize_for],
156+
best["f1"],
157+
best["accuracy"],
158+
-abs(best["threshold"] - 0.5),
159+
)
160+
if current_key > best_key:
161+
best = row
162+
163+
if best is None:
164+
raise ValueError("at least one threshold is required")
165+
return best

src/quorabust/serve.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ class PredictOut(BaseModel):
141141
"git_revision",
142142
"csv_sha256",
143143
"reference_feature_means",
144+
"decision_threshold",
145+
"decision_threshold_source",
146+
"decision_threshold_metric",
147+
"decision_threshold_metrics",
144148
}
145149

146150

tests/test_cli.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,55 @@ def test_cli_writes_metadata_sidecar(tmp_path):
5252
assert "eval_accuracy" in payload
5353

5454

55+
def test_cli_persists_holdout_decision_threshold(tmp_path):
56+
csv = tmp_path / "train.csv"
57+
_write_synthetic_csv(csv, n=80)
58+
out = tmp_path / "model.pkl"
59+
meta = tmp_path / "model.meta.json"
60+
61+
assert (
62+
main(
63+
[
64+
"--csv",
65+
str(csv),
66+
"--out",
67+
str(out),
68+
"--metadata-out",
69+
str(meta),
70+
"--thresholds",
71+
"0.3,0.5,0.7",
72+
"--threshold-metric",
73+
"f1",
74+
]
75+
)
76+
== 0
77+
)
78+
79+
payload = json.loads(meta.read_text(encoding="utf-8"))
80+
assert payload["decision_threshold"] in {0.3, 0.5, 0.7}
81+
assert payload["decision_threshold_source"] == "eval_holdout"
82+
assert payload["decision_threshold_metric"] == "f1"
83+
assert "f1" in payload["decision_threshold_metrics"]
84+
85+
86+
def test_cli_rejects_bad_threshold_grid(tmp_path):
87+
csv = tmp_path / "train.csv"
88+
_write_synthetic_csv(csv)
89+
assert (
90+
main(
91+
[
92+
"--csv",
93+
str(csv),
94+
"--out",
95+
str(tmp_path / "model.pkl"),
96+
"--thresholds",
97+
"0.2,nope",
98+
]
99+
)
100+
== 1
101+
)
102+
103+
55104
def test_cli_rejects_bad_columns(tmp_path):
56105
csv = tmp_path / "bad.csv"
57106
pd.DataFrame({"a": [1]}).to_csv(csv, index=False)

tests/test_metrics.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import pandas as pd
22

3-
from quorabust.model import eval_classification_metrics, train_duplicate_classifier
3+
from quorabust.model import (
4+
eval_classification_metrics,
5+
select_decision_threshold,
6+
train_duplicate_classifier,
7+
)
48

59

610
def test_eval_classification_metrics_has_auc():
@@ -15,3 +19,31 @@ def test_eval_classification_metrics_has_auc():
1519
m = eval_classification_metrics(b, clf, df)
1620
assert "accuracy" in m and "log_loss" in m and "roc_auc" in m
1721
assert 0.0 <= m["roc_auc"] <= 1.0
22+
23+
24+
def test_eval_classification_metrics_handles_one_class_eval():
25+
df = pd.DataFrame(
26+
{
27+
"question1": [f"q1 {i}" for i in range(20)],
28+
"question2": [f"q2 {i % 4}" for i in range(20)],
29+
"is_duplicate": [i % 2 for i in range(20)],
30+
}
31+
)
32+
b, clf = train_duplicate_classifier(df, xgb_params={"n_estimators": 20, "max_depth": 3})
33+
one_class = df[df["is_duplicate"] == 1].head(5).copy()
34+
m = eval_classification_metrics(b, clf, one_class)
35+
assert "accuracy" in m and "log_loss" in m
36+
assert "roc_auc" not in m
37+
38+
39+
def test_select_decision_threshold_optimizes_requested_metric():
40+
y = pd.Series([0, 0, 1, 1]).to_numpy()
41+
proba = pd.Series([0.1, 0.4, 0.6, 0.9]).to_numpy()
42+
selected = select_decision_threshold(
43+
y,
44+
proba,
45+
thresholds=[0.3, 0.5, 0.7],
46+
optimize_for="f1",
47+
)
48+
assert selected["threshold"] == 0.5
49+
assert selected["f1"] == 1.0

0 commit comments

Comments
 (0)