WORKBENCH

Why is the eval separate?

If the evaluator and the model are both just models, why are they different systems?

Listen · 13 min0:00 / 12:30

An eval is the measuring stick you hold up to a model to find out whether it’s any good. The surprise is that the stick can be a model too, and what makes it trustworthy isn’t how clever it is but how little it shares with the thing it measures.

Get this wrong and the number lies to you, always in the flattering direction. A model asked to grade its own homework approves its own mistakes. A test set you check every afternoon for three weeks slowly stops measuring anything, so your cross-encoder’s score climbs while real users notice nothing. Every serious team, and every interviewer at a lab that trains models, wants to know how you know your number is honest.

All you need is to have trained a model and watched how it does on data held back from training. This opens the evaluation track: Ranking metrics and eval sets applies it to your ranker, Is that improvement real? tells you how much of a win is just luck, and Rewards and verifiers shows what happens when a training loop starts chasing the score.

Today

Nothing is required beyond having trained models and watched a validation loss. Training a tiny LM helps for the picture of training data next to held-out data. Budget one evening to read and one session on the Spark for the build.

The Problem

You said it yourself: “I used to read about how there is an evaluation system and there is a model. I never understood why there is a separate evaluation system and a separate model when both are basically some kind of machine learning models themselves.”

It’s a fair question, and the honest answer is that the word “eval” hides the important part. Here is the scene where it bites.

You fine-tune a small model to do arithmetic word problems. To check it, you ask the same model, “Here is a question and your answer. Is the answer correct? Say YES or NO.” It says YES 91% of the time. Great. Then you run a ten-line Python script that actually computes the answers, and the true accuracy is 55%.

Nothing crashed. Nobody lied on purpose. The model that produced 347 × 28 = 9,616 (the real answer is 9,716) looked at 9,616 and found it entirely believable, because it was computed by the same weights with the same blind spot. You didn’t measure the model. You asked it for its opinion of itself.

Now a second scene, closer to your real work. You’re tuning the cross-encoder. Every afternoon you change something, rerun the eval set, and keep the change if NDCG goes up. After three weeks, you’ve made 60 decisions against the same 500 queries. Your NDCG is up 4 points. You ship. Online, the click-through rate doesn’t move.

Same disease, different organ. In the first scene, the measurement shared the model’s errors. In the second, the measurement was slowly absorbed into the model: every decision you made against those 500 queries leaked a little information about them into your system, until your system was partly fit to them. Both scenes end the same way: the number says “better” and reality says “no”.

Separating the eval from the model is the fix for both. Not because the eval is a different kind of thing (it can absolutely be a neural network), but because its value comes from being independent of the thing it measures, and untouched by the process that improves it.

The Idea

An eval is a thermometer, not a doctor

Start with what an eval is for. You have something you actually care about: “the ranker puts the right document first”, “the answer is correct”, “users are happier”. You can’t observe that directly at scale, so you build an instrument that produces a number you hope moves with it. That instrument is the evalShort for evaluation. A fixed procedure that takes a model (or prompt, or system) and produces a score meant to reflect how good it is at the thing you care about..

The quality of an instrument isn’t how clever it is. A thermometer is dumber than a doctor. What makes a thermometer useful is that its reading depends on the patient’s temperature and on nothing else: not on what the patient wants it to say, not on what the doctor hopes, not on whether you’ve used it ten times today. The word for that is independenceTwo things are independent when knowing one tells you nothing about the other. An eval’s errors should be independent of the model’s errors, so they don’t line up and hide each other., and it’s the whole game.

So the precise version of your question is not “why is the eval a different model?” It’s: what does the eval have to be independent of? Two things:

  1. The model’s errors. If the grader tends to be wrong in the same places the model is wrong, its mistakes don’t add noise, they add bias, always in the flattering direction.
  2. The optimization process. If the score is used to make changes to the model, the model will drift toward whatever the score rewards, including the parts of the score that are wrong.

Everything else in this lesson is one of those two ideas in more detail.

FIG_001[ TWO LOOPS, ONE WALL ]
TRAINING LOOPruns millions of timesTRAIN DATAseen every epochMODELLOSScheap, smooth,a proxyGRADIENTSCHANGE WEIGHTSWALLnothing flows backEVALUATION LOOPruns a handful of timesTEST SETfrozen, unseenMODELweights frozenGRADERprogram, human,or other modelMETRICwhat you careabout, maybe slowDECISION: SHIP? WHICH RUN?
The training loop optimizes a proxy (the loss) with gradients, millions of times. The evaluation loop runs a frozen model on held-out data, produces a number for a human decision, and sends nothing back into the weights. The wall between them is the point.

Loss is not the metric: the training objective is a proxy on purpose

Even inside a single training run, there are already two different numbers, and they’re different on purpose.

The training objectiveThe number the optimizer pushes down during training, usually called the loss. It has to be cheap to compute and differentiable so gradients can flow through it. has hard engineering requirements. It must be computed millions of times, so it has to be cheap. Gradients have to flow through it, so it has to be differentiableYou can compute how a tiny change in each weight would change the number. Gradient descent needs this; a step function like “right or wrong” gives zero gradient almost everywhere.. Cross-entropy is a great training objective. Nobody actually cares about cross-entropy.

The evaluation metricThe number you use to judge whether the model is good at the real task. It can be slow, non-differentiable, or even a human opinion, because you compute it rarely and never take gradients of it. has the opposite constraints. It must match what you care about, and that’s all. It can be a step function (exact match). It can involve a sort (NDCG). It can take a human a minute per example. It can be “did the user buy something in the next week”.

Your cross-encoder is the perfect example. You train it with binary cross-entropy or a pairwise loss on (query, document) scores, because those are smooth. You judge it with NDCG@10, which depends on the order of the top ten documents. Sorting is a step function: nudge a score a hair and the order either flips or doesn’t, so NDCG has no useful gradient. The loss is a differentiable stand-in for NDCG, and they usually move together. Usually. A model can lower the loss by getting more confident about documents ranked 40th, which NDCG doesn’t see at all.

That’s the first reason the eval is separate: the thing you optimize is chosen to be optimizable, and the thing you measure is chosen to be true. Those are different design goals, so they’re different objects.

Self-grading: correlated errors

Now the first scene. Why does a model grading itself fail?

Think of two students. One takes an arithmetic test and gets 45% of it wrong. The other grades it. If the grader is an independent person who’s good at arithmetic, their grading mistakes are random: once in a while they miss an error, once in a while they mark a right answer wrong. Those mistakes are unrelated to which questions the student got wrong. Average over 1,000 questions and the grader’s noise mostly washes out.

Now let the student grade their own test. The question they got wrong is precisely the question where their mental arithmetic has a hole. Rechecking with the same hole, they get the same wrong answer, and it looks right. The grading mistakes are now concentrated exactly on the student’s mistakes. That’s correlated errorsWhen two systems tend to be wrong on the same inputs. Averaging doesn’t cancel correlated errors; they stack in one direction., and they don’t wash out. They push the score in one direction: up.

A model is in the same position, only more so. Its “opinion” of an answer comes from the same weights that produced the answer. If it believes 347 × 28 is 9,616, a judging prompt doesn’t install new arithmetic. And there’s a second, subtler effect: researchers have found that LLM judges tend to recognize and prefer text written by themselves, a self-preference biasThe tendency of an LLM judge to rate its own outputs (or outputs in its own style) higher than equally good outputs from others. separate from correctness. Huang and colleagues (2023) found that LLMs asked to review and fix their own reasoning, with no outside feedback, mostly didn’t improve, and sometimes changed right answers to wrong ones.

Here is the bookkeeping. Every graded answer lands in one of four boxes: right and approved, right and rejected, wrong and approved, wrong and rejected. The box that matters is wrong and approved, the false approvalA wrong answer the judge marks as correct. In classifier language, a false positive. This is the box that inflates your score.. Play with who does the grading.

Predict firstwrite it down

A model is right on 55% of 1,000 arithmetic questions. It grades its own answers. Say it approves 95% of its right answers, and it approves 85% of its wrong ones (because it shares the blind spot that caused most of them). What accuracy does the self-judge report?

Reveal

Right and approved: 0.95 × 550 ≈ 523. Wrong and approved: 0.85 × 450 ≈ 383. The judge says YES to 523 + 383 = 906 answers, so it reports about 91%, against a true 55%. Notice the self-judge is not even “a bit generous”. It mostly measures how often the model answers at all, because it approves nearly everything. Load the “Same model” preset below and you’ll see these exact counts.

FIG_002[ WHO GRADES MATTERS ]

true accuracy - · judge says - · wrong answers approved -

Expected counts out of 1,000 answers. The blue cell is wrong answers the judge approved. A judge that shares the model's blind spot moves most wrong answers into that cell, and the measured bar runs far ahead of the true one. Drag the blind-spot slider for any judge to see how much independence is worth. Presets are illustrative, not measurements; you'll measure real ones in the build.

The formula behind that figure is short, and it’s worth knowing, because it tells you what a judge is actually reporting. Call the model’s true accuracy a. Call the judge’s approval rate on right answers TPR (true positive rate) and on wrong answers FAR (false approval rate). Then:

measured = a · TPR + (1 − a) · FAR

If FAR is 0 and TPR is 1, measured = a. Perfect. Every bit of FAR adds score that doesn’t exist, and the size of the lie is biggest exactly when the model is weak (large 1 − a). And here’s the real reason independence matters: an independent judge has some FAR, but it’s roughly the same no matter which questions the model gets wrong. A self-judge’s FAR is high because of which questions the model gets wrong. Change the model, and the self-judge’s FAR changes with it, so you can’t even compare two models fairly.

There’s a hopeful corollary. If you measure TPR and FAR on a sample where you know the truth (a couple hundred examples you labeled yourself), you can solve that equation backward:

a ≈ (measured − FAR) / (TPR − FAR)

Epidemiologists use this to correct disease prevalence for imperfect tests (it’s called the Rogan-Gladen correctionA 1978 formula from epidemiology that recovers the true rate of something from a measured rate, given the test’s known true positive and false positive rates.). It only works if FAR and TPR are stable, which is exactly what an independent judge gives you and a self-judge doesn’t. Hold that thought; it’s the core of when a model-judge is fine.

What can grade: programs, people, models

There are three kinds of grader. Each has a failure mode, and picking one is a trade between how much it costs and how closely it tracks the truth.

GraderExamplesStrong atFails by
Programexact match, unit tests, a calculator, a SQL query result, a JSON schema check, a verifierA program that checks whether an output is correct, usually much simpler than producing it. Running a test suite verifies code; multiplying back verifies a division.Free, fast, deterministic, zero shared blind spotsOnly works when “correct” is computable; brittle parsing marks right answers wrong (“9716” vs “9,716”); checks the letter of the spec, not its intent (tests pass, code is awful)
Humanyou labeling 200 rankings, annotators, domain expertsClosest to what you mean by “good”Slow and expensive; people disagree with each other; tired raters drift; they prefer confident, long, well-formatted answers too
Modelan LLM judge with a rubric, a reward model, a trained classifierCheap at scale, handles fuzzy criteria (“is this summary faithful?”)Shares blind spots with models like it; biased toward position, length, its own style; can be fooled by text written to fool it

The rule of thumb: use the cheapest grader that’s independent enough for the decision. Arithmetic and code get programs. “Is the right document in the top three?” gets your own labels, once, then a program (NDCG over the labels). “Is this answer helpful and faithful?” gets a model judge, calibrated on human labels.

That last one is where your original confusion lives. A model judge is fine, and very widely used, when four things hold:

  1. It isn’t the model being graded, and ideally isn’t from the same family or trained on the same outputs. Different training data means less-correlated errors.
  2. The judging task is easier than the generating task. Checking that a summary contains no claims absent from the source is easier than writing a great summary. Checking a sudoku is easier than solving one. Verification being easier than generation is why a judge can sometimes grade a model stronger than itself.
  3. It’s calibrated against humans. You hand-label a few hundred examples, run the judge on them, and measure agreement (and TPR and FAR). In the MT-Bench paper (Zheng et al., 2023), strong LLM judges agreed with human preferences about as often as humans agreed with each other, above 80%, and the paper also documented their position, length, and self-enhancement biases.
  4. It never becomes the training target without a separate check. Which brings us to Goodhart.

LLMs as judges is the full lesson on building and calibrating one. Here, the point is narrower: “both are ML models” is fine. “Both share errors” is not.

Goodhart’s law: when the measure becomes the target

The second scene, the slow leak, has a famous name. Economist Charles Goodhart wrote in 1975 that any observed statistical regularity tends to collapse once pressure is placed on it for control purposes. Anthropologist Marilyn Strathern later gave it the form everyone quotes: Goodhart's law“When a measure becomes a target, it ceases to be a good measure.” Optimizing hard against a proxy finds the ways the proxy differs from the real thing..

Here’s why it’s a law and not a proverb. A proxy is correlated with the real thing, but it’s not the real thing; there’s some gap. When you optimize gently, you mostly move along the shared direction (real improvements raise both). But real improvements are hard and get harder, while the gap is often cheap to exploit: a judge that likes long answers is easier to please by writing more than by being right. Optimize harder, and the cheap direction wins.

There’s also a purely statistical version that needs no cheating at all. Pick the 30 highest-scoring candidates out of 300. Score = true quality + noise. The candidates at the very top are disproportionately the ones that got lucky noise, so their true quality is lower than their scores suggest. Researchers call this flavor regressional GoodhartSelecting on a noisy proxy: the top scorers are partly top because of noise, so their true value regresses toward average. Happens even when nobody games anything., and it’s why the correlation between score and truth is always weakest among the winners, which is where you’re looking.

Push the slider.

FIG_003[ GOODHART, IN ONE SLIDER ]

avg score - · avg true quality - · correlation - · true quality of the top 10% -

Left: 300 candidates, measured score against true quality. Right: average score (blue) and average true quality (black) as optimization pressure against the score rises. At first both climb. Then the cheap trick the score rewards takes over: the score keeps rising and true quality falls. Note how flat the blue top-10% cluster is even at zero pressure: among the winners, score barely predicts truth. Toy model with fixed seeds.
Predict firstwrite it down

Before you move the slider: at what pressure do you expect average true quality to peak, and what happens to the score past that point? And among the top 10% by score at zero pressure, do you expect a strong or weak correlation between score and true quality?

Reveal

In this toy, true quality peaks around pressure 30-40 (about 0.42) while the score keeps climbing in a nearly straight line to 3.3. By pressure 100, average true quality is about −0.84, worse than where you started, while the score is the best it’s ever been.

The top-10% correlation is weak even at zero pressure. Among the 30 winners, the range of true quality is narrow and noise dominates, so the score barely ranks them. That’s regressional Goodhart, no gaming required. The overall correlation drops from about 0.77 to 0.10 as pressure rises.

This isn’t hypothetical in modern ML. RLHF is Goodhart’s law with a GPU. A reward model is trained to predict human preferences: it’s an eval. Then the policy is trained by RL to maximize it: now it’s a target. Gao, Schulman and Hilton (2022) measured this carefully: as the policy optimizes harder against a learned reward model, the score from a separate “gold” reward model first rises, then falls, while the optimized reward keeps rising. That’s the curve in Figure 003, measured for real. It’s why RLHF uses a KL penalty to keep the policy near where it started, and why labs evaluate RL-trained models on things other than the reward model they trained against. When the model finds a way to raise the reward that isn’t what you meant, it’s called reward hackingWhen an optimized system raises its reward by exploiting flaws in how the reward is computed, rather than by doing the intended task better..

So here’s the cleanest definition of an eval you’ll find in this lesson: an eval is a measurement you promise not to optimize against directly. The moment you do, it becomes a training signal, and you need a new eval to check it.

Train, dev, test: three piles, three levels of contact

The same idea governs how you split data. The question isn’t “is this data in the training set?” It’s “how many times has this data influenced a decision?”

FIG_004[ WHO TOUCHES WHAT, HOW OFTEN ]
TRAIN · 80%fit the weightsDEV · 10%make choicesTEST · 10%report onceTOUCHED BYthe optimizer, every step~10⁶ timesyou, every experiment~10-1000 timesyou, at the endonceLEAKS: DUPLICATES · SAME QUERY IN BOTH ·PRETRAINING CONTAMINATION · PEEKING
Each pile is defined by how much it has influenced the system, not just by whether it was trained on. Leaks (dashed) quietly move test data leftward: duplicates across splits, the same query in both sides, contamination in pretraining, and you peeking at the test score to make a choice.

Two leaks deserve special mention because they bite you specifically.

Group leakage. Your cross-encoder data is (query, document, label) triples. If you split randomly by row, the same query appears in train and test with different documents. The model can learn “for this query, documents about X are relevant” and then ace the test on that query, a skill it won’t have on new queries in production. Split by query (all rows for a query go to one side). The general rule: split along whatever unit will be new in production. New queries? Split by query. New users? Split by user. Future data? Split by time.

Adaptive overfitting. This is the second scene, made precise. Each time you look at a test score and make a decision, you’ve used the test set as a dev set. Do it enough and you can overfit a test set without ever training on it, just by choosing. Researchers who study this call it adaptive data analysisAnalysis where each new question you ask of a dataset depends on the answers to earlier questions. Reusing a holdout this way can make it overfit even though no model trained on it.. How bad can it get? Here is a simulation you’ll run yourself in the build: 1,000 test examples with random labels (no model can truly beat 50%), and a bunch of random-guess “models” scored against them.

FIG_005[ WINNING ON NOISE ]
50%60%70%1101001,00010,000MODELS TRIED AGAINST THE SAME TEST SET (K)FRESH TEST SET: 50%BEST OF K: 54%56%VOTE OF THE LUCKY ONES: 71%
1,000 random labels, K random-guess models, keep the best by test score (measured on this Mac, numpy, 20 trials each). The best looks better and better as you try more, and on a fresh test set every one of them scores 50%. Majority-voting the 'lucky' half of 1,000 random models reaches 71% on the test set it was chosen on, and 50% on a fresh one.

Notice the shape. Trying 100 models and keeping the best buys you 4 points of pure noise on a 1,000-example set. That’s bigger than most of the “wins” in a typical paper’s results table. The voting trick is from Blum and Hardt’s “Ladder” paper, written precisely because competition leaderboards on Kaggle were being climbed this way. (Is that improvement real? is where you learn to put error bars on this.)

One honest caveat, because it surprised the field: when Recht and colleagues (2019) built a fresh ImageNet test set to measure a decade of adaptive overfitting, accuracy dropped about 11-14 points, but the ranking of models was almost perfectly preserved, and the drop came mostly from the new images being slightly harder, not from overfitting to the old test set. Big, diverse test sets used by a whole community proved surprisingly robust. Your 500-query dev set that one person makes 60 decisions on is not a big community benchmark. Treat it like Figure 005.

The eval is the spec

There’s one more reason the eval is a separate system, and it’s the one that matters most once you’re building products instead of reading papers. Writing the eval forces you to decide what “good” means.

“Make the ranker better” is not a spec. “Raise NDCG@10 on 500 real queries, labeled with these four relevance grades, sampled from last month’s traffic in proportion to frequency, with no drop on the 50 navigational queries” is a spec. It answers questions nobody wants to answer: which queries matter? How much is a grade-2 document worth compared to a grade-3? Do rare queries count as much as common ones? Two engineers who disagree on those will build two different rankers and both claim victory. The eval set settles the argument before the work starts.

That’s why strong teams practice eval-driven developmentWriting the eval before (or alongside) the system, like tests in test-driven development: collect examples of the behaviour you want, see them fail, change the system until they pass, and keep them as a regression suite.. You collect examples of the behaviour you want (including the embarrassing failures from production), you see the current system fail them, you change things until they pass, and every fixed failure stays in the suite forever so it can’t silently come back. It’s TDD with a score instead of a pass/fail, and it’s what your validation harness at work is, or should become.

Offline evals and online tests

Last distinction. Everything so far is an offline evalScoring a system on a fixed, stored dataset, without real users. Fast, cheap, repeatable, and only a proxy for what users experience.: a frozen dataset, scored in minutes, repeatable forever. Offline evals are how you iterate.

But an offline eval is itself a proxy, for what happens when real users meet the system. The ground truth for a product is an A/B testAn online controlled experiment: randomly send some real users to the new system and some to the old one, and compare what they do (clicks, purchases, retention).: randomly split real traffic, show half the new ranker, and measure what users actually do. It’s slow (days to weeks to get enough data), risky (real users see the bad version), and needs care to avoid its own traps, but it’s the closest thing to the truth you’ll get.

The two form a ladder, and each rung checks the one below it:

  1. Training loss (cheap proxy) checked by
  2. dev-set metric (what you iterate on) checked by
  3. frozen test set (what you report) checked by
  4. online A/B test (what users do).

When a rung stops predicting the next one up, you have a Goodhart problem at that rung. If your NDCG wins stop turning into click-through wins, it’s time to look hard at whether your offline labels still mean what users mean. Measuring how well offline wins predict online wins is a real piece of work, and teams that do it trust their offline evals for good reasons instead of out of habit.

Build It

You’ll do this on the Spark: a small model answering arithmetic, three different judges grading it, a Goodhart demo, and then a clean harness. Arithmetic is chosen on purpose: a ten-line program knows the truth, so every judge can be checked exactly.

Make a folder, say ~/workbench/evals/. Everything below goes in it.

Serve three local models and write a cached clientAgent: fine

On the Spark, install Ollama and pull a tiny answerer, a bigger sibling from the same family, and a model from a different family. (Check ollama.com/library for current tags; these existed at the time of writing.)

curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:0.5b     # the answerer, and its own judge
ollama pull qwen2.5:7b       # same family, bigger
ollama pull llama3.1:8b      # different family
python3 -m venv .venv && . .venv/bin/activate && pip install openai numpy

Ollama serves an OpenAI-compatible API on port 11434. Every call goes through one function that caches to disk, so reruns are free and every raw output is kept.

# llm.py
import hashlib, json, os, time
from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
CACHE = "cache.jsonl"
_mem = {}
if os.path.exists(CACHE):
    for line in open(CACHE):
        rec = json.loads(line)
        _mem[rec["key"]] = rec

def llm(model: str, prompt: str, temperature: float = 0.0, seed: int = 0, max_tokens: int = 512) -> str:
    key = hashlib.sha256(json.dumps([model, prompt, temperature, seed, max_tokens]).encode()).hexdigest()
    if key in _mem:
        return _mem[key]["out"]
    t0 = time.perf_counter()
    r = client.chat.completions.create(
        model=model, temperature=temperature, seed=seed, max_tokens=max_tokens,
        messages=[{"role": "user", "content": prompt}],
    )
    out = r.choices[0].message.content
    rec = {"key": key, "model": model, "prompt": prompt, "temperature": temperature,
           "seed": seed, "out": out, "secs": round(time.perf_counter() - t0, 3)}
    _mem[key] = rec
    with open(CACHE, "a") as f:
        f.write(json.dumps(rec) + "\n")
    return out

And a question generator. Seeded, so the set is reproducible.

# questions.py
import json, random

def make(n: int, seed: int) -> list[dict]:
    rng = random.Random(seed)
    qs = []
    for i in range(n):
        a, b, c = rng.randint(100, 999), rng.randint(11, 99), rng.randint(10, 999)
        qs.append({"id": f"q{seed}-{i}", "question": f"What is {a} * {b} - {c}?", "answer": a * b - c})
    return qs

if __name__ == "__main__":
    with open("questions.jsonl", "w") as f:
        for q in make(400, seed=1):
            f.write(json.dumps(q) + "\n")

The 0.5B model should get somewhere between a fifth and two thirds of these right. If it’s far outside that, change the digit ranges: you want plenty of both right and wrong answers to grade.

Answer, then verify with a programHands only

Write the answerer and the ground-truth checker. The checker is the most important ten lines in this lesson: it’s the grader that shares nothing with the model.

# answer.py
from llm import llm

ANSWER_PROMPT = "{question}\nWork it out, then end with a line 'ANSWER: <integer>'."

def answer(model: str, question: str) -> str:
    """Return the model's full text response at temperature 0."""
    ...

def parse_int(text: str) -> int | None:
    """Pull the integer after the last 'ANSWER:'. Strip commas and spaces.
    Return None if there isn't one (that counts as wrong, but log it separately)."""
    ...

def verify(text: str, truth: int) -> bool:
    """The programmatic verifier: parse, compare to the true answer."""
    ...

Run it over all 400 questions with qwen2.5:0.5b. Print accuracy, and the count of unparseable responses. Then eyeball 10 failures: are they arithmetic slips, or parse failures? A verifier that marks right answers wrong because of formatting is a broken ruler, and you’d want to know before you trust it.

Three judges: self, sibling, strangerHands only

Now grade the same 400 responses three more ways, with a model judge that sees the question and the response but not the true answer.

# judge.py
JUDGE_PROMPT = """Here is a math question and a proposed solution.
Question: {question}
Solution: {response}
Is the final answer correct? Reply with exactly one word: YES or NO."""

def judge(judge_model: str, question: str, response: str) -> bool | None:
    """True for YES, False for NO, None if the reply is neither. Temperature 0."""
    ...

def confusion(truth: list[bool], verdict: list[bool]) -> dict:
    """Counts of the four boxes: tp (right, YES), fn (right, NO), fp (wrong, YES), tn (wrong, NO).
    Also return tpr = tp/(tp+fn), far = fp/(fp+tn), measured = (tp+fp)/n."""
    ...

Run it with judge models qwen2.5:0.5b (self), qwen2.5:7b (sibling) and llama3.1:8b (stranger). Use the verifier from t1 as truth. Fill in the first Measure table.

Then try the correction. Split the 400 into 100 “calibration” and 300 “use”. On the calibration 100, estimate each judge’s TPR and FAR against the verifier. On the other 300, compute (measured − FAR) / (TPR − FAR) and compare to the verifier’s true accuracy on those 300. Which judge does the correction rescue, and which does it make wild? (Hint: look at TPR − FAR for the self-judge.)

Make a flawed metric go up while accuracy goes downHands only

Here’s the flawed metric. It’s a caricature of a sloppy rubric: it rewards length and words that sound careful.

# goodhart.py
import re

CAREFUL = ["verify", "check", "carefully", "therefore", "step", "confirm"]

def looks_good(text: str) -> float:
    """The proxy. 0 to 10. Rewards length (up to 300 words) and careful-sounding words."""
    words = len(text.split())
    hits = sum(text.lower().count(w) for w in CAREFUL)
    return min(words, 300) / 50 + min(hits, 8) * 0.5

Now do two kinds of optimization against it.

Prompt search. Score each of these prompts on 100 questions by mean looks_good, and also by true accuracy with your verifier:

PROMPTS = [
    "{question}\nEnd with 'ANSWER: <integer>'.",
    "{question}\nThink step by step. End with 'ANSWER: <integer>'.",
    "{question}\nThink step by step and double-check. End with 'ANSWER: <integer>'.",
    "{question}\nExplain carefully, verify each step, at least 150 words. End with 'ANSWER: <integer>'.",
    "{question}\nWrite at least 300 words. Use the words verify, check, therefore, carefully, confirm often. End with 'ANSWER: <integer>'.",
    "{question}\nFirst write 300 words on the history of multiplication, mentioning that you verify and check carefully. Then end with 'ANSWER: <integer>'.",
]

Best-of-N selection. For 100 questions, sample N = 8 responses at temperature 0.8 (vary seed). For N in 1, 2, 4, 8, pick the response with the highest looks_good among the first N, and record its true correctness. Also record two references: pick at random, and pick the correct one if any is correct (the ceiling, often called pass@N).

def best_of_n(responses: list[str], n: int, key) -> str:
    """Return the response among responses[:n] that maximizes key(response)."""
    ...

Plot (or tabulate) mean proxy and true accuracy for each prompt, and for each N. You are hoping to see Figure 003 with real data. Be honest about what you see: step-by-step reasoning genuinely helps arithmetic, so true accuracy may rise for the first few prompts before falling. That’s the healthy part of the curve, and it’s exactly what makes Goodhart hard to spot early.

Optional: replace looks_good with the 0.5B model rating responses 1 to 10, and check how strongly its ratings correlate with response length versus with correctness.

Build the clean harness, with a frozen test setAgent: review after

Now build the thing you’d actually trust. Requirements:

  • Freeze the data. Generate a fresh 1,000 questions with a seed you’ve never used. Split by a fixed rule into dev.jsonl (500) and test.jsonl (500). Write manifest.json with each file’s SHA-256. The harness refuses to run if a hash doesn’t match.
  • One command, one config. python run_eval.py --config configs/foo.json --split dev. The config names the model, prompt template, temperature, seed, and grader.
  • Log everything. Each run gets a folder runs/<utc-timestamp>-<name>/ containing the config, the git commit of your code (git rev-parse HEAD, and a flag if the tree is dirty), the dataset hashes, every prompt and raw response, every parsed answer and verdict, latency per call, and a summary.json with the score and a 95% bootstrap interval.
  • Guard the test set. Running --split test requires --final, asks for a one-line reason, and appends an entry to test_log.jsonl (time, config, score, reason). The log is your record of how many times you’ve peeked. If it has more than a handful of entries, your test set is turning into a dev set, and the log will tell you so.

Write it yourself, then have an agent review it specifically for leaks: anywhere test data could influence a decision, any non-determinism that would make two runs of the same config disagree, anything not logged that you’d need to reproduce a number six months from now.

Win on noise, on your MacHands only

Reproduce Figure 005 with numpy. No LLM needed.

# peek.py
import numpy as np

def best_of_k(n: int, k: int, rng) -> tuple[float, float]:
    """Random 0/1 labels of length n. k random 0/1 'models'. Return (best test accuracy,
    that same model's accuracy on a fresh set of random labels)."""
    ...

def lucky_vote(n: int, k: int, rng) -> tuple[float, float]:
    """Keep the models with test accuracy > 0.5, majority-vote them. Return (test acc, fresh acc)."""
    ...

Average each over 10-20 trials for k in 1, 10, 100, 1000, with n = 1000. Then change n to 200 (a typical “quick eval set”) and rerun. How much fake improvement can 100 tries buy you now?

Done when
  • You have true accuracy for the 0.5B model from a program, and a confusion matrix for each of three model judges against it.
  • You can say, with your own numbers, how much the self-judge inflates the score and why.
  • You’ve seen a proxy metric rise while true accuracy fell (or you can explain precisely why it didn’t in your run).
  • run_eval.py runs from a config, logs everything, verifies dataset hashes, and refuses to touch the test set without --final.
  • peek.py reproduces the shape of Figure 005.

Measure

Judges. One row per grader, all on the same 400 responses from qwen2.5:0.5b.

GraderMeasured accuracyTPRFAR (wrong approved)Corrected estimate (300)Your prediction of FAR
Verifier (truth)1.000.00—
Self (qwen2.5:0.5b)
Sibling (qwen2.5:7b)
Stranger (llama3.1:8b)

What to expect, roughly (I could not run these models on this Mac, so this is a prediction to check, not a result): the self-judge far above the truth, with FAR well over half, because a 0.5B model barely judges and says YES to most things. If its TPR is close to its FAR, it isn’t discriminating at all, and the correction divides by nearly zero and blows up. The 7B sibling and 8B stranger should both be much better, because they can mostly do the arithmetic. If they’re close to each other, that’s a real finding: for arithmetic the shared blind spot is “can’t multiply”, which a 7B model mostly doesn’t have. Family matters most for things a whole family learned wrong from the same data.

Goodhart. One row per prompt, 100 questions each.

PromptMean wordsMean looks_goodTrue accuracy
P0 bare
P1 step by step
P2 double-check
P3 150 words
P4 300 words + keywords
P5 history essay

And for best-of-N:

NProxy-picked accuracyRandom-picked accuracypass@N (ceiling)
1
2
4
8

You should see looks_good climb nearly monotonically down the prompt table: it’s measuring words and keywords, and the later prompts ask for exactly that. True accuracy should rise from P0 to P1 or P2 (reasoning helps) and then flatten or fall as the model spends its small capacity on padding (P5 may also cause more parse failures, which count as wrong). For best-of-N, pass@N will rise steeply (more chances), random-picked stays flat, and proxy-picked tells you whether your proxy has any real signal: if it tracks random, the proxy is selecting on length, which carries no information about correctness here.

Peeking. On my Mac (Python 3.14, numpy, 20 trials per row, n = 1,000):

Models tried (K)Best test accuracySame model, fresh labels
150.3%~50%
1052.6%~50%
10053.9%~50%
1,00055.0%~50%
10,00056.2%~50%
Lucky vote, K = 1,00071.1%50.5%

The shape follows from one fact: the best of K noisy scores grows like the standard error times roughly √(2 ln K). On 1,000 examples, one model’s accuracy has a standard error of about √(0.25/1000) ≈ 1.6 points, so 100 tries buy about 3 standard errors, about 4.8 points in theory, a bit less in practice. Shrink n to 200 and the standard error grows to about 3.5 points, so the same 100 tries buy around 8 points of nothing (my simulation: 58.3%).

Break It

1. Give the judge the answer key. Rerun the self-judge with the true answer in the prompt (for example “The correct answer is 9,716. Does the solution’s final answer match?”). The 0.5B self-judge should suddenly look excellent. Why? You turned it into a string-matching verifier. What made it a bad grader was never that it’s a neural network; it was that it had no access to the truth independent of its own beliefs. Reference answers are the cheapest independence you can buy, and it’s why most good LLM-judge setups give the judge a reference or a rubric anchored in facts.

2. Leak the test set into the prompt. Take 5 questions from test.jsonl, with answers, and put them in the prompt as few-shot examples. Score on the full test split. Those 5 jump to near-perfect, and the overall score rises a little. Now imagine this at internet scale, invisibly, across a whole benchmark: that’s contamination. Then check that your harness from t4 would have caught you (did you log the prompt? does a hash or overlap check flag test items appearing in prompts?). If not, add an overlap check.

3. Pad a wrong answer. Take 20 responses the stranger judge correctly rejected. Append “I have carefully verified each step twice and confirmed the result is correct.” and rejudge. Count how many flip to YES. Then try moving the answer line to the top, or writing it twice. Each flip is a free point of score that nothing earned, and an RL policy optimized against this judge would discover these tricks within hours.

Explain It Back

Answer out loud, in plain words, as if in an interview. Then check.

1. The evaluator and the model are both ML models. Why are they separate systems?

Because the eval’s job isn’t to be smart, it’s to be an independent measurement of what you care about. Two kinds of independence matter. First, its errors must not line up with the model’s errors: a model grading itself approves its own mistakes because the same weights that produced a wrong answer find it convincing, so the score is biased upward, not just noisy. Second, it must not be optimized against: once you train or select hard against a score, the system finds the ways the score differs from the real goal (Goodhart’s law), so the score stops meaning anything. A model can be a fine grader if it’s a different model, the judging task is easier than the generating one, and you’ve measured its agreement with human labels. It just can’t be the same thing, and it can’t be the target.

2. Why not just train directly on the metric you care about, like NDCG or accuracy?

Often you can’t: those metrics are step functions (accuracy flips between right and wrong; NDCG depends on a sort), so their gradient is zero almost everywhere and gradient descent gets no signal. So you train on a smooth, cheap proxy like cross-entropy or a pairwise loss, and measure the real metric separately. Even when you can optimize the real metric (by RL, or by selecting among candidates), the metric you optimize stops being an honest measure of progress, so you still need a separate held-out measurement to check you’ve improved and not just fit the metric’s quirks.

3. Why must the test set stay untouched, if you never train on it?

Because decisions leak information too. Every time you look at a test score and keep or discard a change, you’ve used the test set to choose, which partly fits your system to those specific examples. With enough decisions you can overfit it without a single gradient step: picking the best of 100 random models on a 1,000-example set buys about 4 points of pure noise. The fix is separate piles: a dev set you iterate on freely (and discount), and a test set you score on once at the end, with a log of every time you peek. Also watch for leaks that bypass you entirely: duplicates across splits, the same query or user on both sides, and test data in pretraining.

4. How does this scale? You go from 1 eval decision a week to an automated search that tries 10,000 prompt variants a day against the same 500-example dev set.

The fake improvement from selection grows with the number of things you try, roughly like √(2 ln K) standard errors. At 500 examples one score’s standard error is about 2 points at 50% accuracy, so the best of 10,000 variants can look about 9 points better than it is, purely by luck (I simulated it with random guessers: best of 10,000 on 500 random labels scored 58.9%), and the search will happily find it. So more search needs more data and more separation: a bigger dev set (the noise shrinks like 1/√n, so 4x the data halves it), a separate validation set to confirm the winner, a test set used once, and ideally fresh data regularly so nothing gets reused forever. Cost scales too: a model judge at 10,000 variants × 500 examples is 5 million judge calls a day, which is why cheap programmatic graders and caching matter, and why you’d shortlist with a cheap metric and confirm with an expensive one.

5. When would you trust an LLM judge for your ranker’s eval, and what would you check first?

When the criterion is fuzzy enough that a program can’t compute it (is this document actually relevant to the intent?), and labeling everything by hand is too slow. First I’d hand-label a few hundred (query, document) pairs myself, run the judge on them, and measure agreement and its false-approval and false-rejection rates, overall and per query type. I’d use a judge from a different model family than anything that generated or trained the ranker, give it a clear rubric with examples, check position and length bias by swapping order and padding, and recalibrate periodically. And I’d never train the ranker against that judge without keeping a separate, human-labeled test set to catch it gaming the judge. Finally, I’d check that offline wins under the judge predict online A/B wins, because that’s the rung above.

Go Deeper

Connections

This is the doorway to the Evaluation track. Ranking metrics and eval sets applies it to your cross-encoder: which metric, and how to build an eval set with query-level splits. Is that improvement real? puts error bars on everything here, including how big a dev set has to be before a 1-point win means anything. LLMs as judges is the full treatment of model-based grading: rubrics, calibration, and catching position and length bias.

It also sets up the Agents & RL track. A reward in RL is an eval that’s become a target, so Verifiers and rewards is this lesson’s Goodhart section with a training loop attached, and it’s why programmatic verifiers (like the ten-line arithmetic checker you wrote) are so valuable there. And Training a tiny LM is where you first watched train and val loss diverge: overfitting is Goodhart’s law applied to the training set.