Skip to content

Commit 703064b

Browse files
committed
feat(model): add cross-encoder backend
1 parent 89c2eef commit 703064b

9 files changed

Lines changed: 184 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- Calibration summary and probability-bin diagnostics in `quorabust-report`.
1717
- Holdout-selected `decision_threshold` metadata from `quorabust-train`.
1818
- `quorabust-validate-report` release gate for machine-readable model-card JSON.
19+
- Optional `--feature-backend cross-encoder` for modern transformer pair scoring.
1920

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

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ buyer-facing use cases, method strategy, and production gaps.
1515

1616
## What it demonstrates
1717

18-
- Pairwise text features with TF-IDF or optional sentence-transformer embeddings
18+
- Pairwise text features with TF-IDF, optional sentence-transformer embeddings, or
19+
optional cross-encoder pair scoring
1920
- XGBoost training with optional holdout evaluation and early stopping
2021
- Saved artifacts that include lineage, feature schema, dataset checksum, and metrics
2122
- FastAPI inference with health/readiness checks, thresholded decisions, OpenAPI docs,
@@ -66,7 +67,7 @@ quorabust-train --csv data/raw/train.csv --out models/quorabust.pkl
6667
python -m quorabust --csv data/raw/train.csv --out models/quorabust.pkl # equivalent
6768
```
6869

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).
70+
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,cross-encoder}`, `--embedding-model …`, `--cross-encoder-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).
7071

7172
### Generate a model card
7273

docs/ENTERPRISE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ prefer a non-pickle format such as `skops` or ONNX in a future release.
3636

3737
## Scale and NLP
3838

39-
See [SCALING.md](SCALING.md) for chunked CSV I/O, optional **embedding** training (`pip install ".[nlp]"`, `quorabust-train --feature-backend embedding`), and pointers to distributed XGBoost.
39+
See [SCALING.md](SCALING.md) for chunked CSV I/O, optional **embedding** training
40+
(`pip install ".[nlp]"`, `quorabust-train --feature-backend embedding`), optional
41+
**cross-encoder** pair scoring (`quorabust-train --feature-backend cross-encoder`), and
42+
pointers to distributed XGBoost.
4043

4144
## Registry and drift (lightweight)
4245

docs/PRODUCT.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ The modern path is:
3636
1. **TF-IDF + XGBoost baseline** for speed, explainable feature values, and cheap serving.
3737
2. **Sentence-transformer embeddings + XGBoost** for better semantic recall when wording
3838
differs.
39-
3. **Cross-encoder reranker** for the highest-accuracy pair scoring when latency and
40-
compute budget allow it.
39+
3. **Cross-encoder pair scoring + XGBoost** for the highest-accuracy pair scoring when
40+
latency and compute budget allow it.
4141

4242
Do not claim state-of-the-art quality from the checked-in smoke model. Use a real held-out
4343
dataset or customer-domain labels before making performance claims.

docs/SCALING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ This document maps **ambitions** to what ships in-repo and what stays external.
1010
## Modern NLP (embeddings)
1111

1212
- **Optional extra** `pip install ".[nlp]"`**: `PairEmbeddingBuilder` in `quorabust.embedding_features` uses `sentence-transformers` to encode pairs and feeds cosine / L2 / pooling stats into the same XGBoost head. Training can select `--feature-backend embedding` in `quorabust-train`.
13+
- **Cross-encoder pair scoring**: `PairCrossEncoderBuilder` in
14+
`quorabust.cross_encoder_features` uses a Sentence Transformers `CrossEncoder` to score
15+
each pair directly, then feeds that score plus length stats into the same XGBoost head.
16+
Select it with `--feature-backend cross-encoder`. This is the modern high-accuracy path
17+
for pair scoring, but it is slower than TF-IDF or bi-encoder embeddings because every
18+
pair must be passed through the transformer jointly.
1319

1420
## Online serving, SLOs, monitoring
1521

src/quorabust/cli.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,23 @@ def main(argv: list[str] | None = None) -> int:
8181
p.add_argument("--seed", type=int, default=42)
8282
p.add_argument(
8383
"--feature-backend",
84-
choices=["tfidf", "embedding"],
84+
choices=["tfidf", "embedding", "cross-encoder"],
8585
default="tfidf",
86-
help="tfidf (default) or sentence-transformer embeddings (requires nlp extra)",
86+
help=(
87+
"tfidf (default), sentence-transformer embeddings, or cross-encoder pair scores "
88+
"(requires nlp extra)"
89+
),
8790
)
8891
p.add_argument(
8992
"--embedding-model",
9093
default="sentence-transformers/all-MiniLM-L6-v2",
9194
help="When --feature-backend=embedding, SentenceTransformer model id",
9295
)
96+
p.add_argument(
97+
"--cross-encoder-model",
98+
default="cross-encoder/quora-distilroberta-base",
99+
help="When --feature-backend=cross-encoder, CrossEncoder model id",
100+
)
93101
p.add_argument(
94102
"--registry-dir",
95103
type=Path,
@@ -146,6 +154,10 @@ def main(argv: list[str] | None = None) -> int:
146154
from quorabust.embedding_features import PairEmbeddingBuilder
147155

148156
feature_builder = PairEmbeddingBuilder(model_name=args.embedding_model)
157+
elif args.feature_backend == "cross-encoder":
158+
from quorabust.cross_encoder_features import PairCrossEncoderBuilder
159+
160+
feature_builder = PairCrossEncoderBuilder(model_name=args.cross_encoder_model)
149161

150162
builder, clf = train_duplicate_classifier(
151163
train_df,
@@ -154,10 +166,11 @@ def main(argv: list[str] | None = None) -> int:
154166
feature_builder=feature_builder,
155167
)
156168

157-
if args.feature_backend == "embedding":
158-
feat_names = ["cos", "l2", "mad", "len_ratio", "len_sum"]
159-
else:
160-
feat_names = ["cos", "jaccard", "len_ratio", "abs_len_diff", "len_sum"]
169+
feat_names = (
170+
builder.feature_names()
171+
if hasattr(builder, "feature_names")
172+
else ["feature_0", "feature_1", "feature_2", "feature_3", "feature_4"]
173+
)
161174

162175
meta: dict[str, Any] = {
163176
"n_train": len(train_df),
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from __future__ import annotations
2+
3+
import numpy as np
4+
import pandas as pd
5+
6+
from quorabust.preprocess import clean_text
7+
8+
try:
9+
from sentence_transformers import CrossEncoder
10+
except ImportError:
11+
CrossEncoder = None # type: ignore[misc, assignment]
12+
13+
14+
class PairCrossEncoderBuilder:
15+
"""Cross-encoder pair scores plus simple length features (optional ``nlp`` extra)."""
16+
17+
def __init__(self, model_name: str = "cross-encoder/quora-distilroberta-base") -> None:
18+
if CrossEncoder is None:
19+
raise RuntimeError(
20+
'Missing dependency: install with pip install "Quorabust[nlp]"',
21+
)
22+
self.model_name = model_name
23+
self._model = CrossEncoder(model_name)
24+
self._fitted = True
25+
26+
def fit(self, corpus: list[str] | None = None) -> PairCrossEncoderBuilder:
27+
return self
28+
29+
def feature_names(self) -> list[str]:
30+
return ["cross_score", "len_ratio", "abs_len_diff", "len_sum"]
31+
32+
def fit_from_frame(
33+
self,
34+
df: pd.DataFrame,
35+
col_q1: str = "question1",
36+
col_q2: str = "question2",
37+
) -> PairCrossEncoderBuilder:
38+
return self
39+
40+
def transform_pairs(
41+
self,
42+
q1: list[str],
43+
q2: list[str],
44+
) -> np.ndarray:
45+
if len(q1) != len(q2):
46+
raise ValueError("q1 and q2 must have the same length.")
47+
t1 = [clean_text(x) for x in q1]
48+
t2 = [clean_text(x) for x in q2]
49+
scores = np.asarray(
50+
self._model.predict(list(zip(t1, t2, strict=True)), show_progress_bar=False),
51+
dtype=np.float64,
52+
).reshape(-1)
53+
54+
rows: list[list[float]] = []
55+
for idx, (a, b) in enumerate(zip(t1, t2, strict=True)):
56+
la, lb = len(a.split()), len(b.split())
57+
max_len = max(la, lb, 1)
58+
len_ratio = min(la, lb) / max_len
59+
rows.append([float(scores[idx]), len_ratio, float(abs(la - lb)), float(la + lb)])
60+
return np.asarray(rows, dtype=np.float64)
61+
62+
def transform_frame(
63+
self,
64+
df: pd.DataFrame,
65+
col_q1: str = "question1",
66+
col_q2: str = "question2",
67+
) -> np.ndarray:
68+
return self.transform_pairs(
69+
df[col_q1].astype(str).tolist(),
70+
df[col_q2].astype(str).tolist(),
71+
)

tests/test_cli.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@
22

33
import pandas as pd
44

5+
import quorabust.cross_encoder_features as cef
56
from quorabust.cli import main
67

78

9+
class _FakeCrossEncoder:
10+
def __init__(self, model_name: str) -> None:
11+
self.model_name = model_name
12+
13+
def predict(self, pairs, show_progress_bar: bool = False):
14+
return [0.9 if idx % 2 else 0.1 for idx, _pair in enumerate(pairs)]
15+
16+
817
def _write_synthetic_csv(path, n: int = 30) -> None:
918
df = pd.DataFrame(
1019
{
@@ -101,6 +110,38 @@ def test_cli_rejects_bad_threshold_grid(tmp_path):
101110
)
102111

103112

113+
def test_cli_trains_with_cross_encoder_backend(tmp_path, monkeypatch):
114+
monkeypatch.setattr(cef, "CrossEncoder", _FakeCrossEncoder)
115+
csv = tmp_path / "train.csv"
116+
_write_synthetic_csv(csv)
117+
out = tmp_path / "model.pkl"
118+
meta = tmp_path / "model.meta.json"
119+
120+
assert (
121+
main(
122+
[
123+
"--csv",
124+
str(csv),
125+
"--out",
126+
str(out),
127+
"--feature-backend",
128+
"cross-encoder",
129+
"--cross-encoder-model",
130+
"fake-cross-encoder",
131+
"--metadata-out",
132+
str(meta),
133+
"--eval-fraction",
134+
"0",
135+
]
136+
)
137+
== 0
138+
)
139+
140+
payload = json.loads(meta.read_text(encoding="utf-8"))
141+
assert payload["feature_backend"] == "cross-encoder"
142+
assert payload["feature_schema"] == ["cross_score", "len_ratio", "abs_len_diff", "len_sum"]
143+
144+
104145
def test_cli_rejects_bad_columns(tmp_path):
105146
csv = tmp_path / "bad.csv"
106147
pd.DataFrame({"a": [1]}).to_csv(csv, index=False)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import numpy as np
2+
import pandas as pd
3+
import pytest
4+
5+
import quorabust.cross_encoder_features as cef
6+
7+
8+
class _FakeCrossEncoder:
9+
def __init__(self, model_name: str) -> None:
10+
self.model_name = model_name
11+
12+
def predict(self, pairs, show_progress_bar: bool = False):
13+
return np.asarray([0.9 if a.split()[0:1] == b.split()[0:1] else 0.1 for a, b in pairs])
14+
15+
16+
def test_pair_cross_encoder_builder_requires_sentence_transformers(monkeypatch):
17+
monkeypatch.setattr(cef, "CrossEncoder", None)
18+
with pytest.raises(RuntimeError, match="nlp"):
19+
cef.PairCrossEncoderBuilder()
20+
21+
22+
def test_pair_cross_encoder_builder_shapes(monkeypatch):
23+
monkeypatch.setattr(cef, "CrossEncoder", _FakeCrossEncoder)
24+
builder = cef.PairCrossEncoderBuilder(model_name="fake-cross-encoder")
25+
df = pd.DataFrame(
26+
{
27+
"question1": ["hello world", "foo bar"],
28+
"question2": ["hello there", "baz qux"],
29+
}
30+
)
31+
32+
X = builder.fit_from_frame(df).transform_frame(df)
33+
34+
assert builder.feature_names() == ["cross_score", "len_ratio", "abs_len_diff", "len_sum"]
35+
assert X.shape == (2, 4)
36+
assert X[0, 0] == 0.9
37+
assert X[1, 0] == 0.1

0 commit comments

Comments
 (0)