BLEU Score › Code Implementation

Michael BrenndoerferFebruary 22, 20267 min read
Language AI HandbookAll chapters
Part 53 of 67Evaluation Fundamentals
Chapter 407BLEU Score › Code Implementation
Reading section 8 of 15 · Code Implementation · 7 minSection 8 of 15 · 7 min

Book contents

Section 8 of 15 · 7 min read

BLEU Score › Code ImplementationOne section · Sections in this chapter8 / 15 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let us implement BLEU from scratch to understand the mechanics, then compare with standard libraries. We will calculate both sentence-level and corpus-level scores. Implementing the metric manually illuminates the importance of edge cases such as empty strings, varying reference counts, and the numerical stability of logarithmic calculations.

First, we need utilities for extracting n-grams and computing clipped counts.

In[5]:
Code
from collections import Counter
from typing import List, Tuple


def get_ngrams(tokens: List[str], n: int) -> List[Tuple[str, ...]]:
    """Extract n-grams from a list of tokens."""
    return [tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)]


def clipped_count(
    candidate_ngrams: Counter, reference_ngrams_list: List[Counter]
) -> int:
    """Sum of clipped counts for candidate n-grams against multiple references."""
    total = 0
    for ngram, count in candidate_ngrams.items():
        # Max count of this n-gram in any single reference
        max_ref_count = max(ref.get(ngram, 0) for ref in reference_ngrams_list)
        total += min(count, max_ref_count)
    return total


def modified_precision(
    candidate: List[str], references: List[List[str]], n: int
) -> float:
    """Calculate modified n-gram precision."""
    candidate_ngrams = Counter(get_ngrams(candidate, n))
    if not candidate_ngrams:
        return 0.0

    reference_counters = [Counter(get_ngrams(ref, n)) for ref in references]

    clipped = clipped_count(candidate_ngrams, reference_counters)
    total = sum(candidate_ngrams.values())

    return clipped / total if total > 0 else 0.0

Now we implement the brevity penalty and the full BLEU calculation.

In[6]:
Code
import math


def brevity_penalty(candidate_len: int, reference_len: int) -> float:
    """Calculate BP with effective reference length."""
    if candidate_len > reference_len:
        return 1.0
    else:
        return math.exp(1 - reference_len / candidate_len)


def closest_reference_length(
    candidate: List[str], references: List[List[str]]
) -> int:
    """Find the reference length closest to candidate length."""
    c_len = len(candidate)
    ref_lengths = [len(ref) for ref in references]

    # Find reference with length closest to candidate
    closest = min(ref_lengths, key=lambda r: abs(r - c_len))
    return closest


def sentence_bleu(
    candidate: List[str],
    references: List[List[str]],
    weights: List[float] = None,
    smoothing: float = 1e-10,
) -> float:
    """Calculate sentence-level BLEU."""
    if weights is None:
        weights = [0.25, 0.25, 0.25, 0.25]  # Uniform 1-4 gram weights

    max_n = len(weights)

    # Calculate modified precisions for each n-gram order
    precisions = []
    for n in range(1, max_n + 1):
        p = modified_precision(candidate, references, n)
        # Smoothing: avoid log(0)
        if p == 0:
            p = smoothing
        precisions.append(p)

    # Geometric mean of precisions
    log_sum = sum(w * math.log(p) for w, p in zip(weights, precisions))
    geo_mean = math.exp(log_sum)

    # Brevity penalty
    c_len = len(candidate)
    r_len = closest_reference_length(candidate, references)
    bp = brevity_penalty(c_len, r_len)

    return bp * geo_mean

Let us test this with our earlier examples.

In[7]:
Code
# Example 1: Perfect match
candidate1 = ["the", "cat", "sat", "on", "the", "mat"]
reference1 = ["the", "cat", "sat", "on", "the", "mat"]
reference2 = ["the", "cat", "was", "sitting", "on", "the", "mat"]

score1 = sentence_bleu(candidate1, [reference1, reference2])

# Example 2: Missing word
candidate2 = ["the", "cat", "sat", "on", "mat"]
score2 = sentence_bleu(candidate2, [reference1, reference2])

# Example 3: Word substitution (synonym)
candidate3 = ["the", "feline", "sat", "on", "the", "mat"]
score3 = sentence_bleu(candidate3, [reference1, reference2])

# Example 4: Adversarial repetition
candidate4 = ["the", "the", "the", "the", "the", "the"]
score4 = sentence_bleu(candidate4, [reference1, reference2])
Out[8]:
Console
Perfect match BLEU:          1.0000
Missing 'the' BLEU:          0.5789
Synonym 'feline' BLEU:       0.5373
Adversarial repetition BLEU: 0.0000

The perfect match achieves a score near 1.0, while the missing word and synonym substitutions cause significant drops. Notice that "feline" receives a harsh penalty despite being semantically equivalent to "cat". This highlights a fundamental limitation we will discuss shortly: BLEU operates on string matching, not semantic equivalence, making it blind to paraphrase and synonymy. The adversarial repetition example shows the clipping mechanism at work: despite repeating the most common word, the score is very low because the clipped count for "the" is capped at its maximum reference occurrence.

For corpus-level BLEU, we aggregate counts across all sentences before calculating precisions. This avoids the volatility of sentence-level scoring and provides the stability necessary for reliable system comparison.

In[9]:
Code
def corpus_bleu(
    candidates: List[List[str]],
    references_list: List[List[List[str]]],
    weights: List[float] = None,
) -> float:
    """Calculate corpus-level BLEU by aggregating counts."""
    if weights is None:
        weights = [0.25, 0.25, 0.25, 0.25]

    max_n = len(weights)
    total_candidate_len = 0
    total_reference_len = 0

    # Aggregate counts for each n-gram order
    clipped_counts = [0] * max_n
    total_counts = [0] * max_n

    for candidate, references in zip(candidates, references_list):
        total_candidate_len += len(candidate)
        total_reference_len += closest_reference_length(candidate, references)

        for n in range(1, max_n + 1):
            cand_ngrams = Counter(get_ngrams(candidate, n))
            ref_counters = [Counter(get_ngrams(ref, n)) for ref in references]

            clipped_counts[n - 1] += clipped_count(cand_ngrams, ref_counters)
            total_counts[n - 1] += sum(cand_ngrams.values())

    # Calculate precisions
    precisions = []
    for clipped, total in zip(clipped_counts, total_counts):
        if total == 0:
            precisions.append(0.0)
        else:
            precisions.append(clipped / total)

    # Geometric mean
    log_sum = sum(
        w * math.log(p) if p > 0 else float("-inf")
        for w, p in zip(weights, precisions)
    )
    if log_sum == float("-inf"):
        geo_mean = 0.0
    else:
        geo_mean = math.exp(log_sum)

    # Brevity penalty on corpus lengths
    bp = brevity_penalty(total_candidate_len, total_reference_len)

    return bp * geo_mean

Now we compare sentence-level versus corpus-level scoring on a mini test set.

In[10]:
Code
# Mini corpus with two sentences
candidates = [
    ["the", "cat", "sat", "on", "the", "mat"],
    ["a", "dog", "ran", "quickly"],
]

refs = [
    [
        ["the", "cat", "sat", "on", "the", "mat"],
        ["the", "cat", "was", "on", "the", "mat"],
    ],
    [["a", "dog", "ran", "fast"], ["the", "dog", "ran", "quickly"]],
]

# Sentence-level average
sent_scores = [sentence_bleu(c, r) for c, r in zip(candidates, refs)]
avg_sent = sum(sent_scores) / len(sent_scores)

# Corpus-level
corp_score = corpus_bleu(candidates, refs)
Out[11]:
Console
Sentence-level average: 0.5016
Corpus-level BLEU: 0.9306
Individual sentence scores: ['1.0000', '0.0032']
Out[12]:
Visualization
Bar chart comparing BLEU score values for Sentence 1, Sentence 2, the sentence-level average, and the corpus-level BLEU score.
Comparison of sentence-level BLEU scores versus corpus-level aggregation on a two-sentence test set. Corpus-level BLEU produces a more stable estimate by pooling n-gram statistics before computing precision, which avoids the zero-score problem that affects individual short sentences. The individual sentence scores show the variance inherent in sentence-level evaluation.

Corpus-level BLEU typically differs from the average of sentence-level scores because it aggregates statistics globally. Notice how the second sentence, with its synonym differences ("quickly" vs "fast"), drags down the average more than it might affect corpus-level scores where other matches compensate. This mathematical property makes corpus-level evaluation the gold standard for research publications and system comparisons.

In practice, you should use established libraries like sacrebleu or nltk rather than implementing BLEU yourself, as they handle edge cases (empty strings, smoothing, multiple references) robustly and follow standardized preprocessing conventions that ensure comparability across studies.

In[13]:
Code
import subprocess

# Install sacrebleu for comparison (using uv since pip is not available in venv)
subprocess.check_call(["uv", "pip", "install", "sacrebleu", "-q"])

import sacrebleu

# Ensure required imports are available for the code block

# Prepare strings for sacrebleu (expects detokenized strings)
candidate_strs = ["the cat sat on the mat", "a dog ran quickly"]
reference_strs = [
    ["the cat sat on the mat", "the cat was on the mat"],  # refs for sentence 1
    ["a dog ran fast", "the dog ran quickly"],  # refs for sentence 2
]

# sacrebleu expects references as list of lists (transposed)
refs_formatted = [
    [ref[i] for ref in reference_strs] for i in range(len(reference_strs[0]))
]

bleu = sacrebleu.corpus_bleu(candidate_strs, refs_formatted)
Out[14]:
Console
sacrebleu corpus BLEU: 93.06
Our implementation:    93.06

The sacrebleu score matches our manual corpus calculation when both use the same tokenization and case normalization. Note that sacrebleu reports scores on a 0-100 scale (multiply by 100) by convention, a tradition inherited from earlier machine translation competitions where integer scores were preferred for leaderboard display.