TAI BUI
Back to curriculum
CapstonePython

Classical Metrics

BLEU, ROUGE-L, F1, exact-match, and accuracy account for a large share of published LLM eval numbers. Implement each from first principles and you'll know what the number means.

TIME: ~90 minPREREQUISITES: Phase 19 Track B foundations, lesson 70

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.

Why reimplement

A classical metric is a deterministic function that scores a prediction against reference text by surface overlap, with no model in the loop. That makes the numbers cheap and reproducible, and it also makes them easy to misread.

You will see one paper report BLEU 28.3 and another report BLEU 0.283. You will see ROUGE-L scores that differ by ten points across two libraries because one lowercases and the other doesn't. Writing the metrics yourself is the quickest way out of that confusion: you can point at the line where the tokenizer is decided and the line where the smoothing is applied. Comparing numbers across papers then comes down to reading the metric setup instead of arguing about libraries.

Stdlib plus numpy is enough. BLEU is counting and a clamp. ROUGE-L is dynamic programming. F1 is an intersection of token counts. The hard part is picking a tokenizer and sticking with it.

Check: Why reimplement0/3
1. What is a classical metric?Define
2. Your ROUGE-L is about ten points below a paper's number on the same outputs. Which setup difference does the lesson name as a likely cause?Analyze
3. You publish a ROUGE-L and BLEU-4 result from this code. Which details should you record next to the numbers?Design

Tokenisation

The tokenizer is re.findall(r"\w+", text.lower()): lowercase the text, keep runs of alphanumeric characters, drop punctuation. F1, BLEU-4, and ROUGE-L share it, and the runner doesn't get a say. Exact match and accuracy skip it and compare stripped strings. Swap the tokenizer and you're running a different benchmark.

python
TOKEN_RE = re.compile(r"\w+", re.UNICODE)
def tokenize(text):
    return TOKEN_RE.findall(text.lower())

This is a deliberate simplification. Production setups have to deal with CJK, contractions, and code identifiers. What I want you to take from it is that the tokenizer is a contract, not a knob.

Check: Tokenisation0/2
1. What does the tokenizer contract require of F1, BLEU-4 and ROUGE-L?Define
2. F1, BLEU-4 and ROUGE-L all call the same tokenize function. Why does that matter when you compare their scores?Explain

Exact match

Exact match scores 1.0 when the prediction equals a target string after stripping whitespace, and 0.0 otherwise.

python
def exact_match(prediction, targets):
    if not targets:
        return 0.0
    pred = prediction.strip()
    return 1.0 if any(pred == t.strip() for t in targets) else 0.0

An empty target list scores 0.0. There is no lowercasing and no punctuation stripping, so "Paris." does not match "Paris".

The aggregate over a dataset is the mean of the per-task scores. It's the workhorse for arithmetic, MCQ, and short classification tasks.

Check: Exact match0/2
1. What does exact match return for a prediction and a list of targets?Define
2. A model answers "Paris." and the target is "Paris". Which result do you expect?Apply

Token-level F1

Token-level F1 is the harmonic mean of token precision and recall between a prediction and a target. Build a token multiset for each. Precision is the size of the multiset intersection divided by the size of the prediction's multiset, and recall is the same intersection divided by the size of the target's. The implementation also covers the empty-prediction and empty-target cases.

Mermaid diagram

For multi-target tasks, take the best F1 over the target list. That matches the SQuAD-style behaviour widely reported in the literature.

Check: Token-level F10/4
1. What is token-level F1?Define
2. In main.py, token-level F1 returns 1.0 in which case?Apply
3. What does score("f1", "the cat sat", ["a cat sat on the mat"]) return, rounded to three decimals?Apply
4. What do f1_score("cat cat cat", "cat") and f1_score("cat", "cat cat cat") return?Apply

BLEU-4

BLEU-4 scores a candidate by its modified n-gram precision for n = 1 through 4, combined by geometric mean and scaled by a brevity penalty. It's the canonical machine-translation metric and it still shows up in summarisation work. The version score("bleu_4", ...) dispatches to is sentence-level: bleu4(prediction, reference) scores one candidate against one reference, with the standard brevity penalty and additive-one smoothing on modified n-gram counts, so one missing 4-gram doesn't push the score to zero. When a task has several targets, score takes the max over them. main.py also has a corpus_bleu that pools clipped counts and lengths across a dataset before taking the geometric mean, but the dispatcher doesn't call it.

Modified precision clips each candidate n-gram count by the count of that n-gram in the reference (one reference per bleu4 call), so a candidate can't inflate its score by repeating one phrase. For each candidate-reference pair you compute that precision at n = 1, 2, 3, 4, take the geometric mean of the four, and wrap it in the brevity penalty.

Mermaid diagram

The smoothing is what Lin and Och called method 1: add one to both numerator and denominator of every n-gram precision before taking the log. That avoids log 0 when a reference has no matching 4-gram, and the result stays close to the unsmoothed value on long candidates.

Check: BLEU-40/9
1. What does modified n-gram precision do that plain n-gram precision does not?Define
2. What is BLEU-4?Define
3. What is the brevity penalty in BLEU?Define
4. What does the add-one smoothing in `bleu4` do before the logarithm?Define
5. Why does BLEU apply a brevity penalty?Explain
6. A candidate has 4 tokens and its reference has 6. What brevity penalty does main.py compute, to two decimals?Apply
7. Scoring "the the the the" against "the cat sat on the mat" with bleu_4 gives a low score. What is the main reason?Apply
8. bleu4("the cat sat", "the cat sat on the mat") is about 0.37 even though every candidate n-gram appears in the reference. Which ingredient explains the shortfall?Analyze
9. A report averages score("bleu_4", ...) over a dataset, while a colleague runs corpus_bleu on the same pairs. Why can the two numbers differ?Analyze

ROUGE-L

ROUGE-L scores the longest common subsequence (LCS) of the candidate and reference token sequences. An LCS keeps word order without requiring the matched words to sit next to each other, which is why ROUGE-L is the default summarisation metric. Compute the LCS length with a standard dynamic-programming table, then take recall as lcs / reference length and precision as lcs / candidate length. Combine them with F-beta, where beta = 1 gives the symmetric F1 form.

python
def lcs_length(a, b):
    n, m = len(a), len(b)
    if n == 0 or m == 0:
        return 0
    dp = np.zeros((n + 1, m + 1), dtype=np.int32)
    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 keeps the recurrence easy to read, and pure Python lists would work too. A task that opts into ROUGE-L pays O(n m) per prediction-target pair, so cost grows with the product of the two lengths.

Check: ROUGE-L0/7
1. What does ROUGE-L compare between candidate and reference?Define
2. What is a longest common subsequence of two token sequences?Define
3. Why does ROUGE-L use a longest common subsequence instead of requiring shared words to be adjacent?Explain
4. A prediction of n tokens is scored against a target of m tokens with ROUGE-L. How does the cost of the LCS table grow?Explain
5. Against the reference "a b c d", which pair of ROUGE-L scores does main.py give for the predictions "a b c" and "c b a"?Apply
6. What does lcs_length(["a", "b", "c"], ["c", "b", "a"]) return?Apply
7. You score multi-sentence summaries where word order matters but the matched words need not sit next to each other. Which metric fits?Design

Accuracy

Accuracy is the fraction of predictions that match their target. In main.py it is a thin wrapper: accuracy(prediction, targets) calls exact_match on the whole target list, so it inherits the same rule (stripped strings, no other normalisation, any target may match). It gets its own function so the dispatcher can branch on metric_name without string comparisons inside the runner.

Check: Accuracy0/1
1. How does `accuracy` relate to `exact_match` in main.py?Define

Dispatch contract

The single entry point is score(metric_name, prediction, targets), and it returns a float in [0, 1]. The runner doesn't branch on the metric name. It hands the call off and writes down the result. Lesson 75 glues this surface to the task spec from lesson 70.

python
def score(metric_name, pred, targets):
    if not targets:
        return 0.0
    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}")

The code_exec metric is handled in lesson 72 and slots into the dispatcher there.

Check: Dispatch contract0/5
1. What is the dispatch contract?Define
2. What would go wrong if the runner branched on metric_name itself instead of calling score?Explain
3. What does score("f1", "the cat", ["a horse", "the cat"]) return?Apply
4. A run stops with ValueError: unknown metric_name: rougeL. What does that tell you?Analyze
5. Your benchmark mixes multiple-choice tasks and summarisation tasks in one run. How should the runner score it?Design

What this lesson does not do

Here is what stays out: no model calls, no normalisation of generations beyond the post-process rules from lesson 70, no confidence intervals, and no BLEURT or BERTScore (those need a model and belong in a different lesson). What's left is the floor: five metrics, one tokenizer, one dispatcher.

Check: What this lesson does not do0/1
1. Why do BLEURT and BERTScore stay out of this lesson?Explain

Key Terms

TermWhat it means
Classical metricA deterministic function that scores a prediction against reference text by surface overlap, with no model in the loop
Tokenizer contractThe shared re.findall(r"\w+", text.lower()) rule used by F1, BLEU-4, and ROUGE-L; changing it changes the benchmark
Exact match1.0 when the stripped prediction equals a stripped target, 0.0 otherwise, with no lowercasing or punctuation handling
Token-level F1Harmonic mean of token precision and recall computed over token multisets; the best score over the target list is kept
Modified n-gram precisionCandidate n-gram counts clipped by their count in the reference, so repeating a phrase cannot inflate the score
BLEU-4Geometric mean of modified n-gram precisions for n = 1 through 4, scaled by a brevity penalty
Brevity penaltyThe BLEU factor that lowers the score of a candidate shorter than its reference
Additive-one smoothingAdding one to the numerator and denominator of each n-gram precision so a missing 4-gram does not give log 0
ROUGE-LF-beta combination of precision and recall based on the longest common subsequence of candidate and reference
Longest common subsequence (LCS)The longest run of tokens appearing in both sequences in the same order, not necessarily adjacent
AccuracyThe fraction of predictions that match their target; here a thin wrapper that calls exact_match
Dispatch contractThe single score(metric_name, prediction, targets) entry point that returns a float in [0, 1]
Author's Note

My bet is that setup you can't see in the score deserves as much scrutiny as the score itself. I'd write the tokenizer, the smoothing rule, and beta down next to every number I report, because the \w+ tokenizer and the add-one smoothing here are choices, and a different pair of choices gives a different benchmark.

I'd also keep an eye on where the tokenizer applies. exact_match compares stripped strings with ==, so a case or punctuation difference is a miss, while F1, BLEU-4, and ROUGE-L lowercase and drop punctuation first. The same prediction can score 0.0 on one metric and 1.0 on another, and that's worth knowing before you pick a metric for a task.

How to read the code

main.py defines each metric as a free function, plus the dispatcher. A METRIC_TABLE dict lists how each metric aggregates (scalar or per-target max), corpus_mean averages per-task scores, and corpus_bleu is the pooled variant. The reference vectors live in the _reference_examples() function near the bottom of the file. The demo runs the dispatcher against eight examples, prints per-metric scores, and finishes with a corpus_bleu run over two pairs. The tests in tests/test_metrics.py pin the reference vectors and cover the edge cases: empty prediction, empty reference, empty target list, no shared tokens, exact match, and repeated-phrase clipping.

Read main.py top to bottom, since the functions run from simple to complex. exact_match is four lines and accuracy is a one-line wrapper around it. f1_score is about fifteen lines. BLEU and ROUGE-L are the heavy parts. The file has no comments, so the smoothing rule lives in bleu4 (add one to numerator and denominator) and the LCS recurrence in lcs_length. score is a plain if-chain over metric_name, not a lookup table.

Check: How to read the code0/1
1. Which of these is an edge case that the tests in tests/test_metrics.py pin?Define

Going further

Classical metrics are necessary but not sufficient. They reward surface overlap and miss meaning. Once you trust the classical floor, layer model-based metrics (BLEURT, BERTScore, GEval) on top; that's a later lesson. For now, get these five working and pin them with tests. You'll have a metric stack you can audit, run fast, and reproduce.

Warm-up0/3
1. Each task record in lesson 70 carries a metric_name field. What does that buy a runner?Warm-up
2. One paper reports a system's accuracy as 91.2 and another reports 0.912 for the same system. What is the most likely explanation?Warm-up
3. In Python, what does re.findall(r"\w+", "Hi, there!") return?Warm-up

Code