hands-on labon a real NVIDIA GPU

Evaluation & Benchmarking LLMs

Four evaluation lenses in one lab: compute real perplexity, expose BLEU's blindness to paraphrase, run side-by-side model comparisons, and build an LLM-as-judge harness with position-bias detection.

time
45 min
checked steps
4
level
Intermediate
setup
None
NCP-GENLNCA-GENLNCP-ADSPart of the AI Engineer Course

$29.99a month with Pro

Pro opens this lab and every other one. Cancel any time.

Lab cockpit45 min · 4 stepsSession running
2 / 4 steps passingSide-by-side model comparison (GPT-2 vs GPT-2 Medium) · step 3 of 4
lab.ipynb

Step 3 — Side-by-side model comparison (GPT-2 vs GPT-2 Medium)

Metrics are only useful if you're comparing. Let's generate from two models — gpt2 (124M) and gpt2-medium (355M) — on the same prompts and see the size effect with our own eyes.

[ ]:
prompts = [    'The key insight behind gradient descent is',    'The most common reason a machine learning model overfits is',    'A transformer differs from a recurrent neural network because',] models_to_compare = {    'gpt2': None,    'gpt2-medium': None,} # Load both models. They'll coexist in ~1.4 GB of VRAM combined.for name in models_to_compare:    tok_i = AutoTokenizer.from_pretrained(name)    mdl_i = AutoModelForCausalLM.from_pretrained(name).to(device)    mdl_i.eval()    if tok_i.pad_token is None:        tok_i.pad_token = tok_i.eos_token    models_to_compare[name] = (tok_i, mdl_i) print(f'Loaded: {list(models_to_compare)}')
[ ]:
generations = {}for p in prompts:    generations[p] = {}    for name, (tok_i, mdl_i) in models_to_compare.items():        ids = tok_i(p, return_tensors='pt').input_ids.to(device)        with torch.no_grad():            out = mdl_i.generate(ids, max_new_tokens=40, do_sample=False, pad_token_id=tok_i.eos_token_id)        text = tok_i.decode(out[0], skip_special_tokens=True)        generations[p][name] = text for p, per_model in generations.items():    print(f'PROMPT: {p}')    for name, text in per_model.items():        print(f'  [{name}] {text}')    print()
[ ]:
lab.check(3)
step 3 of 4, replayed: Side-by-side model comparison (GPT-2 vs GPT-2 Medium). The code, the output and the check are the lab's own.

4 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Perplexity: the branching-factor intuition

    read this step below ↓

  2. 2

    BLEU & ROUGE: what they measure and what they miss

  3. 3

    Side-by-side model comparison (GPT-2 vs GPT-2 Medium)

  4. 4

    LLM-as-a-Judge: the modern pattern (and its pitfalls)

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Perplexity: the branching-factor intuition

"If you can't measure it, you can't improve it." — Peter Drucker

Evaluating an LLM is one of the hardest problems in the field. Humans disagree on what "good" means. Automated metrics miss meaning. Running full benchmarks costs hundreds of dollars per model. And if you can't trust your eval, every optimization you do is flying blind.

This lab builds up from the simplest metrics to modern practice:

  1. Perplexity — the classic, still-useful LM metric. Computed on GPU.
  2. BLEU / ROUGE — n-gram overlap metrics from the machine-translation / summarization era. We'll show why they fail on LLM outputs, with a concrete example.
  3. Side-by-side model comparison — generate from two models of different sizes, compare qualitatively.
  4. LLM-as-a-Judge — the modern pattern. We'll implement the core pattern and detect the position bias that makes naive LLM judging untrustworthy.

Two papers worth reading while this runs

  • Zheng et al. 2023 — Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena. The definitive paper establishing LLM-as-judge as a credible eval method. Shows GPT-4 agrees with human preferences >80% of the time — as high as humans agree with each other. Also enumerates the three main biases (position, verbosity, self-enhancement) and mitigations. Chatbot Arena, the live leaderboard at lmarena.ai, is the practical output of this work.
  • HuggingFace — Perplexity of fixed-length models. The canonical reference for computing perplexity correctly on modern transformers (stride, context window, why model.eval() matters).

The two models we'll compare

  • gpt2 (124M params, 2019) — small but sharp baseline
  • gpt2-medium (355M params, 2019) — ~3× bigger, same era

Both fit comfortably in 8GB VRAM together, and the size effect on quality is visible even on ~5 prompts.


Perplexity is the exponential of cross-entropy loss. It answers: "at each token the model predicts, how many equally-likely options is it choosing between, on average?"

Concrete numbers:

  • Perplexity 2 → the model is effectively flipping a coin. Very confident.
  • Perplexity 50 → choosing between ~50 words. Typical for a decent small LM on natural text.
  • Perplexity 10,000 → basically random over a ~10K vocab. Garbage model.

Perplexity is a relative metric. Comparing perplexities across different tokenizers is meaningless (different vocab sizes change the scale). But on the same tokenizer + same corpus, lower is better.

It has fallen somewhat out of fashion in the LLM era — perplexity doesn't capture reasoning, factuality, or instruction-following. But it's still the first thing you'd sanity-check during pretraining: a sudden perplexity spike usually means your data pipeline broke.

🐛 Common mistake: forgetting model.eval()

If you compute perplexity on a model still in training mode, dropout layers are active, so the forward pass randomly zeros out activations. Your measured perplexity will be higher than it should, and — worse — non-deterministic. If you see different perplexity every run for the same data, this is almost always why.

Do: Load gpt2, move to CUDA, set to eval mode, and compute perplexity over a short evaluation text using the standard exp(mean(loss)) formula. Assign the scalar to ppl.

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

device = torch.device('cuda')
tok = AutoTokenizer.from_pretrained('gpt2')
model = AutoModelForCausalLM.from_pretrained('gpt2').to(device)
model.eval()  # <— critical: disables dropout, makes perplexity reproducible

eval_text = (
    "The quick brown fox jumps over the lazy dog. Machine learning models "
    "have become remarkably capable in recent years. Fine-tuning on "
    "domain-specific data often produces better results than prompting alone."
)

ids = tok(eval_text, return_tensors='pt').input_ids.to(device)
with torch.no_grad():
    out = model(ids, labels=ids)
    ppl = torch.exp(out.loss).item()

print(f'GPT-2 perplexity on our eval text: {ppl:.2f}')
print(f'Interpretation: the model behaves as if it had to pick between ~{ppl:.0f} equally-likely next tokens per step.')

what you get

  • A JupyterLab notebook on a real NVIDIA GPU
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Can load a Hugging Face causal LM and run `.generate()`
  • Understanding of cross-entropy loss and log-likelihood
  • Comfortable with basic Python data structures and strings

Exam domains covered

ExperimentationLLM Fundamentals & ArchitecturePrompt Engineering

Part of the LLMOps and MLOps track

Lab 10 of 22. vLLM serving, load tests against SLOs, tracing, drift monitoring and prompt tests in CI.

Questions about this lab

Does perplexity predict whether users will like a chat model?

No. Perplexity measures how well the model's own distribution assigns probability to held-out text, a useful signal during pretraining, and nearly orthogonal to helpfulness after fine-tuning. A heavily-RLHF'd chat model can have higher perplexity on a news corpus than its base model because the RLHF shifted the distribution toward instruction-following. Use perplexity to catch gross training collapse; use task-level metrics (accuracy, pass@1, win-rate vs a reference, grounded-answer rate) as the real release gate.

Why does BLEU score a synonym pair so low?

Because BLEU is literally counting matching n-grams between your output and the reference, with a brevity penalty. 'The feline rests upon the rug' shares almost no n-grams with 'The cat is on the mat' despite meaning the same thing. BLEU was designed for machine-translation evaluation against a small set of reference translations, where lexical overlap was a reasonable proxy; it fails on open-ended generation where valid outputs diverge lexically. ROUGE has the same limitation (it's n-gram recall instead of precision). The Step 2 demo makes this concrete.

Is LLM-as-judge reliable enough to gate a release?

With caveats. It's the most useful proxy for human preference at scale, but you have to treat the score as a noisy signal: randomize A/B ordering to suppress position bias, control for length to suppress verbosity bias, use multiple judge models to suppress self-preference bias, and calibrate against a small human-rated set to verify the judge tracks humans on your domain. The Step 4 pattern, run every pair twice in swapped order and flag position_bias_detected, is the minimum production discipline. Single-run single-judge scores are a lie detector that sometimes lies.

What do I actually do when position bias is detected?

Three options, increasing in rigor: randomize A/B order per query and aggregate across many comparisons (simplest); run every comparison in both orderings and only count decisive cases where both orderings agree (what the lab pattern builds toward); or use multiple judge models and majority-vote. The failure mode to avoid is a single judge + fixed ordering + a small eval set, that gives you a plausible-looking score that's largely noise.

What's still missing even after all four lenses?

Safety and harm evaluation (adversarial prompts, jailbreak resistance, demographic bias probes), factual grounding against a reference corpus (hallucination rate), latency and cost at production load, regression tests on critical user prompts that must never change, and real user feedback signals. The four lenses in this lab are the quality gates; the full release gate stack also needs safety, infra, and user-signal layers. The reflection at the end asks exactly this question, what are you still not measuring.

What does the grader enforce on each step?

Step 1 requires ppl to be a finite scalar in 5 < ppl < 1000, GPT-2's sane English range. Step 2 requires bleu_score in [0,1] and synonym_bleu < 0.3, catching a learner who accidentally computed BLEU against the identical pair. Step 3 needs ≥2 prompts, each with ≥2 models producing non-empty text. Step 4 needs judge_verdicts covering ≥2 comparisons and position_bias_detected as a proper bool, meaning you actually ran both orderings rather than hard-coding the result.

What you'll evaluate in this LLM benchmarking lab

Shipping an LLM feature without a real evaluation stack is how teams end up debugging hallucinations in production. In 45 minutes you'll touch the four evaluation lenses that matter, perplexity, BLEU/ROUGE, side-by-side generations, and LLM-as-judge, and see concretely where each one fails, so you can triangulate across them instead of trusting any single score. You'll walk away with a working LLM-as-judge harness that runs every comparison twice in swapped order to flag position bias, a concrete demo of why BLEU scores paraphrase as 'different' even when the meaning is identical, the branching-factor intuition for perplexity (exp(loss) ≈ 'the model is as uncertain as if choosing from ~N equally-likely next tokens'), and a defensible opinion about which subset of metrics you'd actually gate a release on, plus the dimensions you're still missing (safety, grounding, latency under load, regression tests on critical prompts).

The technical substance is the specific failure modes each metric hides. Perplexity: compute it the right way with model.eval(), labels=input_ids so HuggingFace shifts them internally, and ppl = math.exp(mean_cross_entropy), but recognize it tracks likelihood under the model's own distribution, which is nearly orthogonal to helpfulness in chat (a heavily RLHF'd model can have higher perplexity on a news corpus than its base). BLEU/ROUGE: n-gram overlap metrics designed for machine-translation evaluation against reference translations, they score 'The feline rests upon the rug' against 'The cat is on the mat' as almost unrelated. The grader enforces synonym_bleu < 0.3 so you actually computed BLEU across the synonym pair, not the exact-match one. LLM-as-judge: the most useful proxy for human preference at scale, but it carries position bias (judges favor whichever response appeared first or second), verbosity bias (longer answers win), and self-preference bias (a model rates its own style as better). The judge-swap pattern, run every pair twice, once as (A, B), once as (B, A), flag position_bias_detected = True when any verdict flips on swap, is the minimum instrumentation that keeps a single-judge score from being pure noise. The production escalation path from there is randomized ordering + multiple judges + calibration against a small human-rated set on your domain.

You need to be able to load a HuggingFace causal LM, run .generate(), and understand cross-entropy loss at an intuitive level. The sandbox is a real NVIDIA GPU pod we provision per session with transformers, GPT-2 + GPT-2 Medium, BLEU/ROUGE implementations, and a judge-model configuration all preinstalled. Checks hold measurements to sensible ranges rather than magic numbers: ppl finite and in 5 < ppl < 1000 for GPT-2 on English, bleu_score in [0,1], synonym_bleu < 0.3 (catches a learner who accidentally BLEU'd the identical pair), generations spans ≥2 prompts across ≥2 models with non-empty text, judge_verdicts has ≥2 comparisons, and position_bias_detected is a proper bool derived from swapped-order runs.