Part 3 · Evaluating LLMs · module 1 of 2 · 20 min
Quantitative evaluation
Measuring a model with numbers — perplexity, masked-prediction accuracy, HellaSwag, KL divergence, MAUVE, benchmark suites and bias assessment.
The essence
Every quantitative metric for a language model reduces to one of two questions. Either: how surprised was the model by text it should have predicted (perplexity, cross-entropy, masked accuracy)? Or: how close is the distribution of text it generates to the distribution of real text (KL divergence, MAUVE)? Benchmarks like HellaSwag are the first question dressed as multiple choice — score each option by likelihood and pick the highest.
Why it matters
Numbers are the only way to compare models, track a fine-tune, or notice a regression, and they are the currency of every claim in the field. They are also systematically misleading: perplexity is not comparable across tokenizers, benchmark scores are inflated by contamination, and none of it measures whether the model is useful. Knowing precisely what each number does and does not license is the actual skill.
Topics covered (14)
- 01The promises and challenges of quantitative evaluation
- 02Numerical issues in logits and softmax
- 03Perplexity
- 04Comparing perplexities across models and texts
- 05Masked word prediction accuracy in BERT
- 06HellaSwag
- 07Loading large models with bitsandbytes quantisation
- 08Running the same benchmark on two models
- 09Kullback-Leibler divergence
- 10MAUVE
- 11Variability in MAUVE across sample sizes
- 12SuperGLUE and other benchmark amalgamations
- 13Assessing bias and fairness
- 14Non-technical benchmarks
What quantitative evaluation can and cannot do
A single number lets you compare, track and automate — you can watch a fine-tune improve, catch a regression in CI, and rank two checkpoints without reading a word of output. Nothing else scales.
The cost is that the number measures the proxy, not the goal. A model can lower perplexity by becoming better at predicting boilerplate. It can gain ten points on a benchmark because the benchmark leaked into pretraining. Neither tells you whether the model is more useful, and both look like progress.
Perplexity
Cross-entropy loss is the mean negative log-probability the model assigned to the tokens that actually occurred. Perplexity is its exponential, which puts it on an interpretable scale: a perplexity of 20 means the model was, on average, as uncertain as if choosing uniformly among 20 options at each step.
Lower is better, and the floor is 1 (perfect certainty). A good modern model reaches the low teens or single digits on ordinary English; a randomly initialised model sits near the vocabulary size, because it genuinely has no information.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained("gpt2")
model = AutoModelForCausalLM.from_pretrained("gpt2").eval()
@torch.no_grad()
def perplexity(text, stride=512, max_len=1024):
ids = tok(text, return_tensors="pt")["input_ids"]
nlls, n_tokens = [], 0
for begin in range(0, ids.size(1), stride):
end = min(begin + max_len, ids.size(1))
target_len = end - begin - (max_len - stride if begin else 0)
chunk = ids[:, begin:end]
labels = chunk.clone()
labels[:, :-target_len] = -100 # score only the new tokens
loss = model(chunk, labels=labels).loss
nlls.append(loss * target_len)
n_tokens += target_len
return torch.exp(torch.stack(nlls).sum() / n_tokens).item()
print(perplexity("The capital of France is Paris. " * 20)) # low: predictable
print(perplexity("qwzx plib fromage tractor helicopter")) # high: nonsense
# Bits per character — comparable ACROSS tokenizers.
def bits_per_char(text):
ids = tok(text, return_tensors="pt")["input_ids"]
loss = model(ids, labels=ids).loss.item() # nats per token
return loss * ids.size(1) / (len(text) * 0.6931) # -> bits per charNote: Naively chunking without the stride scores tokens that had almost no context, inflating perplexity. The sliding window gives every scored token a full window of history.
Numerical issues in logits and softmax
Logits are unbounded reals, and exponentiating them overflows quickly — exp(89) already exceeds float32's range. Every softmax implementation therefore subtracts the maximum logit first, which leaves the result identical and the intermediate values safe.
For evaluation you almost always want log-probabilities, and you should get them from log_softmax rather than by taking the log of softmax output. log_softmax uses the log-sum-exp identity to stay stable, whereas log(softmax(x)) can produce −inf when a probability underflows to exactly zero — and one −inf poisons the mean.
import torch, torch.nn.functional as F
logits = torch.tensor([1000.0, 999.0, -1000.0])
print(torch.exp(logits)) # [inf, inf, 0] — overflow
print(F.softmax(logits, dim=-1)) # fine: max is subtracted
print(torch.log(F.softmax(logits, dim=-1))) # [..., ..., -inf] — underflow
print(F.log_softmax(logits, dim=-1)) # stable, finite
# Extract the log-prob of specific target tokens — the primitive underneath
# perplexity, HellaSwag and almost every likelihood-based benchmark.
def target_logprobs(logits, targets):
logp = F.log_softmax(logits, dim=-1)
return logp.gather(-1, targets.unsqueeze(-1)).squeeze(-1)Masked prediction accuracy
BERT was trained to fill in masked tokens, so the natural metric is how often it recovers the original. Unlike perplexity this gives a top-1 accuracy, and top-k accuracy is often more informative — the correct word may be a defensible second choice.
It is also a useful probe rather than just a score. Mask a gendered pronoun or an occupation and inspect the ranked candidates, and you are measuring the model's learned associations directly, which is the basis of most bias evaluation for encoder models.
import torch
from transformers import AutoModelForMaskedLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained("bert-base-uncased")
model = AutoModelForMaskedLM.from_pretrained("bert-base-uncased").eval()
@torch.no_grad()
def top_predictions(sentence, k=5):
ids = tok(sentence, return_tensors="pt")
pos = (ids["input_ids"][0] == tok.mask_token_id).nonzero().item()
logits = model(**ids).logits[0, pos]
top = logits.topk(k)
probs = torch.softmax(logits, -1)[top.indices]
return [(tok.decode([i]), round(p.item(), 3))
for i, p in zip(top.indices, probs)]
print(top_predictions(f"The capital of France is {tok.mask_token}."))
# The same call, used as a measurement of learned association:
print(top_predictions(f"The nurse said {tok.mask_token} was tired."))
print(top_predictions(f"The engineer said {tok.mask_token} was tired."))
# Comparing the pronoun distributions across matched sentences quantifies
# occupational gender association without any hand-labelling.HellaSwag, and how likelihood benchmarks work
HellaSwag gives a context and four possible continuations, one genuine and three adversarially generated to be plausible-looking but wrong. Humans score above 95%; the wrong options were specifically filtered to fool models.
The scoring mechanism is what generalises. You do not ask the model to choose — you compute the average log-probability it assigns to each continuation and take the highest. Length normalisation is essential: without dividing by token count the shortest option wins by default, since every additional token multiplies in another probability below 1.
import torch, torch.nn.functional as F
@torch.no_grad()
def score_continuation(model, tok, context, continuation):
"""Mean log-probability of the continuation tokens, given the context."""
ctx = tok(context, return_tensors="pt")["input_ids"]
full = tok(context + continuation, return_tensors="pt")["input_ids"]
logits = model(full).logits[0, :-1] # predicting position i+1
targets = full[0, 1:]
logp = F.log_softmax(logits, dim=-1).gather(-1, targets[:, None]).squeeze(-1)
cont = logp[ctx.size(1) - 1:] # continuation tokens only
return cont.mean().item() # normalise by length
def hellaswag_accuracy(model, tok, examples):
correct = 0
for ex in examples:
scores = [score_continuation(model, tok, ex["ctx"], e) for e in ex["endings"]]
correct += int(max(range(len(scores)), key=scores.__getitem__) == ex["label"])
return correct / len(examples)
# Using .sum() instead of .mean() makes the shortest ending win almost always.
# That single line is the most common bug in home-grown benchmark harnesses.from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch
config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.bfloat16, # compute in bf16, store in 4-bit
bnb_4bit_quant_type="nf4", # normal-float 4, better than int4
bnb_4bit_use_double_quant=True, # quantise the quantisation constants
)
model = AutoModelForCausalLM.from_pretrained(
"EleutherAI/pythia-2.8b", quantization_config=config, device_map="auto")
# Roughly a 4x memory reduction with a small quality cost. Enough to run a
# 7B model on a 12GB card. For evaluation this is usually an acceptable
# trade — but report that you quantised, because it does move the numbers.KL divergence
KL divergence measures how much information is lost by using distribution Q to approximate P: the expected log-ratio, summed over the support of P. It is zero only when the two distributions are identical.
It is not symmetric, and the asymmetry is meaningful. D(P‖Q) is infinite if Q assigns zero probability where P does not — so it punishes a model for ruling out something that happens. Reversing the arguments punishes the opposite error. Which direction you use encodes which mistake you consider worse.
In LLM work KL appears constantly: as the penalty keeping an RLHF policy near its reference, as a measure of how much a fine-tune shifted the output distribution, and as the divergence between a quantised model and its full-precision original.
import torch, torch.nn.functional as F
@torch.no_grad()
def kl_between_models(model_p, model_q, ids):
logp = F.log_softmax(model_p(ids).logits, dim=-1)
logq = F.log_softmax(model_q(ids).logits, dim=-1)
# KL(P||Q) = sum_x P(x) [log P(x) - log Q(x)]
return (logp.exp() * (logp - logq)).sum(-1).mean().item()
# PyTorch's version expects log-probabilities for the input and
# probabilities for the target by default — a frequent source of wrong signs.
print(F.kl_div(logq, logp, log_target=True, reduction="batchmean"))
# Asymmetry, demonstrated:
p = torch.tensor([0.5, 0.5, 0.0])
q = torch.tensor([0.5, 0.5, 1e-9])
# KL(p||q) is finite; KL(q||p) blows up, because p rules out what q allows.MAUVE
Perplexity says nothing about generated text — it only scores text the model did not write. MAUVE addresses the other question: does the distribution of what the model generates resemble the distribution of real text?
It works by embedding both sets of texts, clustering the joint space to get discrete distributions, and computing a curve of divergences between them at varying mixture weights. The area under that curve is the score, between 0 and 1, and it captures both failure directions at once: degenerate repetitive output, and output so diverse it stops resembling real writing.
The practical caveat is variance. MAUVE is sensitive to sample size and to the clustering, so a difference computed on a few hundred samples is largely noise. Report it over several thousand samples, and repeat with different seeds before believing a small gap.
Choosing a metric
| Question | Metric | Needs |
|---|---|---|
| How well does it predict held-out text? | Perplexity, bits per byte | A reference corpus |
| How good is its generated text? | MAUVE | Thousands of samples plus real text |
| Can it choose the sensible continuation? | HellaSwag, ARC, PIQA | A labelled benchmark |
| Does it know facts? | MMLU, TriviaQA | A labelled benchmark |
| How far did my fine-tune move it? | KL divergence from the base | Both models |
| Can it reason multi-step? | GSM8K, BBH | A labelled benchmark |
| Is it biased? | Paired-template probes, BBQ, StereoSet | Matched sentence pairs |
Benchmark suites, and contamination
SuperGLUE, MMLU, HELM and the open leaderboards bundle many tasks into one score, which reduces the chance that a model wins by being narrowly tuned. Aggregation also hides the thing you most want to know — which task moved — so always look at the per-task breakdown.
The deeper problem is contamination. These benchmarks are public, so they are in the web crawls used for pretraining. A model may score well by having memorised the answers, which is undetectable from the score alone. Treat any benchmark result on a model whose training data you cannot inspect as an upper bound, and prefer benchmarks released after the model's training cutoff.
Worth remembering
- Perplexity is exp(mean cross-entropy) — the effective number of equally likely choices per token.
- Perplexity is not comparable across tokenizers, because the denominator is a different count of tokens.
- KL divergence is asymmetric; MAUVE exists to compare generated and real text distributions symmetrically.