Classical Metrics
BLEU, ROUGE-L, F1, exact-match, accuracy. Five metrics that still account for most published LLM eval numbers. Implement each from first principles so you know what the number means.
Lesson 69 of 85Build~90 minPython
Before this: Phase 19 Track B foundations, lesson 70
On this page
Learning objectives
- Implement token-level exact-match, F1, and accuracy with explicit tokenisation rules.
- Implement BLEU-4 from the ground up: modified n-gram precision, geometric mean over n equals 1 through 4, brevity penalty.
- Implement ROUGE-L using longest common subsequence, with F-beta combination of precision and recall.
- Dispatch on the metric_name field from lesson 70 so the runner stays metric-agnostic.
- Pin the behaviour with reference vectors drawn from worked examples, not from a third-party library.
cd-bleu-overlap
Why reimplement
You will read papers that report BLEU 28.3 and another that reports BLEU 0.283. You will find ROUGE-L scores that differ by ten points across two libraries because one truncates to lowercase and the other does not. The fastest way to stop being confused is to write the metrics yourself, then point at the line where the tokenizer is decided and the line where the smoothing is applied. After that, comparing numbers across papers becomes a matter of reading the metric setup, not arguing about libraries.
Stdlib plus numpy is enough. BLEU is counting and a clamp. ROUGE-L is dynamic programming. F1 is a set intersection on tokens. The hardest part is choosing a tokenizer and committing to it.
Tokenisation
The tokenizer is re.findall(r"\w+", text.lower()). Lowercase, runs of word characters (letters, digits, and underscore), drop everything else. Every metric in this lesson uses this exact tokenizer. The runner does not get to choose. If you swap tokenizers, you are running a different benchmark.
TOKEN_RE = re.compile(r"\w+", re.UNICODE)
def tokenize(text):
return TOKEN_RE.findall(text.lower())
This is a deliberate simplification. Production setups will care about CJK, contractions, and code identifiers. The point of the lesson is that the tokenizer is a contract, not a knob.
Exact match
def exact_match(pred, targets):
return float(any(pred.strip() == t.strip() for t in targets))
It returns 1.0 or 0.0 per task. The aggregate over a dataset is the mean. This is the workhorse for arithmetic, MCQ, and short classification tasks.
Token-level F1
Set up the token multiset for prediction and target. Precision is the multiset intersection divided by the multiset of the prediction. Recall is the same intersection divided by the multiset of the target. F1 is the harmonic mean. The implementation handles the empty-prediction and empty-target edge cases.
For multi-target tasks, we take the best F1 over the target list. That matches the SQuAD-style behaviour widely reported in the literature.
BLEU-4
BLEU is the canonical machine-translation metric and it still shows up in summarisation work. The formulation we use is sentence-level BLEU-4, scored per prediction and reference pair, with the standard brevity penalty and additive-one smoothing on modified n-gram counts so a single missing 4-gram does not push the score to zero.
For each candidate-reference pair, we count modified n-gram precision for n equals 1, 2, 3, 4. Modified precision clips the candidate n-gram count by the maximum count of that n-gram in any reference, so a candidate cannot inflate by repeating one phrase. The geometric mean across the four precisions is wrapped by the brevity penalty.
The smoothing rule is the one Lin and Och called method 1: add one to both numerator and denominator of every n-gram precision before taking the log. This avoids log 0 when a reference has no matching 4-gram and stays close to the unsmoothed value on long candidates.
ROUGE-L
ROUGE-L compares the longest common subsequence of the candidate and reference token sequences. The LCS captures word order without forcing contiguity, which is why it is the default summarisation metric. We compute the LCS length with a standard dynamic-programming table, then derive recall as lcs / reference length, precision as lcs / candidate length, and combine with F-beta where beta equals one for the symmetric F1 form.
def lcs_length(a, b):
n, m = len(a), len(b)
dp = numpy.zeros((n + 1, m + 1), dtype=int)
for i in range(n):
for j in range(m):
if a[i] == b[j]:
dp[i+1, j+1] = dp[i, j] + 1
else:
dp[i+1, j+1] = max(dp[i+1, j], dp[i, j+1])
return int(dp[n, m])
The numpy table makes the implementation legible; pure Python lists would work too. Tasks that opt into ROUGE-L pay the $O(n m)$ cost per task. For typical summary lengths that stays under a millisecond.
Accuracy
For classification tasks, accuracy reduces to exact-match: the prediction scores 1 if it matches any normalised target in the list. We expose it as a separate function so the dispatcher can dispatch on metric_name without going through string comparisons inside the runner.
Dispatch contract
The single entry point is score(metric_name, prediction, targets). It returns a float in [0, 1]. The runner does not branch on metric name. It hands the call off and writes the result. This is the surface that lesson 75 will glue to the task spec from lesson 70.
def score(metric_name, pred, targets):
if metric_name == "exact_match":
return exact_match(pred, targets)
if metric_name == "f1":
return max(f1_score(pred, t) for t in targets)
if metric_name == "bleu_4":
return max(bleu4(pred, t) for t in targets)
if metric_name == "rouge_l":
return max(rouge_l(pred, t) for t in targets)
if metric_name == "accuracy":
return accuracy(pred, targets)
raise ValueError(f"unknown metric_name: {metric_name}")
code_exec is handled in lesson 72 and slotted into the dispatcher there.
What this lesson does not do
It does not call a model. It does not normalise generations beyond what the post-process rules from lesson 70 already did. It does not compute confidence intervals. It does not do BLEURT or BERTScore (those need a model and live in a different lesson). The point is the floor: five metrics, one tokenizer, one dispatch table.
How to read the code
main.py defines each metric as a free function plus the dispatcher. The reference vectors live in the _reference_examples block at the bottom of the file. The demo runs the dispatcher against eight examples and prints per-metric scores. The tests in code/tests/test_metrics.py pin the reference vectors and stress every edge case (empty prediction, empty reference, no shared tokens, exact match, repeated phrase clipping).
Read main.py top to bottom. The functions are ordered by complexity. exact_match and accuracy are one line each. F1 is six lines. BLEU and ROUGE-L are the heavy parts and they include detailed comments on the smoothing rule and the LCS recurrence.
Going further
The classical metrics are necessary, not sufficient. They reward surface overlap and miss meaning. The fix is to layer model-based metrics on top (BLEURT, BERTScore, GEval) once you trust the classical floor. That is a later lesson. For now: make these five work, pin them with tests, and you have a metric stack that is auditable, fast, and reproducible.
Check yourself
Quick quiz
Why is the tokenizer chosen at the metric layer rather than the runner layer?
- Tokenizers are model-specific and the runner cannot see the model
- The tokenizer defines what counts as a token match; swapping it changes the benchmark
- Metric implementations are O(n) regardless of tokenizer choice
- Numpy requires a tokenizer at import time
Show the answer
B. The tokenizer defines what counts as a token match; swapping it changes the benchmark BLEU and F1 are sensitive to tokenisation. Binding the tokenizer to the metric makes scores reproducible and lets you point at the rule.
What does modified n-gram precision do that plain n-gram precision does not?
- It clips each candidate n-gram count by the maximum count seen in any reference
- It runs faster than plain precision
- It uses a different log base
- It returns a value in 0 to 100 instead of 0 to 1
Show the answer
A. It clips each candidate n-gram count by the maximum count seen in any reference Clipping by the reference cap stops a candidate from inflating its score by repeating a high-precision word over and over.
Why does BLEU use a brevity penalty?
- Long candidates make the geometric mean numerically unstable
- Brevity penalty replaces the n-gram count cap
- It compensates for the additive-one smoothing
- Without it, a very short candidate that matches a few words gets a high precision and unfairly high BLEU
Show the answer
D. Without it, a very short candidate that matches a few words gets a high precision and unfairly high BLEU Precision alone rewards short outputs. BP downweights candidates shorter than the reference using exp(1 - r/c).
What does ROUGE-L compare?
- The Levenshtein distance between candidate and reference strings
- The 4-gram precision against any reference 4-gram
- The longest common subsequence of candidate and reference token sequences
- The character-level Jaccard index
Show the answer
C. The longest common subsequence of candidate and reference token sequences ROUGE-L uses the LCS length, then computes precision (LCS/cand-len) and recall (LCS/ref-len) combined with F-beta.
Token-level F1 returns 1.0 for which case?
- When the prediction and target share exactly one token
- When the prediction is a superset of the target with extra noise
- When the prediction is empty and the target is non-empty
- When prediction and target are both empty
Show the answer
D. When prediction and target are both empty By convention, empty-empty is a perfect match. Any non-empty target with an empty prediction is 0.0.
Why dispatch on metric_name rather than category in the score function?
- The runner does not have access to the category field
- It lets the same category use different metrics across tasks and keeps the dispatcher metric-agnostic
- Category is harder to validate than metric_name
- metric_name is shorter than category
Show the answer
B. It lets the same category use different metrics across tasks and keeps the dispatcher metric-agnostic metric_name is the contract. A summary task can use rouge_l on one record and bleu_4 on another; the dispatcher does not need to care.
Questions people ask
What is the difference between BLEU and ROUGE?
Both compare generated text to reference text by word overlap. BLEU focuses on precision, how much of the output appears in the reference, and is common in translation. ROUGE focuses on recall, how much of the reference appears in the output, and is common in summarization.
What is exact match in AI evaluation?
Exact match checks whether the model's answer equals the reference answer after simple cleanup like lowercasing and removing punctuation. It is strict and easy to compute, which suits short factual answers but not open-ended text.
Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License