#!/usr/bin/env python3 """Fit the trainable logistic-regression head for local_encoder (one-shot, offline). Spec item D of ``plans/local-encoder-backbone-and-accuracy-measurement.md``: nearest-centroid IS a linear classifier whose weights are hand-written description strings; this script fits those weights instead, on the same frozen encoder embeddings, and Platt-calibrates the result so the reported confidence is P(correct) — spec item C falls out for free. No raw task text is ever captured (``docs/operations.md``): the training corpus is SYNTHETIC, generated from the committed ``evals/tasks.yaml`` task set in agent-traffic shape — every prompt wrapped in the same structural agent-session noise ``src/local_encoder.py::_isolate_task_text`` strips, at the three noise levels the committed eval harness uses. The corpus and the fitted coefficients are both committed, diffable artifacts: evals/synthetic/encoder-training.jsonl one row per (task, noise) evals/synthetic/encoder-head-coefficients.json coef/intercept + Platt a/b What it does, in order: 1. Generate the corpus: ALL 57 tasks (including ``tool_use_agentic``, labeled from the task's own ``category`` field) x 3 noise variants (clean / short / long) via ``eval_classifier._wrap_agent_noise``. 2. Embed every row through local_encoder's FROZEN pipeline (``local_encoder._embed_task`` — isolate, fit to the token window, per-model query prefix, one forward pass, snapshot-configured pool, L2-normalize), so the head is trained on exactly what serving feeds it. 3. Fit ``LogisticRegression(solver="lbfgs")`` — convex, so a seeded run is reproducible — and Platt-calibrate with ``CalibratedClassifierCV(method="sigmoid", ensemble=False)``. 4. Verify the serialized artifact reproduces sklearn's own ``predict_proba`` in pure Python before writing it. 5. Write the coefficients JSON and report top-1 accuracy on the 46-task scoreable eval set, nearest-centroid vs the fitted head. sklearn is a TRAINING-time dependency only (``requirements-encoder.txt``); the fitted artifact is served by ``local_encoder._TrainableHead`` in pure Python, so the router needs no sklearn at runtime. PYTHONPATH=src python scripts/train_encoder_head.py PYTHONPATH=src python scripts/train_encoder_head.py --device cuda """ from __future__ import annotations import argparse import json import math import sys from collections import Counter from pathlib import Path from typing import Any, Optional _PROJECT_ROOT = Path(__file__).resolve().parent.parent _SRC = str(_PROJECT_ROOT / "src") if _SRC not in sys.path: sys.path.insert(0, _SRC) import yaml from config import load_config TASKS_PATH = _PROJECT_ROOT / "evals" / "tasks.yaml" CORPUS_PATH = _PROJECT_ROOT / "evals" / "synthetic" / "encoder-training.jsonl" COEFFICIENTS_PATH = ( _PROJECT_ROOT / "evals" / "synthetic" / "encoder-head-coefficients.json" ) # The committed artifact is trained for the documented default backbone. # config.local.yaml overlays are deliberately IGNORED here: the committed # coefficients must not depend on which machine happens to run the script. DEFAULT_MODEL_ID = "BAAI/bge-large-en-v1.5" DEFAULT_DEVICE = "cpu" # Coefficient rounding: embeddings are float32 (~7 significant digits), so # 8 decimals on coef/intercept keeps the serialized artifact well under the # embedding's own precision while staying small and diffable. _COEF_DECIMALS = 8 # Platt calibrators are two scalars per class; full precision is free. _CALIB_DECIMALS = 8 # Tolerance for the serialized-artifact replication check: the rounded # coef/intercept move each decision value by at most ~1e-6 on unit-norm # embeddings, so the reconstructed probabilities must match sklearn's with # an order of magnitude of headroom beyond that. _REPLICATION_TOLERANCE = 1e-4 # --- corpus generation ---------------------------------------------------- def build_corpus() -> list[dict]: """One row per (task, noise_level): text, category, noise_level. All 57 tasks, including tool_use_agentic: the eval set excludes tool-use because classifier.candidate_categories deliberately cannot emit that label, but the head's class set is not bound by that policy — the serving-side wiring (``classify_zero_shot``) restricts the head's probabilities to the caller's candidate set at request time, so extra classes are inert until a candidate set asks for them. """ from eval_classifier import NOISE_LEVELS, _wrap_agent_noise tasks = (yaml.safe_load(TASKS_PATH.read_text()) or {}).get("tasks") or [] if not tasks: raise SystemExit(f"no tasks found in {TASKS_PATH}") rows: list[dict] = [] for task in tasks: for level in NOISE_LEVELS: rows.append({ "text": _wrap_agent_noise(task["prompt"], level), "category": task["category"], "noise_level": level, }) return rows def write_corpus(rows: list[dict]) -> None: CORPUS_PATH.parent.mkdir(parents=True, exist_ok=True) with CORPUS_PATH.open("w") as f: for row in rows: f.write(json.dumps(row, ensure_ascii=False) + "\n") counts: Counter = Counter(r["category"] for r in rows) print(f"wrote {len(rows)} rows -> {CORPUS_PATH}") for cat in sorted(counts): print(f" {cat:20s} {counts[cat]} rows") # --- embedding extraction (the frozen pipeline) ---------------------------- def extract_embeddings( rows: list[dict], model_id: str, device: str, ) -> tuple[list[list[float]], list[str]]: """X (one L2-normalized embedding per row) and y, via local_encoder. Deliberately batch-of-1: that is exactly the shape the serving path embeds (``classify_zero_shot`` -> ``_embed_task``), so training and serving share identical preprocessing — the whole point of fitting the head on frozen embeddings. """ import local_encoder model, tokenizer = local_encoder._load_model_tokenizer(model_id, device) _, tensor_device = local_encoder._resolve_device(device) X: list[list[float]] = [] y: list[str] = [] for i, row in enumerate(rows): emb = local_encoder._embed_task( row["text"], model, tokenizer, model_id, tensor_device, ) X.append(emb[0].tolist()) y.append(row["category"]) if (i + 1) % 25 == 0 or i + 1 == len(rows): print(f" embedded {i + 1}/{len(rows)}") return X, y # --- fit + calibrate ------------------------------------------------------- def fit_calibrated_head( X: list[list[float]], y: list[str], model_id: str, cv: int, ) -> tuple[dict, Any]: """Fit lbfgs logistic regression, Platt-calibrate it, return (payload, model). ``ensemble=False`` matters for the artifact: it fits the per-class sigmoid calibrators on out-of-fold predictions, then refits ONE base estimator on all the data — so the serialized artifact is a single coef/intercept matrix plus one (a, b) pair per class, exactly what ``_TrainableHead`` serves. (The default ``ensemble=True`` would serialize one model per fold.) """ from sklearn.calibration import CalibratedClassifierCV from sklearn.linear_model import LogisticRegression # lbfgs on the multinomial objective is sklearn's default for multiclass # (the explicit multi_class= parameter was deprecated in 1.5 and removed # in 1.8 — passing it here would raise on the installed version). base = LogisticRegression(solver="lbfgs", max_iter=2000) calibrated = CalibratedClassifierCV( base, method="sigmoid", cv=cv, ensemble=False, ) calibrated.fit(X, y) fitted = calibrated.calibrated_classifiers_[0] estimator = fitted.estimator payload = { "model_id": model_id, "embedding_dim": len(X[0]), "classes": [str(c) for c in estimator.classes_], "coef": [ [round(float(v), _COEF_DECIMALS) for v in row] for row in estimator.coef_ ], "intercept": [ round(float(b), _COEF_DECIMALS) for b in estimator.intercept_ ], "calibration": "platt", "calibration_a": [ round(float(c.a_), _CALIB_DECIMALS) for c in fitted.calibrators ], "calibration_b": [ round(float(c.b_), _CALIB_DECIMALS) for c in fitted.calibrators ], } return payload, calibrated def _platt_sigmoid(a: float, b: float, decision: float) -> float: """expit(-(a*decision + b)) — the exact _SigmoidCalibration.predict form.""" z = a * decision + b if z > 700.0: return 0.0 if z < -700.0: return 1.0 return 1.0 / (1.0 + math.exp(z)) def verify_serialized_payload( payload: dict, calibrated: Any, X: list[list[float]], ) -> float: """Max probability drift between the serialized artifact and sklearn. Recomputes predict_proba for the training rows from the ROUNDED payload in pure Python (the exact arithmetic ``_TrainableHead`` runs at serving time) and compares against sklearn's own calibrated predict_proba. A drift beyond the tolerance means the artifact would not faithfully reproduce the model it claims to serialize — refuse to write it. """ sklearn_proba = calibrated.predict_proba(X) classes: list[str] = payload["classes"] worst = 0.0 for i, row in enumerate(X): # sklearn's calibrated predict_proba columns follow estimator.classes_, # which is exactly the payload's classes order. reconstructed: list[float] = [] for k, _cat in enumerate(classes): decision = payload["intercept"][k] coef_row = payload["coef"][k] for j, xv in enumerate(row): decision += coef_row[j] * xv reconstructed.append(_platt_sigmoid( payload["calibration_a"][k], payload["calibration_b"][k], decision, )) total = sum(reconstructed) if total <= 0.0: reconstructed = [1.0 / len(classes)] * len(classes) else: reconstructed = [d / total for d in reconstructed] for k in range(len(classes)): worst = max(worst, abs(reconstructed[k] - sklearn_proba[i][k])) return worst # --- before/after accuracy on the scoreable eval set ----------------------- def _accuracy(rows: list) -> Optional[float]: if not rows: return None return sum(1 for _tid, gold, pred, _c in rows if gold == pred) / len(rows) def _print_accuracy(title: str, results: dict, noise_levels: tuple) -> None: print(f"\n## {title}") print(f"{'noise':8} {'correct':>8} {'total':>7} {'accuracy':>10}") for level in noise_levels: rows = results["gold_history"][level] acc = _accuracy(rows) if acc is None: continue correct = sum(1 for _t, g, p, _c in rows if g == p) print(f"{level:8} {correct:>8} {len(rows):>7} {acc:10.3f}") tv_rows = [ (_id, gold, pred, conf) for _id, (gold, pred, conf) in sorted(results["task_verdicts"].items()) ] acc = _accuracy(tv_rows) if acc is not None: correct = sum(1 for _t, g, p, _c in tv_rows if g == p) print(f"{'overall':8} {correct:>8} {len(tv_rows):>7} {acc:10.3f}") def run_accuracy( title: str, tasks: list[dict], categories: list[str], noise_levels: tuple, *, model_id: str, device: str, head: Optional[Any], ) -> None: import local_encoder from eval_classifier import run_eval # The head switch is the module global: None reproduces the pre-head # classifier exactly; an installed head takes the fitted path. local_encoder._TRAINABLE_HEAD = head local_encoder._TRAINABLE_HEAD_RESOLVED = True results = run_eval( tasks, categories, model_id=model_id, device=device, noise_levels=noise_levels, ) _print_accuracy(title, results, noise_levels) # --- CLI ------------------------------------------------------------------- def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--model", help=f"encoder model id (default {DEFAULT_MODEL_ID})") ap.add_argument("--device", help=f"encoder device (default {DEFAULT_DEVICE})") ap.add_argument( "--cv", type=int, default=3, help="folds for CalibratedClassifierCV (default 3; 3 or 5 are sane)", ) ap.add_argument( "--skip-eval", action="store_true", help="skip the before/after accuracy report (still trains + writes)", ) args = ap.parse_args() cfg = load_config( str(_PROJECT_ROOT / "config" / "config.yaml"), include_overlay=False, ) enc = cfg.classifier.encoder model_id = args.model or (enc.model if enc is not None else DEFAULT_MODEL_ID) device = args.device or (enc.device if enc is not None else DEFAULT_DEVICE) print(f"model={model_id} device={device} cv={args.cv}") rows = build_corpus() write_corpus(rows) print(f"\nembedding {len(rows)} rows through the frozen pipeline...") X, y = extract_embeddings(rows, model_id, device) payload, calibrated = fit_calibrated_head(X, y, model_id, cv=args.cv) # sklearn's honest generalization estimate for the fit, before any # artifact talk: 5-fold stratified in-corpus CV of the uncalibrated LR. from sklearn.linear_model import LogisticRegression from sklearn.model_selection import StratifiedKFold, cross_val_score scores = cross_val_score( LogisticRegression(solver="lbfgs", max_iter=2000), X, y, cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=0), ) print( f"in-corpus 5-fold CV accuracy (uncalibrated LR): " f"{scores.mean():.3f} +/- {scores.std():.3f}" ) worst = verify_serialized_payload(payload, calibrated, X) if worst > _REPLICATION_TOLERANCE: print( f"serialized artifact drifts {worst:.3e} from sklearn predict_proba " f"(tolerance {_REPLICATION_TOLERANCE:.1e}) — refusing to write", file=sys.stderr, ) return 1 print(f"serialized replication check: max prob drift {worst:.2e} (ok)") COEFFICIENTS_PATH.parent.mkdir(parents=True, exist_ok=True) COEFFICIENTS_PATH.write_text(json.dumps(payload, indent=1) + "\n") print( f"wrote {COEFFICIENTS_PATH} " f"({len(payload['classes'])} classes x {payload['embedding_dim']} dims)" ) if args.skip_eval: return 0 from eval_classifier import NOISE_LEVELS, load_scoreable_tasks tasks = load_scoreable_tasks(str(TASKS_PATH)) categories = list(cfg.classifier_candidate_categories) unknown = {t["category"] for t in tasks} - set(categories) if unknown: print( f"tasks reference categories missing from the candidate set: " f"{sorted(unknown)}", file=sys.stderr, ) return 1 import local_encoder head = local_encoder._TrainableHead(payload, str(COEFFICIENTS_PATH)) run_accuracy( "Nearest-centroid (before trainable head)", tasks, categories, NOISE_LEVELS, model_id=model_id, device=device, head=None, ) run_accuracy( "Fitted head (after, Platt-calibrated)", tasks, categories, NOISE_LEVELS, model_id=model_id, device=device, head=head, ) return 0 if __name__ == "__main__": raise SystemExit(main())