Skip to content

Commit 4af22af

Browse files
committed
fix(fit): map predict_proba columns to label ids in _predict
_predict() returned the argmax column index as the predicted label. That is only the label id when the surrogate saw every class. The RSB residual stage is trained on the rows stage 1 rejected, which usually lack some classes, so its predictions were shifted to the wrong labels. Calibration then measured the shifted labels and nearly always refused stage 2, losing its coverage. Map the columns through clf.classes_.
1 parent 093792a commit 4af22af

2 files changed

Lines changed: 97 additions & 1 deletion

File tree

‎src/tracer/fit/pipeline.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,11 @@ def _calibrate_threshold(scores, preds, y_teacher, target_ta, alpha=0.1, min_acc
233233

234234
def _predict(clf, X):
235235
probs = clf.predict_proba(X)
236-
preds = probs.argmax(axis=1).astype(int)
236+
idx = probs.argmax(axis=1)
237+
# predict_proba columns follow clf.classes_. A stage trained on a subset of
238+
# the label space (the RSB residual stage) must map them back to label ids.
239+
classes = getattr(clf, "classes_", None)
240+
preds = (np.asarray(classes)[idx] if classes is not None else idx).astype(int)
237241
return preds, probs
238242

239243

‎tests/test_stage_labels.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""A stage's predictions must be teacher label ids, not predict_proba columns.
2+
3+
The RSB second stage is trained only on the rows the first stage rejected, so
4+
its surrogate usually sees a subset of the label space (e.g. the easy class the
5+
first stage fully absorbs is missing). For such a model, column ``i`` of
6+
``predict_proba`` is ``clf.classes_[i]``, not label id ``i``.
7+
"""
8+
import json
9+
import tempfile
10+
from pathlib import Path
11+
12+
import numpy as np
13+
from sklearn.linear_model import LogisticRegression
14+
15+
from tracer.fit.pipeline import _predict, apply_stage
16+
17+
LINEAR_ONLY = ("gbt", "rf", "et", "xgb", "dt", "mlp_1h", "mlp_2h")
18+
19+
20+
def test_predict_maps_proba_columns_to_class_labels():
21+
rng = np.random.RandomState(0)
22+
# Trained on label ids {1, 3} only (0 and 2 absent from this subset).
23+
X = np.vstack([rng.randn(50, 2) - 4, rng.randn(50, 2) + 4])
24+
y = np.r_[np.full(50, 1), np.full(50, 3)]
25+
clf = LogisticRegression().fit(X, y)
26+
27+
preds, probs = _predict(clf, X)
28+
assert probs.shape == (100, 2)
29+
assert set(np.unique(preds)) == {1, 3}
30+
assert (preds == y).mean() > 0.99
31+
32+
stage = {"clf": clf, "accept_all": True}
33+
stage_preds, _, _ = apply_stage(stage, X)
34+
assert set(np.unique(stage_preds)) == {1, 3}
35+
36+
37+
def _residual_traces(tmp, n=4000):
38+
"""Class 0 is trivially separable, so stage 1 accepts every class-0 row.
39+
40+
Classes 1-3 come in two groups. The 'easy' group carries a per-class
41+
marker; the 'hard' group encodes the class along a direction that the easy
42+
group uses the opposite way, which a global linear surrogate cannot
43+
resolve. Those rows are rejected by stage 1 and are exactly what the RSB
44+
residual stage should pick up, with only labels {1, 2, 3} in its pool.
45+
"""
46+
rng = np.random.RandomState(0)
47+
X, y = [], []
48+
for _ in range(n):
49+
c = rng.randint(0, 4)
50+
x = rng.randn(6) * 0.5
51+
if c == 0:
52+
x[0] += 10
53+
elif rng.rand() > 0.3:
54+
x[c] += 4
55+
x[5] -= (c - 2) * 8
56+
x[4] -= 3
57+
else:
58+
x[4] += 3
59+
x[5] += (c - 2) * 2
60+
X.append(x)
61+
y.append(c)
62+
X = np.asarray(X, dtype=np.float32)
63+
names = ["cls_0", "cls_1", "cls_2", "cls_3"]
64+
path = Path(tmp) / "traces.jsonl"
65+
with path.open("w") as f:
66+
for i, c in enumerate(y):
67+
f.write(json.dumps({"input": f"t{i}", "teacher": names[c]}) + "\n")
68+
return path, X, np.asarray([names[c] for c in y])
69+
70+
71+
def test_rsb_residual_stage_routes_with_teacher_labels():
72+
from tracer.api import fit, load_router
73+
from tracer.config import FitConfig
74+
75+
with tempfile.TemporaryDirectory() as tmp:
76+
path, X, teacher = _residual_traces(tmp)
77+
out = Path(tmp) / ".tracer"
78+
cfg = FitConfig(target_teacher_agreement=0.95, frontier_targets=(0.95,),
79+
skip_candidates=LINEAR_ONLY, verbose=False)
80+
result = fit(path, artifact_dir=out, embeddings=X, config=cfg)
81+
82+
# The residual stage is certified, so RSB beats single-stage L2D.
83+
assert result.manifest.selected_method == "rsb"
84+
assert result.manifest.coverage_cal > 0.95
85+
86+
router = load_router(out)
87+
batch = router.predict_batch(X)
88+
stage2 = np.asarray(batch["stage_id"]) == 1
89+
assert stage2.sum() > 100
90+
labels = np.asarray(batch["labels"], dtype=object)[stage2]
91+
# Stage-2 rows carry the class they belong to, not a shifted label.
92+
assert (labels == teacher[stage2]).mean() >= 0.9

0 commit comments

Comments
 (0)