Skip to content

Commit 05aafa6

Browse files
committed
feat(api): expose prediction feature values
1 parent 4fce240 commit 05aafa6

10 files changed

Lines changed: 128 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- `examples/smoke_pairs.csv` and CI coverage for the train-to-report workflow.
1010
- JSON output for `quorabust-report --format json`.
1111
- Precision, recall, F1, and configurable threshold sweeps in model reports.
12+
- `POST /predict?explain=true` feature-value explanations for scored pairs.
1213

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

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ quorabust-serve --host 0.0.0.0 --port 8000
9595
# optional second artifact: export QUORABUST_MODEL_B=models/other.pkl
9696
```
9797

98-
`GET /metrics` exposes Prometheus text; `POST /predict` accepts `{"question1":[...],"question2":[...]}` and optional header `X-Quorabust-Variant: b`. Interactive docs: **`/docs`**. Load testing: [docs/LOAD_TESTING.md](docs/LOAD_TESTING.md). Grafana: [docs/GRAFANA.md](docs/GRAFANA.md).
98+
`GET /metrics` exposes Prometheus text; `POST /predict` accepts `{"question1":[...],"question2":[...]}` and optional header `X-Quorabust-Variant: b`. 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).
9999
`GET /models` returns allowlisted metadata for loaded variants without leaking local
100100
artifact paths or training CSV paths.
101101

docs/NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ The public [Quora Question Pairs](https://www.kaggle.com/c/quora-question-pairs)
1313
- **TF–IDF** uses `stop_words=None` so short or stopword-heavy questions still produce a vocabulary (important for tests and for “what is …” style duplicates).
1414
- **Early stopping** is enabled when you pass `eval_df` to `train_duplicate_classifier`; `early_stopping_rounds` defaults to 20 and can be overridden via `xgb_params`.
1515
- **Artifacts**: use `save_classifier` / `load_classifier` from `quorabust.persist` (or `from quorabust import …`) so the `PairFeatureBuilder` (TF–IDF state) and `XGBClassifier` stay in sync.
16+
- **Explanations**: `POST /predict?explain=true` returns model input feature values
17+
(for example cosine, Jaccard, and length stats). These are inspectable features, not
18+
causal natural-language explanations.
1619

1720
## Training CLI
1821

src/quorabust/embedding_features.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2") -
2525
def fit(self, corpus: list[str] | None = None) -> PairEmbeddingBuilder:
2626
return self
2727

28+
def feature_names(self) -> list[str]:
29+
return ["cos", "l2", "mad", "len_ratio", "len_sum"]
30+
2831
def fit_from_frame(
2932
self,
3033
df: pd.DataFrame,

src/quorabust/explain.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
6+
def feature_names_for_builder(
7+
builder: Any,
8+
feature_schema: list[str] | None = None,
9+
) -> list[str]:
10+
"""Return stable feature names for a loaded builder."""
11+
if feature_schema:
12+
return feature_schema
13+
if hasattr(builder, "feature_names"):
14+
names = builder.feature_names()
15+
if isinstance(names, list) and all(isinstance(x, str) for x in names):
16+
return names
17+
return []
18+
19+
20+
def explain_pair_features(
21+
builder: Any,
22+
q1: list[str],
23+
q2: list[str],
24+
*,
25+
feature_schema: list[str] | None = None,
26+
) -> list[dict[str, float]]:
27+
"""Expose model input feature values for each question pair."""
28+
values = builder.transform_pairs(q1, q2)
29+
names = feature_names_for_builder(builder, feature_schema)
30+
if not names:
31+
names = [f"feature_{i}" for i in range(values.shape[1])]
32+
return [
33+
{name: float(value) for name, value in zip(names, row, strict=False)}
34+
for row in values
35+
]

src/quorabust/features.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ def fit(self, corpus: list[str]) -> PairFeatureBuilder:
4343
self._fitted = True
4444
return self
4545

46+
def feature_names(self) -> list[str]:
47+
return ["cos", "jaccard", "len_ratio", "abs_len_diff", "len_sum"]
48+
4649
def fit_from_frame(
4750
self,
4851
df: pd.DataFrame,

src/quorabust/serve.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from prometheus_client import CollectorRegistry, Counter, Histogram, generate_latest
1414
from pydantic import BaseModel, ConfigDict, Field
1515

16+
from quorabust.explain import explain_pair_features
1617
from quorabust.model import predict_proba_duplicate
1718
from quorabust.persist import load_classifier
1819

@@ -65,6 +66,15 @@ class PredictOut(BaseModel):
6566
{
6667
"proba_duplicate": [0.82, 0.31],
6768
"variant": "a",
69+
"features": [
70+
{
71+
"cos": 0.73,
72+
"jaccard": 0.42,
73+
"len_ratio": 0.8,
74+
"abs_len_diff": 1.0,
75+
"len_sum": 9.0,
76+
}
77+
],
6878
}
6979
]
7080
}
@@ -75,6 +85,13 @@ class PredictOut(BaseModel):
7585
description="P(duplicate) for each pair, same order as the request lists.",
7686
)
7787
variant: str = Field(..., description="Scoring variant (a or b) after A/B fallback rules.")
88+
features: list[dict[str, float]] | None = Field(
89+
default=None,
90+
description=(
91+
"Optional per-pair model input feature values when `explain=true`. "
92+
"These are feature values, not causal explanations."
93+
),
94+
)
7895

7996

8097
_PUBLIC_META_KEYS = {
@@ -193,7 +210,8 @@ def metrics() -> PlainTextResponse:
193210
summary="Predict duplicate probability",
194211
description=(
195212
"Scores one or more question pairs. Optional header "
196-
"`X-Quorabust-Variant: b` selects the B artifact when configured."
213+
"`X-Quorabust-Variant: b` selects the B artifact when configured. "
214+
"Set query parameter `explain=true` to return input feature values."
197215
),
198216
responses={
199217
400: {"description": "question1 and question2 length mismatch"},
@@ -202,6 +220,7 @@ def metrics() -> PlainTextResponse:
202220
)
203221
def predict(
204222
body: PredictBody,
223+
explain: bool = False,
205224
x_quorabust_variant: str | None = Header(
206225
default=None,
207226
alias="X-Quorabust-Variant",
@@ -222,7 +241,16 @@ def predict(
222241
finally:
223242
latency.labels(v).observe(time.perf_counter() - t0)
224243
predictions.labels(v).inc()
225-
return PredictOut(proba_duplicate=[float(x) for x in proba], variant=v)
244+
features = None
245+
if explain:
246+
schema = _meta.get("feature_schema")
247+
features = explain_pair_features(
248+
bld,
249+
body.question1,
250+
body.question2,
251+
feature_schema=schema if isinstance(schema, list) else None,
252+
)
253+
return PredictOut(proba_duplicate=[float(x) for x in proba], variant=v, features=features)
226254

227255
return app
228256

tests/test_explain.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import pandas as pd
2+
3+
from quorabust.explain import explain_pair_features, feature_names_for_builder
4+
from quorabust.features import PairFeatureBuilder
5+
6+
7+
def _builder():
8+
df = pd.DataFrame(
9+
{
10+
"question1": ["how to learn python", "best pizza"],
11+
"question2": ["python learning tips", "pizza places"],
12+
}
13+
)
14+
return PairFeatureBuilder(max_features=50).fit_from_frame(df)
15+
16+
17+
def test_explain_pair_features_returns_named_values():
18+
builder = _builder()
19+
rows = explain_pair_features(
20+
builder,
21+
["how to learn python"],
22+
["python learning tips"],
23+
)
24+
assert set(rows[0]) == {"cos", "jaccard", "len_ratio", "abs_len_diff", "len_sum"}
25+
assert all(isinstance(v, float) for v in rows[0].values())
26+
27+
28+
def test_feature_schema_overrides_builder_names():
29+
builder = _builder()
30+
assert feature_names_for_builder(builder, ["a", "b"]) == ["a", "b"]

tests/test_features.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def test_pair_feature_builder_shape_and_monotonic_similarity():
2626
X = b.transform_pairs(q1, q2)
2727
assert X.shape == (2, 5)
2828
assert X[0, 0] >= X[1, 0]
29+
assert b.feature_names() == ["cos", "jaccard", "len_ratio", "abs_len_diff", "len_sum"]
2930

3031

3132
def test_fit_from_frame():

tests/test_serve.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def test_serve_health_ready_predict(tmp_path):
4545
body = r.json()
4646
assert "proba_duplicate" in body
4747
assert body["variant"] == "a"
48+
assert body["features"] is None
4849
m = client.get("/metrics")
4950
assert m.status_code == 200
5051
assert b"quorabust_predictions_total" in m.content
@@ -84,6 +85,26 @@ def test_models_returns_safe_public_metadata(tmp_path):
8485
assert "id" not in model
8586

8687

88+
def test_predict_can_return_feature_explanations(tmp_path):
89+
p = tmp_path / "m.pkl"
90+
_tiny_pkl(p)
91+
app = create_app(model_path_a=str(p))
92+
with TestClient(app) as client:
93+
r = client.post(
94+
"/predict?explain=true",
95+
json={"question1": ["hello"], "question2": ["hello there"]},
96+
)
97+
assert r.status_code == 200
98+
features = r.json()["features"]
99+
assert features and set(features[0]) == {
100+
"cos",
101+
"jaccard",
102+
"len_ratio",
103+
"abs_len_diff",
104+
"len_sum",
105+
}
106+
107+
87108
def test_models_without_loaded_artifacts_is_unavailable():
88109
app = create_app(model_path_a="/nonexistent/quorabust_missing.pkl")
89110
with TestClient(app) as client:

0 commit comments

Comments
 (0)