Evaluation Methodology.
The Concept
Imagine a student preparing for a final exam. You have three ways to measure whether the student learned the material. You can ask them to recite the textbook from memory, which tests recall. You can give them a multiple-choice quiz, which tests pattern matching against four answer shapes. You can hand them an open-ended problem and grade their reasoning, which tests something closer to understanding. Each method gives you a different number, and each number measures something different. None of them is "the truth." All three together start to look like one.
Now add a twist. Suppose the multiple-choice quiz is identical to the practice quiz the student took last week, and they wrote down every answer. The score will look brilliant, but the student has not learned anything new — the test is broken in a hidden way. This is the situation every honest evaluator of large language models lives in.
There are four families of evaluation that matter, and each measures a different slice of the model.
The first is perplexity, or its byte-normalized cousin, bits per byte (often abbreviated bpb). Perplexity asks a narrow question: how surprised is the model by a piece of natural text it has never seen? You feed the model a held-out sentence, you compute the probability the model would have assigned to each token in that sentence, and you take the geometric mean. Low perplexity means the model finds the text predictable. Bits per byte normalizes by the actual byte count of the text, which lets you compare models with different tokenizers. It is the unit Project 9: Pretraining on the Real Web already used to compare runs. Perplexity is the cleanest signal you can get, but it only measures distribution fit on natural text. It does not measure whether the model can follow instructions, answer questions, write code, or do anything you would actually use a language model for.
The second is multiple-choice scoring, where the model picks A, B, C, or D. The most famous benchmark in this family is MMLU, Massive Multitask Language Understanding, which gives the model 57 different subjects, from high school physics to professional law, and asks four-choice questions. The way you score it matters a great deal. The standard approach computes the logprob (the log of the probability) of each answer token under the model's distribution, conditioned on the question and the answer choices. You pick the option with the highest logprob and check whether it matches the labeled answer. Multiple-choice scoring is fast, repeatable, and brittle. The model might pick the right letter for the wrong reason, or the wrong letter for a reason the format does not let it express.
The third is open-ended generation evaluation. You give the model a prompt, you let it generate a response, and you check whether the response is correct. The check can be exact-match (does the generated text match the reference exactly?), or substring match (does the generated text contain the reference answer somewhere?), or it can use a more permissive comparison. Generation eval measures something closer to real usage, but the score depends on the prompt format, the sampling temperature, the maximum length, and a dozen other choices that the multiple-choice version hides.
The fourth is LLM-as-judge, where a stronger model reads the candidate model's output and assigns a score on a rubric. You write a prompt that says "Rate this answer from 1 to 5 on correctness and clarity," you send the candidate's output to GPT-4 or Claude, and you collect the judge's score. LLM-as-judge is the closest you can get to measuring usefulness at scale without paying human labelers, but the judge has biases. It prefers longer responses. It prefers the first response when shown a pair. It tends to agree with confident-sounding text even when the text is wrong.
When I first ran a Claude-as-judge pipeline on ACAR outputs, the agreement rate with my own ratings was around 71%. That sounds reasonable until you notice the disagreements clustered. The judge confidently endorsed verbose wrong answers I had marked down. I rewrote the rubric three times before the bias stopped showing up in the failure modes I cared about.
All four methods are valid. None of them measure the same thing. A model can score 4 bpb on web text, 12 percent on MMLU, 80 percent on a custom benchmark you wrote, and 3.4 out of 5 from a Claude judge, all at the same time. The numbers are not contradictory. They are answering different questions.
There is one more concept worth naming before any code appears: contamination. Contamination happens when the evaluation set leaks into the training data. Maybe the MMLU questions were posted on a forum that got scraped into the web crawl. Maybe a paper reproduced the GSM8K problems in its appendix and that paper ended up in your training corpus. Maybe someone bundled a public benchmark into a documentation repository and your data pipeline included it. The leak does not need to be deliberate. It just needs to happen.
When the eval set leaks, every evaluation method inflates. Perplexity drops on the leaked passages because the model has memorized them. Multiple-choice scores rise because the model has seen the question and the answer. Generation evaluations spike because the model can reproduce the reference output verbatim. LLM-as-judge ratings climb because the candidate's output suddenly looks correct, fluent, and confident, for the wrong reason. The model has not learned anything. It has memorized the test. The test is not broken, but the score is a lie.
The analogy that nails this is the cake-baking analogy adjusted slightly. A student scores 100 percent on a final exam that turns out to be a copy of last week's homework. The student has not learned the material; the score reports memorization, not understanding. The test is not broken. The score is. A score divorced from how the test was constructed and whether it was held out is, by itself, uninterpretable.
The mental image that finally made contamination feel concrete to me was thinking of the eval set as a sealed envelope. The training data was supposed to never see the inside of that envelope. The contamination check is the dusting for fingerprints. Most envelopes are clean. Some are covered.
This chapter teaches the four methods, shows how each one fails, and builds the contamination detector that catches the silent failure underneath them all.
Why It Matters
Without proper evaluation methodology, the field of language modeling becomes a casino. Researchers publish benchmark numbers. Practitioners read those numbers and pick models. Buyers read marketing copy that quotes the same numbers and sign contracts. Every step of that chain assumes the numbers mean something specific. When the numbers are produced by a contaminated benchmark, or by a scoring method nobody documented, or by a prompt template that happens to favor one model, the entire chain breaks silently. The decisions still happen. They are just based on noise.
There is a simpler reason this chapter sits where it does. After Project 21: Fine-Tuning and Instruction Tuning and before Project 23: Reward Models and RLHF, you are about to spend real compute on shaping model behavior. Every shaping technique needs a way to verify that it worked. If your eval inflates whenever the training data overlaps the test set, and it does, every time, you will train against the wrong signal and ship a model that scores well on paper while behaving worse in practice. You will not know this happened. You will think the training worked.
Years of running a multi-state SaaS platform taught me one thing that applies here, even though that work had nothing to do with language models: measurement at scale is mostly about discipline, not about the metric. A 99.99% uptime number means very little if you cannot show me what counted as downtime, who was watching, and which incidents got rolled into "scheduled maintenance." Benchmark numbers fail the same way. The score is the surface. The methodology underneath is where the truth lives or hides.
There is also a longer-running reason that becomes obvious once you have read three or four evaluation papers. The published benchmark numbers from major labs are nearly impossible to compare directly. One lab reports MMLU using 5-shot answer-token scoring. Another lab reports MMLU using zero-shot full-completion scoring with a different prompt template. A third lab fine-tunes on MMLU-adjacent data and reports the result anyway. The headline numbers look like they should be comparable. They are not. The skill this chapter builds is the ability to read a benchmark number and ask the three questions that decide whether it is meaningful: how was it scored, how was the prompt constructed, and was the eval held out from training. Without those answers, the number is meaningless. With them, you can interpret almost any published score.
The Build
build.py$python projects/22_evaluation-methodology/build.py --tiny
"""
Project 22: Evaluation Methodology
Canonical runnable build for this project. Generated by tools/extract_code.py
from the book's chapter markdown — re-run that tool to regenerate.
"""
# === Step 1: Perplexity and bits per byte ===
import math
import torch
import torch.nn.functional as F
@torch.no_grad()
def perplexity_and_bpb(model, tokenizer, text):
enc = tokenizer.encode(text, return_tensors="pt").to(model.device)
n_bytes = len(text.encode("utf-8"))
logits = model(enc).logits[:, :-1, :]
targets = enc[:, 1:]
log_probs = F.log_softmax(logits, dim=-1)
chosen = log_probs.gather(-1, targets.unsqueeze(-1)).squeeze(-1)
total_nats = -chosen.sum().item()
n_tokens = targets.numel()
ppl = math.exp(total_nats / n_tokens)
bpb = total_nats / math.log(2) / n_bytes
return ppl, bpb
# === Step 2: Multiple-choice scoring (MMLU-style) ===
@torch.no_grad()
def mmlu_score(model, tokenizer, question, options, answer_label):
prompt = (f"Question: {question}\n"
f"A. {options[0]}\n"
f"B. {options[1]}\n"
f"C. {options[2]}\n"
f"D. {options[3]}\n"
f"Answer:")
ids = tokenizer.encode(prompt, return_tensors="pt").to(model.device)
logits = model(ids).logits[0, -1]
log_probs = F.log_softmax(logits, dim=-1)
letter_ids = [tokenizer.encode(f" {c}", add_special_tokens=False)[0]
for c in "ABCD"]
scores = [log_probs[i].item() for i in letter_ids]
pick = "ABCD"[max(range(4), key=lambda i: scores[i])]
return pick, pick == answer_label, scores
# === Step 3: Open-ended generation evaluation ===
@torch.no_grad()
def generate_eval(model, tokenizer, prompt, reference, max_new_tokens=64):
ids = tokenizer.encode(prompt, return_tensors="pt").to(model.device)
out = model.generate(ids, max_new_tokens=max_new_tokens,
do_sample=False, temperature=0.0)
completion = tokenizer.decode(out[0][ids.shape[1]:],
skip_special_tokens=True)
exact = completion.strip() == reference.strip()
sub = reference.strip() in completion
return completion, exact, sub
# === Step 4: LLM-as-judge ===
JUDGE_PROMPT = """You are grading an answer to a question.
Question: {question}
Reference answer: {reference}
Candidate answer: {candidate}
Rate the candidate answer from 1 to 5 on correctness and clarity.
Output only the number, no other text.
Score:"""
def llm_judge(client, question, reference, candidate):
prompt = JUDGE_PROMPT.format(question=question,
reference=reference,
candidate=candidate)
resp = client.complete(prompt, max_tokens=4, temperature=0.0)
try:
return int(resp.strip())
except ValueError:
return None
# === Step 5: Contamination detection via n-gram overlap ===
def ngrams(tokens, n=13):
return {tuple(tokens[i:i+n]) for i in range(len(tokens) - n + 1)}
def build_eval_index(eval_texts, tokenizer, n=13):
index = set()
for text in eval_texts:
ids = tokenizer.encode(text, add_special_tokens=False)
index.update(ngrams(ids, n))
return index
def scan_corpus(corpus_iter, eval_index, tokenizer, n=13):
hits = []
for doc_id, doc in corpus_iter:
ids = tokenizer.encode(doc, add_special_tokens=False)
doc_ngrams = ngrams(ids, n)
overlap = doc_ngrams & eval_index
if overlap:
hits.append((doc_id, len(overlap)))
return hits
# === Step 7: The held-out paraphrased variant ===
PARAPHRASE_PROMPT = """Rewrite the following question so that it asks
exactly the same thing, but uses different words. Do not change the
meaning. Do not add or remove information.
Original: {question}
Rewrite:"""
def paraphrase_eval(client, questions):
paraphrased = []
for q in questions:
resp = client.complete(PARAPHRASE_PROMPT.format(question=q),
max_tokens=256, temperature=0.7)
paraphrased.append(resp.strip())
return paraphrased
The Steps
6 reference filesEach step is the code for one piece, in isolation — read them as the book introduces them. They assemble into build.py above.
Step 01Perplexity And Bits Per Bytestep_01_perplexity-and-bits-per-byte.py
"""
Project 22: Step 1 — Perplexity and bits per byte
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
import math
import torch
import torch.nn.functional as F
@torch.no_grad()
def perplexity_and_bpb(model, tokenizer, text):
enc = tokenizer.encode(text, return_tensors="pt").to(model.device)
n_bytes = len(text.encode("utf-8"))
logits = model(enc).logits[:, :-1, :]
targets = enc[:, 1:]
log_probs = F.log_softmax(logits, dim=-1)
chosen = log_probs.gather(-1, targets.unsqueeze(-1)).squeeze(-1)
total_nats = -chosen.sum().item()
n_tokens = targets.numel()
ppl = math.exp(total_nats / n_tokens)
bpb = total_nats / math.log(2) / n_bytes
return ppl, bpb
Step 02Multiple Choice Scoring Mmlu Stylestep_02_multiple-choice-scoring-mmlu-style.py
"""
Project 22: Step 2 — Multiple-choice scoring (MMLU-style)
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
@torch.no_grad()
def mmlu_score(model, tokenizer, question, options, answer_label):
prompt = (f"Question: {question}\n"
f"A. {options[0]}\n"
f"B. {options[1]}\n"
f"C. {options[2]}\n"
f"D. {options[3]}\n"
f"Answer:")
ids = tokenizer.encode(prompt, return_tensors="pt").to(model.device)
logits = model(ids).logits[0, -1]
log_probs = F.log_softmax(logits, dim=-1)
letter_ids = [tokenizer.encode(f" {c}", add_special_tokens=False)[0]
for c in "ABCD"]
scores = [log_probs[i].item() for i in letter_ids]
pick = "ABCD"[max(range(4), key=lambda i: scores[i])]
return pick, pick == answer_label, scores
Step 03Open Ended Generation Evaluationstep_03_open-ended-generation-evaluation.py
"""
Project 22: Step 3 — Open-ended generation evaluation
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
@torch.no_grad()
def generate_eval(model, tokenizer, prompt, reference, max_new_tokens=64):
ids = tokenizer.encode(prompt, return_tensors="pt").to(model.device)
out = model.generate(ids, max_new_tokens=max_new_tokens,
do_sample=False, temperature=0.0)
completion = tokenizer.decode(out[0][ids.shape[1]:],
skip_special_tokens=True)
exact = completion.strip() == reference.strip()
sub = reference.strip() in completion
return completion, exact, sub
Step 04Llm As Judgestep_04_llm-as-judge.py
"""
Project 22: Step 4 — LLM-as-judge
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
JUDGE_PROMPT = """You are grading an answer to a question.
Question: {question}
Reference answer: {reference}
Candidate answer: {candidate}
Rate the candidate answer from 1 to 5 on correctness and clarity.
Output only the number, no other text.
Score:"""
def llm_judge(client, question, reference, candidate):
prompt = JUDGE_PROMPT.format(question=question,
reference=reference,
candidate=candidate)
resp = client.complete(prompt, max_tokens=4, temperature=0.0)
try:
return int(resp.strip())
except ValueError:
return None
Step 05Contamination Detection Via N Gram Overlapstep_05_contamination-detection-via-n-gram-overlap.py
"""
Project 22: Step 5 — Contamination detection via n-gram overlap
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
def ngrams(tokens, n=13):
return {tuple(tokens[i:i+n]) for i in range(len(tokens) - n + 1)}
def build_eval_index(eval_texts, tokenizer, n=13):
index = set()
for text in eval_texts:
ids = tokenizer.encode(text, add_special_tokens=False)
index.update(ngrams(ids, n))
return index
def scan_corpus(corpus_iter, eval_index, tokenizer, n=13):
hits = []
for doc_id, doc in corpus_iter:
ids = tokenizer.encode(doc, add_special_tokens=False)
doc_ngrams = ngrams(ids, n)
overlap = doc_ngrams & eval_index
if overlap:
hits.append((doc_id, len(overlap)))
return hits
Step 07The Held Out Paraphrased Variantstep_07_the-held-out-paraphrased-variant.py
"""
Project 22: Step 7 — The held-out paraphrased variant
Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""
PARAPHRASE_PROMPT = """Rewrite the following question so that it asks
exactly the same thing, but uses different words. Do not change the
meaning. Do not add or remove information.
Original: {question}
Rewrite:"""
def paraphrase_eval(client, questions):
paraphrased = []
for q in questions:
resp = client.complete(PARAPHRASE_PROMPT.format(question=q),
max_tokens=256, temperature=0.7)
paraphrased.append(resp.strip())
return paraphrased
Read it in the book
This page ships the code. The full derivation — why the chain rule is mechanical not magical, what the computational graph buys you, the long-form reasoning — lives in Chapter 22 of Under the Hood.
Get the book →