TAI BUI
Back to curriculum
CapstonePython (torch, numpy)

Capstone Lesson 41: Full Evaluation Pipeline

Training comes with loss curves. Evaluation you have to design yourself: four evals, one report, and a mock judge that runs offline.

TIME: ~90 minutesPREREQUISITES: Phase 19 lessons 30-37 (NLP LLM track: tokenizer, embedding table, attention block, transformer body, pre-training loop, checkpointing, generation, perplexity)

Learning Objectives

  • Compute held-out perplexity with masked-token accounting on a tiny transformer.
  • Run an exact-match eval on short-form factual prompts.
  • Compute token-level F1 between predicted and reference strings with normalisation.
  • Build a local mock LLM-as-judge that scores model outputs on a 1-5 scale.
  • Aggregate the four evals into a single weighted report with per-task breakdown.

The Problem

An eval is a function that scores a model against a held-out dataset and returns a number. One eval doesn't describe a language model. Perplexity says how well the model fits the language distribution, and nothing about whether it answers questions. Exact-match says whether the model produced the gold string, and punishes correct paraphrases. Token F1 forgives paraphrase, but lexical overlap with wrong content can fool it. LLM-as-judge captures qualitative dimensions, at the cost of being expensive and stochastic.

You want all four. Each covers a dimension the others miss, and each runs on a held-out subset shaped for its metric. The final report puts the per-task numbers side by side with an aggregate, so a reviewer can see which trade-offs the model is making.

This lesson builds that pipeline in one file.

Warm-up0/3
1. A colleague reports their language model has a perplexity of 4.2 on a held-out set and asks if the model is good. What is the most defensible response?Warm-up
2. You evaluate a model on examples it saw during training and get a great score. Why is that number a poor estimate of how it will do on new inputs?Warm-up
3. A class has 2 students averaging 90 on a test and 8 students averaging 60. What is the average over all 10 students?Warm-up
Check: The Problem0/2
1. What is an eval in this pipeline?Define
2. A team proposes shipping a model after checking only exact-match. What does that single check leave uncovered?Explain

The Concept

Mermaid diagram

Each eval is a function that takes a model, a tokenizer, and a dataset and returns an EvalResult. The result carries the metric value, per-example records for inspection, and a name for the aggregate. run_demo runs the four evals and hands their results to aggregate, which takes an optional weights dict.

Check: The Concept0/1
1. What does an `EvalResult` carry?Define

Perplexity, properly counted

Perplexity is exp(mean negative log-likelihood per token). Two traps sit in the implementation:

  • The mean has to run over actual token positions, not batch * sequence. Padding tokens must stay out of the denominator, or perplexity looks better than it is.
  • The model predicts the next token, so logits at position i predict the token at position i+1. An off-by-one here is silent: the loss still trains, but the metric means nothing.

The eval sums -log p(token) over non-pad positions, tracks a token count per batch, and divides once at the end. Averaging per-batch perplexities instead would under-weight short sequences. The sum-then-divide version is also the textbook definition.

Check: Perplexity, properly counted0/7
1. What is perplexity, as the eval computes it?Define
2. Besides excluding padding from the denominator, what is the second perplexity trap named in the Perplexity, Properly Counted section?Define
3. The perplexity eval sums per-token negative log-likelihood over non-pad positions and divides by the total non-pad token count. Why not average per-batch perplexities instead?Explain
4. Two sequences have 2 and 8 non-pad target tokens, with summed negative log-likelihood 2.0 and 16.0. What mean NLL does the eval compute before taking the exponential?Apply
5. A text of 6 bytes is padded to length 10 with 4 pad tokens. How many next-token predictions from this text enter the perplexity sum?Apply
6. You raise `max_len` from 32 to 128 on the same texts and the same model, and reported perplexity drops sharply. What is the most likely cause?Analyze
7. Training loss falls normally, but the eval's perplexity on the same text bears no relation to it. What should you check first?Analyze

Exact-match, with normalisation

Exact-match checks whether the prediction equals the reference after both are normalised:

  • Lowercase.
  • Strip surrounding whitespace.
  • Collapse internal whitespace runs to a single space.
  • Drop trailing terminal punctuation (., !, ?) from both sides, unconditionally.

Normalisation is what makes the metric usable. A model that says "Paris" is right, and so is "Paris.", and so is " paris ". After normalisation the answer still has to be the same string.

Check: Exact-match, with normalisation0/2
1. What normalisation does the exact-match eval apply before comparing prediction and reference?Define
2. What problem does normalisation solve for exact-match?Explain

Token F1, the right way

Token F1 is the harmonic mean of precision and recall, computed over the bag of tokens. The steps:

  1. Normalise prediction and reference: lowercase, strip, collapse whitespace. Punctuation is not stripped here, so Paris. and Paris count as different tokens.
  2. Split each into a list of tokens (whitespace tokenisation).
  3. Count the multiset intersection.
  4. Precision = intersection_count / len(pred_tokens). Recall = intersection_count / len(ref_tokens). F1 is their harmonic mean.

If both prediction and reference are empty, F1 is 1 (a vacuous match). If only one is empty, F1 is 0. This follows the SQuAD evaluation reference and gives stable numbers across paraphrases.

Check: Token F1, the right way0/3
1. What is token F1 between a prediction and a reference?Define
2. Token F1 returns 1.0 when both prediction and reference are empty. Which other case must return a specific value?Define
3. The model predicts "Paris." and the reference is "Paris". What do exact-match and token F1 return?Apply

Local Mock LLM-as-Judge

A judge is a scorer that takes an instruction, a model's prediction, and a reference, and returns a score plus a rationale. A real one is a frontier model behind an API. This lesson has to run offline, so the mock judge is a deterministic function that returns a score in {1, 2, 3, 4, 5} and a one-line rationale. The rules are explicit:

  • 5 if normalised prediction equals normalised reference.
  • 4 if token F1 between prediction and reference is at least 0.8.
  • 3 if token F1 is in [0.5, 0.8).
  • 2 if token F1 is in [0.2, 0.5).
  • 1 otherwise.

It isn't a real judge, but it has the right interface. To swap in a real model, change one function. The pipeline doesn't care which one it gets.

Mermaid diagram

Check: Local Mock LLM-as-Judge0/6
1. What is the mock judge in this pipeline?Define
2. Why does the pipeline use a deterministic mock judge instead of a real model by default?Explain
3. The model predicts "the the the" and the reference is "the cat". What does the mock judge return?Apply
4. What score does the mock judge assign when token F1 between prediction and reference is 0.65 and the strings differ after normalisation?Apply
5. The model answers "the result is 7" and the reference is "the result is 7.". What does the mock judge return?Apply
6. You swap the mock judge for a real frontier-model judge. What is the minimum change to the pipeline?Design

Aggregation

The aggregate is a weighted mean of normalised eval scores. Each eval reports its own number, mapped into [0, 1]:

  • Perplexity: normalise as 1 / (1 + log(perplexity)). A perplexity of 1 maps to 1, and infinity maps to 0.
  • Exact-match: already in [0, 1].
  • Token F1: already in [0, 1].
  • Judge: divide by 5.

Weights are configurable. aggregate renormalises them over the evals actually present, so running a subset still gives a weighted mean in [0, 1]. normalise_metric also maps a perplexity of zero or below, infinity, or NaN to 0. The default mix is 0.2 perplexity, 0.3 exact-match, 0.3 token F1, 0.2 judge. Picking weights is a product decision, so the knob is exposed for you to experiment with.

Check: Aggregation0/5
1. The aggregator normalises perplexity with `1 / (1 + log(max(ppl, 1.0)))`. What does this guarantee about the normalised score?Explain
2. Why does `aggregate` map every eval into [0, 1] before applying weights?Explain
3. You run only exact-match (weight 0.3) and judge (weight 0.2) through `aggregate` with the default weights. What weights does it effectively use?Apply
4. Default weights apply. Perplexity is 20.1 (normalises to about 0.25), exact-match is 1.0, token F1 is 0.9 and the judge mean is 2.0. What is the aggregate?Apply
5. Only perplexity and token F1 are run. Perplexity is e (about 2.72) and token F1 is 0.8, with default weights. What is the aggregate?Apply

Architecture

Mermaid diagram

There is no suite or aggregator class. Each eval is a free function: perplexity_eval(model, tok, texts, max_len, batch_size) for perplexity, and (model, tok, pairs) for the other three (the judge eval also takes an optional judge_fn). Each returns an EvalResult. The free function aggregate collects the results into a FinalReport, and run_demo orchestrates the whole run. The demo prints the table and writes a JSON copy that downstream CI can ingest.

Check: Architecture0/1
1. What does `aggregate` return?Define

What you will build

The implementation is one main.py plus tests.

  1. TinyGPT: the same decoder-only architecture used in lessons 38-40, included so the lesson stands alone.
  2. InstructionTokenizer: byte tokeniser with INST / RESP / PAD specials.
  3. Four fixtures: an LM corpus, an EM set, an F1 set, and a judge set. Twenty examples each, deterministic.
  4. perplexity_eval: returns EvalResult with the perplexity value, a per-example record for each text (score is that text's perplexity), and total_tokens and mean_nll as extras.
  5. exact_match_eval: returns mean EM and per-example records.
  6. token_f1_eval: returns mean token F1 and per-example records.
  7. mock_judge and judge_eval: per-example score and rationale, mean score across the set.
  8. normalise_metric: per-eval normalisation rule.
  9. aggregate: weighted mean, returned as a FinalReport dataclass.
  10. run_demo: trains a tiny model briefly, runs all four evals, prints the report table, writes the JSON, and exits zero on success.

Reading the report

The report has three layers. The aggregate score is on top. The four per-eval numbers sit below it, and the per-example breakdowns for diagnostics sit below those. A failing CI run usually wants the aggregate. A reviewer chasing a regression wants the per-example layer, to see which inputs the model got wrong.

The JSON dump uses stable keys, so a CI dashboard can plot trend lines across versions. The pretty-printed table is for the human at the terminal after a training run.

Check: Reading the report0/4
1. What are the three layers of the eval report, and why does the JSON dump use stable keys?Explain
2. Your aggregate score is 0.72, but exact-match is 0.40 and judge is 4.5/5. Which trade-off is the model making?Analyze
3. Models A and B both have aggregate 0.60. A gets there from strong perplexity and judge scores with near-zero exact-match, and B from middling scores on all four. What is the defensible reading?Analyze
4. CI must fail the build when a checkpoint regresses, and a reviewer must then learn which inputs broke. Which use of the report layers fits both?Design
Author's Note

I'd start from the per-example layer when chasing a regression. The report puts the aggregate on top, but the per-example breakdown is where you see which inputs the model got wrong, and that's what a reviewer needs.

I'd also treat the weights as a decision you make on purpose. The default mix of 0.2 perplexity, 0.3 exact-match, 0.3 token F1, and 0.2 judge is a starting point, and the aggregation section calls the choice a product decision. My bet is that the four per-eval numbers side by side tell you more than the aggregate does, since each eval covers a dimension the others miss. And since the judge sits behind one function, I'd keep the mock until a real one earns its cost, because the pipeline doesn't change when you swap it.

Stretch goals

  • Add a calibration eval: do the model's softmax probabilities match its accuracy? Bucket predictions by confidence and report the empirical accuracy per bucket.
  • Add a robustness eval: tag each example with a perturbation (typo, paraphrase, distractor) and report the metric drop per perturbation.
  • Replace the mock judge with a real model behind an HTTP call. The function signature stays the same.
  • Add per-task weight learning: instead of fixed weights, fit weights to a target preference order over models.

You now have the four evals, the aggregator, and the report. Real evaluation pipelines layer more dimensions on top, and the pattern holds: one function per eval, one aggregator, one report.

Check: Stretch goals0/1
1. You want a robustness eval that reports the metric drop under typos and paraphrases. How does it fit the pipeline's design?Design

Code