Exact Match and F1 › Code Implementation

Michael BrenndoerferFebruary 25, 20269 min read
Language AI HandbookAll chapters
Part 53 of 67Evaluation Fundamentals
Chapter 410Exact Match and F1 › Code Implementation
Reading section 7 of 13 · Code Implementation · 9 minSection 7 of 13 · 9 min

Book contents

Section 7 of 13 · 9 min read

Exact Match and F1 › Code ImplementationOne section · Sections in this chapter7 / 13 ↓
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's implement Exact Match and F1 evaluation with various normalization strategies to see these metrics in practice.

In[4]:
Code
import re
import string
from typing import List, Union


def normalize_answer(
    text: str,
    lowercase: bool = True,
    remove_punctuation: bool = True,
    remove_articles: bool = True,
    fix_whitespace: bool = True,
) -> str:
    """Apply normalization transforms to text."""
    if lowercase:
        text = text.lower()

    if remove_punctuation:
        # Remove all punctuation characters
        text = text.translate(str.maketrans("", "", string.punctuation))

    if remove_articles:
        # Remove leading articles
        text = re.sub(r"^(a|an|the)\s+", "", text, flags=re.IGNORECASE)

    if fix_whitespace:
        # Normalize whitespace
        text = " ".join(text.split())

    return text


def exact_match_score(
    prediction: str, references: Union[str, List[str]], normalize: bool = False
) -> float:
    """
    Calculate Exact Match score.
    Returns 1.0 if prediction matches any reference, 0.0 otherwise.
    """
    if isinstance(references, str):
        references = [references]

    if normalize:
        prediction = normalize_answer(prediction)
        references = [normalize_answer(ref) for ref in references]

    return float(any(prediction == ref for ref in references))


def f1_score(
    prediction: str, references: Union[str, List[str]], normalize: bool = False
) -> float:
    """
    Calculate token-level F1 score.
    Uses whitespace tokenization.
    """
    if isinstance(references, str):
        references = [references]

    if normalize:
        prediction = normalize_answer(prediction)
        references = [normalize_answer(ref) for ref in references]

    # Tokenize by whitespace
    pred_tokens = set(prediction.split())

    best_f1 = 0.0

    for ref in references:
        ref_tokens = set(ref.split())

        # Calculate overlap
        common_tokens = pred_tokens & ref_tokens

        if len(common_tokens) == 0:
            continue

        precision = len(common_tokens) / len(pred_tokens) if pred_tokens else 0
        recall = len(common_tokens) / len(ref_tokens) if ref_tokens else 0

        if precision + recall > 0:
            f1 = 2 * (precision * recall) / (precision + recall)
            best_f1 = max(best_f1, f1)

    return best_f1

The core logic of f1_score is worth reading carefully. The function converts each answer to a set of tokens and computes the intersection. Using a set rather than a list means that duplicate tokens are collapsed: if the prediction contains "Bell Bell", it still counts as one occurrence of "Bell". This set-based approach matches the SQuAD evaluation convention. An alternative multiset approach, which counts token frequencies, would give different results for repetitive answers and is not standard practice for QA evaluation.

Out[5]:
Console
Prediction: 'The Eiffel Tower'
References: ['Eiffel Tower', 'the Eiffel Tower']

Without normalization:
  Exact Match: 0.0
  F1 Score: 0.800

With normalization:
  Exact Match: 1.0
  F1 Score: 1.000

The normalization process bridges the gap between strict string matching and semantic equivalence. In this example, the article "The" prevents a raw Exact Match, but normalized evaluation recognizes the equivalence. This shows why standard benchmarks like SQuAD apply normalization: without it, models would be penalized for trivial formatting differences that do not reflect comprehension failures.

Notice that raw F1 is already 0.667 in this case, not 0.0. Because "Eiffel" and "Tower" are shared tokens even without normalization, F1 gives partial credit before normalization takes effect. This illustrates the inherent tolerance of F1 compared to EM: even without any normalization pipeline, F1 rewards token-level overlap.

In[6]:
Code
# More complex examples showing partial matching
examples = [
    {
        "prediction": "Alexander Bell",
        "references": ["Alexander Graham Bell"],
        "description": "Missing middle name",
    },
    {
        "prediction": "Sir Alexander Graham Bell",
        "references": ["Alexander Graham Bell"],
        "description": "Extra title (hallucination)",
    },
    {
        "prediction": "Paris, France",
        "references": ["Paris"],
        "description": "Extra location specifier",
    },
    {
        "prediction": "the French capital city",
        "references": ["Paris"],
        "description": "Semantic equivalent, no lexical overlap",
    },
]
Out[7]:
Console
Description                         Prediction                     EM       F1      
-------------------------------------------------------------------------------------
Missing middle name                 Alexander Bell                 0.0      0.800
Extra title (hallucination)         Sir Alexander Graham Bell      0.0      0.857
Extra location specifier            Paris, France                  0.0      0.667
Semantic equivalent, no lexical overlap the French capital city        0.0      0.000

Notice how F1 gracefully handles partial overlaps (missing middle names, extra specifiers) while Exact Match remains binary. The scores decrease gradually with the severity of the deviation. This gives a more fine-grained signal than the all-or-nothing Exact Match approach.

The final example shows a necessary limitation: when the prediction is semantically equivalent but lexically distinct ("the French capital city" versus "Paris"), both EM and F1 fail completely, scoring 0.0. This gap motivates the use of BERTScore or semantic similarity metrics for open-ended generation, which we covered earlier in this part. The inability to recognize paraphrases or descriptions is a basic boundary of lexical metrics: they can only judge what is said, not what is meant.

In[8]:
Code
# Implementing SQuAD-style evaluation with multiple references
def squad_evaluate(predictions: dict, references: dict) -> dict:
    """
    SQuAD-style evaluation letting multiple valid answers per question.

    Args:
        predictions: dict mapping question_id -> predicted_answer
        references: dict mapping question_id -> list of valid answers
    """
    total = len(predictions)
    exact_match = 0
    f1_total = 0.0

    for qid, pred in predictions.items():
        refs = references[qid]

        if exact_match_score(pred, refs, normalize=True):
            exact_match += 1

        f1_total += f1_score(pred, refs, normalize=True)

    return {
        "exact_match": 100.0 * exact_match / total,
        "f1": 100.0 * f1_total / total,
    }
Out[9]:
Console
SQuAD-style Evaluation Results:
  Exact Match: 66.7%
  F1 Score: 88.9%

This implementation shows the "match-any" protocol used in standard benchmarks. The model receives full credit for "1845" despite three valid variants existing in the reference set, illustrating how benchmark design choices affect reported performance. The flexibility of multiple references accommodates natural variation in how humans express the same information, whether as bare numbers ("1845"), prepositional phrases ("in 1845"), or temporal adverbials ("during 1845").

For "Paris, France" evaluated against the reference "Paris", the normalized evaluation strips punctuation, giving "paris france" versus "paris". The intersection contains "paris" only, so precision is 1/2 = 0.5 and recall is 1/1 = 1.0, yielding F1 = 0.667. This is an informative score: the model found the right city but added an unnecessary qualifier, which is a mild error that deserves partial rather than zero credit.

Out[10]:
Visualization
Grouped bar chart comparing Exact Match and F1 scores for six telephone inventor predictions.
Comparison of Exact Match and F1 scores across the worked examples from the 'Who invented the telephone?' scenario. Exact Match gives a binary signal of 0 or 1 for each prediction, while F1 varies continuously and captures partial credit for near-miss answers. The case-mismatch and extra-title predictions illustrate how EM and F1 can diverge materially for the same prediction.
Out[11]:
Visualization
Grouped bar chart showing raw and normalized EM and F1 scores for article, case, punctuation, and whitespace variation examples.
Impact of text normalization on evaluation scores across four surface-form variation types. Raw matching penalizes formatting differences such as leading articles, case, punctuation, and whitespace, while normalized matching raises scores toward 1.0 for all four variation types. The article case is most large: raw EM is 0 but normalized EM is 1, while raw F1 is already 0.667 due to partial token overlap.