alto-llm-corrector / scripts /qe_benchmark.py
Marcel Bautista-Kuljevan
qe: measurement harness — token AUC + ECE/Brier vs heuristic (Phase 3)
6deb507
Raw
History Blame Contribute Delete
10.3 kB
#!/usr/bin/env python3
"""QE scorer bake-off (ROADMAP V3 Phase 3): D'AlemBERT vs the heuristic.
Answers the Phase-3 exit question with numbers a PR can cite: does the
zero-shot D'AlemBERT masked-LM scorer (``corrigenda[qe]``) beat the
zero-dependency :class:`HeuristicQEScorer` at telling a line/token that
still needs correction from one that is already clean?
It scores the token-labeled data from ``scripts/qe_data.py`` (the same
real raw↔ref alignment and synthetic degradations) and reports, per
source and pooled:
* **token AUC** — ranking power, invariant to any monotonic calibration
(this is the headline "does the signal separate errors from clean");
* **line AUC** — same, at line granularity (any-error label);
* **ECE / Brier** — calibration of the probability, using the SAME
``_calibration_metrics`` bins as ``scripts/benchmark.py``.
``--fit`` fits the 2-parameter Platt scaling (surprisal midpoint/scale)
on the SYNTHETIC split by 1-D logistic regression and prints the
constants to bake into ``integrations/qe.py`` — the LM stays zero-shot;
only its surprisal is rescaled onto [0, 1]. Real gold is never fit on,
so it stays an honest test set.
python scripts/qe_benchmark.py --fit # print Platt constants
python scripts/qe_benchmark.py # full comparison table
Dev tooling: needs the ``corrigenda[qe]`` deps and the exported ONNX
bundle (``scripts/export_dalembert_onnx.py``). Never imported by the core.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_REPO_ROOT / "packages" / "corrigenda" / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import qe_data # noqa: E402 (sibling script: record generation)
from corrigenda.core.quality import HeuristicQEScorer # noqa: E402
from corrigenda.integrations.qe import ( # noqa: E402
DEFAULT_MODEL_DIR,
MaskedLMQEScorer,
_sigmoid,
)
DEFAULT_CORPUS = (
_REPO_ROOT / "packages" / "corrigenda" / "tests" / "corpus_gt" / "manifest.json"
)
# ---------------------------------------------------------------------------
# Metrics (dependency-light; numpy only for the Platt fit)
# ---------------------------------------------------------------------------
def auc(scores: list[float], labels: list[int]) -> float:
"""Tie-aware ROC AUC via average ranks (Mann–Whitney U)."""
data = sorted(zip(scores, labels, strict=True), key=lambda p: p[0])
ranks = [0.0] * len(data)
i = 0
while i < len(data):
j = i
while j < len(data) and data[j][0] == data[i][0]:
j += 1
avg = (i + j - 1) / 2 + 1 # 1-based average rank over the tie block
for k in range(i, j):
ranks[k] = avg
i = j
n_pos = sum(1 for _, lbl in zip(scores, labels, strict=True) if lbl == 1)
n_neg = len(labels) - n_pos
if n_pos == 0 or n_neg == 0:
return float("nan")
sum_pos = sum(r for r, (_, lbl) in zip(ranks, data, strict=True) if lbl == 1)
return (sum_pos - n_pos * (n_pos + 1) / 2) / (n_pos * n_neg)
def calibration(pairs: list[tuple[float, float]], bins: int = 10) -> dict[str, float]:
"""Brier + expected calibration error — same recipe as benchmark.py."""
if not pairs:
return {"brier": float("nan"), "ece": float("nan")}
brier = sum((p - c) ** 2 for p, c in pairs) / len(pairs)
ece = 0.0
for b in range(bins):
lo, hi = b / bins, (b + 1) / bins
in_bin = [
(p, c) for p, c in pairs if (lo <= p < hi) or (b == bins - 1 and p == hi)
]
if not in_bin:
continue
conf = sum(p for p, _ in in_bin) / len(in_bin)
acc = sum(c for _, c in in_bin) / len(in_bin)
ece += abs(conf - acc) * len(in_bin) / len(pairs)
return {"brier": round(brier, 4), "ece": round(ece, 4)}
def fit_platt(surprisals: list[float], labels: list[int]) -> tuple[float, float]:
"""1-D logistic regression of label ~ surprisal via Newton/IRLS.
Returns ``(midpoint, scale)`` such that
``P = sigmoid((surprisal - midpoint) / scale)``."""
import numpy as np
x = np.asarray(surprisals, dtype=np.float64)
y = np.asarray(labels, dtype=np.float64)
X = np.column_stack([x, np.ones_like(x)]) # [surprisal, 1]
beta = np.zeros(2)
for _ in range(100):
p = 1.0 / (1.0 + np.exp(-(X @ beta)))
w = np.clip(p * (1.0 - p), 1e-6, None)
grad = X.T @ (p - y)
hess = (X * w[:, None]).T @ X + 1e-6 * np.eye(2)
step = np.linalg.solve(hess, grad)
beta -= step
if float(np.abs(step).max()) < 1e-9:
break
slope, intercept = float(beta[0]), float(beta[1])
if slope <= 0: # degenerate; fall back to a gentle default
return 3.0, 1.0
return -intercept / slope, 1.0 / slope
# ---------------------------------------------------------------------------
# Data assembly — token- and line-level (score, label) for both scorers
# ---------------------------------------------------------------------------
def _alnum(token: str) -> bool:
return any(c.isalnum() for c in token)
def collect(records: list[dict], scorer: MaskedLMQEScorer, heuristic: HeuristicQEScorer):
"""Per token: (dalembert surprisal, heuristic flag, label). Per line:
(dalembert line score, heuristic line score, any-error label)."""
tok_surprisal: list[float] = []
tok_heur: list[float] = []
tok_label: list[int] = []
line_dalembert: list[float] = []
line_heur: list[float] = []
line_label: list[int] = []
misaligned = 0
for rec in records:
pairs = [
(t, lbl)
for t, lbl in zip(rec["tokens"], rec["labels"], strict=True)
if _alnum(t)
]
if not pairs:
continue
line = " ".join(rec["tokens"])
surprisals = scorer._word_surprisals(line)
if len(surprisals) != len(pairs):
misaligned += 1
continue
for (tok, lbl), (_, sur) in zip(pairs, surprisals, strict=True):
tok_surprisal.append(sur)
tok_heur.append(1.0 if heuristic._is_suspicious(tok) else 0.0)
tok_label.append(int(lbl))
line_dalembert.append(scorer.needs_correction(line))
line_heur.append(heuristic.needs_correction(line))
line_label.append(1 if any(lbl for _, lbl in pairs) else 0)
return {
"tok_surprisal": tok_surprisal,
"tok_heur": tok_heur,
"tok_label": tok_label,
"line_dalembert": line_dalembert,
"line_heur": line_heur,
"line_label": line_label,
"misaligned": misaligned,
}
def evaluate(data: dict, midpoint: float, scale: float) -> dict:
tok_p = [_sigmoid((s - midpoint) / scale) for s in data["tok_surprisal"]]
return {
"tokens": len(data["tok_label"]),
"token_errors": sum(data["tok_label"]),
"dalembert": {
"token_auc": round(auc(data["tok_surprisal"], data["tok_label"]), 4),
"line_auc": round(auc(data["line_dalembert"], data["line_label"]), 4),
**calibration(list(zip(tok_p, map(float, data["tok_label"]), strict=True))),
**{
"line_"
+ k: v
for k, v in calibration(
list(
zip(
data["line_dalembert"],
map(float, data["line_label"]),
strict=True,
)
)
).items()
},
},
"heuristic": {
"token_auc": round(auc(data["tok_heur"], data["tok_label"]), 4),
"line_auc": round(auc(data["line_heur"], data["line_label"]), 4),
**calibration(
list(zip(data["tok_heur"], map(float, data["tok_label"]), strict=True))
),
**{
"line_"
+ k: v
for k, v in calibration(
list(
zip(
data["line_heur"],
map(float, data["line_label"]),
strict=True,
)
)
).items()
},
},
"misaligned_lines": data["misaligned"],
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS)
parser.add_argument("--model-dir", type=Path, default=DEFAULT_MODEL_DIR)
parser.add_argument("--seed", type=int, default=1637)
parser.add_argument("--rate", type=int, default=35)
parser.add_argument("--reducer", choices=["mean", "max"], default="mean")
parser.add_argument(
"--fit", action="store_true", help="fit + print Platt constants, then exit"
)
parser.add_argument("--out", type=Path, default=None)
args = parser.parse_args(argv)
scorer = MaskedLMQEScorer(model_dir=args.model_dir, word_reducer=args.reducer)
heuristic = HeuristicQEScorer()
real = qe_data.generate(args.corpus, mode="real", seed=args.seed, rate_percent=args.rate)
synth = qe_data.generate(
args.corpus, mode="synthetic", seed=args.seed, rate_percent=args.rate
)
d_real = collect(real, scorer, heuristic)
d_synth = collect(synth, scorer, heuristic)
# Fit Platt on SYNTHETIC only (abundant, deterministic) — real gold
# stays a test set.
midpoint, scale = fit_platt(d_synth["tok_surprisal"], d_synth["tok_label"])
if args.fit:
print(json.dumps({"midpoint": round(midpoint, 4), "scale": round(scale, 4)}))
return 0
report = {
"reducer": args.reducer,
"platt": {"midpoint": round(midpoint, 4), "scale": round(scale, 4)},
"real": evaluate(d_real, midpoint, scale),
"synthetic": evaluate(d_synth, midpoint, scale),
}
payload = json.dumps(report, indent=2, ensure_ascii=False)
if args.out:
args.out.write_text(payload + "\n", encoding="utf-8")
print(payload)
return 0
if __name__ == "__main__":
raise SystemExit(main())