Training loss is a poor product manager. A fine-tune can look "done" because NLL fell, while live tickets get worse and the model forgets how to follow basic instructions. This lesson focuses on eval design for adaptation: task lift, generalization, and regression against the base.
Three failure modes dominate post-training:
Your scorecard needs task metrics (did we fix the bug?) and anchor metrics (did we break the world?).
| Slice | Purpose | Example |
|---|---|---|
| Task holdout | Generalization of the new skill | 100 labeled tickets never in train |
| Hard cases | Stress format, ambiguity, edge policies | Multilingual, sarcasm, partial info |
| Anchors / regressions | Protect general behavior | MMLU-style samples, safety prompts, "hello" sanity |
| Live shadow | Real distribution | Offline replay of last week's queries |
| Contamination check | Detect leakage | n-gram overlap train vs eval |
Signals:
Mitigations: fewer epochs, more diverse data, dropout/weight decay (limited effect on LLMs), early stopping on eval, LoRA with modest rank, paraphrase augmentation.
Signals:
Mitigations: lower LR, fewer steps, mix a replay of general instruct data, prefer PEFT, freeze more layers, multi-task sampling.
A tiny scorecard comparing base vs fine-tune on task and anchor sets.
from dataclasses import dataclass
@dataclass
class Scores:
task_exact: float
schema_ok: float
anchor_pass: float
def decide(base: Scores, ft: Scores, min_task_lift: float = 0.05) -> str:
task_lift = ft.task_exact - base.task_exact
schema_lift = ft.schema_ok - base.schema_ok
anchor_drop = base.anchor_pass - ft.anchor_pass
if task_lift < min_task_lift and schema_lift < min_task_lift:
return "reject: no meaningful task lift"
if anchor_drop > 0.10:
return "reject: catastrophic forgetting on anchors"
if ft.task_exact > 0.95 and task_lift > 0.3:
# suspiciously huge lift — check contamination
return "review: possible overfit / leakage — audit overlap"
return "accept_candidate"
base = Scores(task_exact=0.55, schema_ok=0.60, anchor_pass=0.92)
good = Scores(task_exact=0.78, schema_ok=0.90, anchor_pass=0.90)
overfitty = Scores(task_exact=0.99, schema_ok=0.99, anchor_pass=0.91)
forgot = Scores(task_exact=0.80, schema_ok=0.88, anchor_pass=0.70)
for name, s in [("good", good), ("overfitty", overfitty), ("forgot", forgot)]:
print(name, decide(base, s))
Overlap check sketch:
def jaccard(a: str, b: str) -> float:
sa, sb = set(a.lower().split()), set(b.lower().split())
return len(sa & sb) / max(1, len(sa | sb))
def max_train_overlap(eval_text: str, train_texts: list[str]) -> float:
return max(jaccard(eval_text, t) for t in train_texts)
print(max_train_overlap("reset mfa after phone swap", [
"reset mfa after phone swap please",
"refund for double charge",
]))
Write a one-page rubric with binary checks where possible: schema valid? required keys? intent in allowed set? refusal when PII requested? Save adjudicated labels. LLM-as-judge can draft scores, but calibrate it against 30 human-labeled rows or it will drift toward verbosity and flattery.
Offline holdout is necessary but not sufficient. After a candidate passes the scorecard, run a shadow or canary: same live prompts, compare baseline vs candidate side-by-side on schema rate and human spot checks. Online metrics catch distribution shift that your static JSONL missed.
Before each release, run three drills on anchors:
If drill (1) fails, stop—the adapter is not ready regardless of triage F1.
Evaluate fine-tunes with a task + anchor scorecard against the base model so you catch underfit, overfit, and catastrophic forgetting before shipping.