Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°07

Token-Level F1

Medium AI EngineeringEvalsNLP

The metric behind QA evaluation (SQuAD and friends): how well does a predicted answer overlap a reference, as a bag of words? Compute token precision and recall, then combine them into the harmonic-mean F1 — the number that actually moves when your model gets better.

The problem

Given a prediction string and a reference string, return the token-level F1. Lowercase and split both on whitespace into tokens. Count the multiset overlap (shared tokens, respecting duplicates). Then precision = overlap / len(pred), recall = overlap / len(ref), and F1 = 2·P·R / (P + R). Return 0.0 if either side is empty or there is no overlap.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement token_f1(prediction, reference): lowercase + split both, count multiset token overlap, then return the harmonic mean of precision and recall (0.0 on empty / no overlap).

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input pred = "the cat sat", ref = "the cat sat"
Output 1.0
exact match
EXAMPLE 2
Input pred = "the cat", ref = "the cat sat"
Output 0.8
P=1.0, R=0.667 → F1=0.8
EXAMPLE 3
Input pred = "a b", ref = "c d"
Output 0.0
no shared tokens
EXAMPLE 4
Input pred = "the the", ref = "the"
Output 0.6666666666666666
edge case: the reference has one "the", so only one of the two predicted copies counts (P = 0.5, R = 1.0)
CONSTRAINTS
  • Case-insensitive; split on whitespace.
  • Overlap is a multiset intersection — "the the" vs "the" shares only one "the".
  • Return 0.0 when either string is empty or overlap is 0 (avoid dividing by zero).
Hints 4 ideas
HINTS — 4 IDEAS
  1. Lowercase both strings and split() on whitespace into token lists.
  2. For multiset overlap, count reference tokens, then for each prediction token consume one from that count if available.
  3. precision = overlap / len(pred); recall = overlap / len(ref).
  4. F1 = 2·P·R / (P + R). Guard the empty / zero-overlap cases by returning 0.0 first.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious way to count shared tokens is to copy the reference token list and, for each prediction token, find and remove a matching copy with list.remove. Removing what was matched makes it respect duplicates, so the count is right. But each search scans the list, so it costs O(n · m) time for n prediction and m reference tokens, with O(m) space for the copy. The faster-looking len(set(pred) & set(ref)) is O(n + m) and wrong: it counts each distinct token once, so "the the cat" against "the the dog" finds one shared token instead of two and scores 0.333 instead of 0.667.

The approach

Count the reference once, then let each prediction token spend from that budget. The reference token_f1 lowercases both strings and calls split(), which splits on runs of whitespace and drops empty strings, and returns 0.0 if either side has no tokens. It builds counts, the multiplicity of every reference token, then walks pred: whenever counts.get(t, 0) > 0, it adds 1 to overlap and decrements counts[t].

The invariant is that overlap equals the sum over tokens of the smaller of the two counts seen so far, and counts never goes negative, so a token repeated in the prediction can match only as many copies as the reference holds. With overlap known, precision = overlap / len(pred), recall = overlap / len(ref), and the return value is 2 * precision * recall / (precision + recall), which simplifies to 2 · overlap / (len(pred) + len(ref)). The TypeScript tokenF1 splits with /\s+/ and filters out empty strings to match Python’s split().

prediction: "the the cat"thethecat0 left: skipthe: 1→0cat: 1→0sat: 1counts from reference "the cat sat"overlap = 2precision = 2/3recall = 2/3F1 = 4/6 = 0.667counting "t in ref" instead: overlap 3, F1 = 1.0 (wrong)
The multiset budget on a prediction with a repeated word. The reference holds one "the", so the first prediction "the" spends it and the second finds nothing left. The reference returns 0.6666666666666666, and the shortcut 2 · overlap / (len(pred) + len(ref)) gives the same 4 / 6.

Walk through an example

The code’s own example, "the cat" against "the cat sat", gives perfect precision and two-thirds recall.

stepworkoverlapcounts after
1tokens: pred = ["the", "cat"], ref = ["the", "cat", "sat"]0—
2count the reference0{"the": 1, "cat": 1, "sat": 1}
3"the" has a count left: match it1{"the": 0, "cat": 1, "sat": 1}
4"cat" has a count left: match it2{"the": 0, "cat": 0, "sat": 1}
5precision = 2 / 2 = 1.0, recall = 2 / 3 = 0.66666666666666662—
62 * 1.0 * 0.6666666666666666 / 1.66666666666666652result 0.8

Complexity

Time O(n + m) for n prediction and m reference tokens, plus the linear cost of lowercasing and splitting the strings: one pass builds counts and one pass spends from it, with average-O(1) dictionary operations. Space O(n + m) for the token lists and counts. The remove-from-a-list version is O(n · m) time with O(m) space.

Edge cases to test

  • "the the" against "the" → 0.6666666666666666. The reference has one "the", so only one prediction token matches: precision 0.5, recall 1.0.
  • "the the cat" against "the the dog" → 0.6666666666666666. Both copies of "the" match; a set intersection counts one and returns 0.333.
  • "" or " " against "the cat" → 0.0. split() yields no tokens, and the guard returns before any division.
  • "The CAT" against "the cat" → 1.0, and "cat the" against "the cat" → 1.0. Case is folded and order is ignored: the tokens are a bag.
  • "cat." against "cat" → 0.0. Punctuation stays attached to the token here; the official SQuAD script strips punctuation before comparing.
  • "not guilty" against "guilty" → 0.6666666666666666. Opposite meaning, decent score: token overlap knows nothing about negation.

Common mistakes

  • Requiring exact-string match instead of token overlap — far too strict for free-form generation.
  • Ignoring multiplicity (counting a token once when it appears several times).
  • Dividing by zero when the prediction or reference is empty — guard those cases.

Follow-ups an interviewer asks next

Match the official SQuAD evaluation script.
Normalize before tokenizing: lowercase, strip punctuation, remove the articles a, an and the, and collapse whitespace, then compute the same F1. Normalization changes scores: "the cat" against "the cat sat" becomes "cat" against "cat sat", which scores 0.667 instead of 0.8. Report which normalization you used, or numbers are not comparable.
Each question has several acceptable reference answers.
Score the prediction against every reference and take the maximum, which is what SQuAD does. Averaging would penalize a correct answer for not also matching the other phrasings. The dataset score is then the mean of those per-question maxima, so every question counts equally however long its answer is.
When is token F1 the wrong metric?
When meaning depends on more than shared words. It ignores order and negation, so "not guilty" scores 0.667 against "guilty", and a correct paraphrase with no words in common scores 0. Report exact match alongside it for short factual answers, and for free-form generation use embedding similarity or a calibrated LLM judge, spot-checked by people.
Write the overlap count in one line.
In Python, collections.Counter supports multiset intersection: sum((Counter(pred) & Counter(ref)).values()) keeps, for each token, the smaller of its two counts. That is exactly the count the reference reaches by decrementing, so the F1 is unchanged, and the whole function stays O(len(pred) + len(ref)). For "the cat sat on the mat" against "the cat" the intersection holds one the and one cat, an overlap of 2.

Where this shows up

Token-F1 is a standard eval metric for question answering and generation (the SQuAD-style overlap score) — a pragmatic middle ground between brittle exact-match and expensive LLM-as-judge. Knowing how it is computed is part of the scarce, in-demand skill of actually measuring AI quality.

Its relatives. Token F1 is a unigram overlap score, and the same counting-with-a-budget idea is the core of the older text-generation metrics. BLEU, from machine translation, is built on precision: it clips each n-gram’s count by its count in the references, exactly the decrement here, for n-grams up to length 4, and adds a brevity penalty so that very short outputs cannot win on precision alone. ROUGE, from summarization, is built on recall of the reference’s n-grams. Moving from single tokens to bigrams is how these metrics start to notice word order, which token F1 ignores completely.

Empty answers are a policy decision. This reference returns 0.0 whenever either side has no tokens, including when both are empty. Benchmarks with unanswerable questions, where the correct output is “no answer”, need the opposite for that case: the official SQuAD 2.0 evaluation scores a pair as 1 if both the prediction and the reference are empty and 0 if only one is. If your dataset includes questions with no answer, this one guard decides whether a model is rewarded or punished for correctly declining to answer.

Use it with care in LLM evaluations. Chat models answer in full sentences, so against a short reference such as "Paris" a correct answer like "The capital is Paris." loses heavily on precision, and its trailing period keeps "paris." from matching at all without the SQuAD normalization. Teams therefore either prompt for short answers, extract the answer span before scoring, or report recall alongside F1, since recall asks only whether the reference tokens appear in the answer.

Check yourself

Predict token_f1("New York City", "york new").

0.8. Case is folded and order is ignored, so new and york both match: overlap 2, precision 2/3, recall 2/2, and 2 · 2 / (3 + 2) = 0.8. The extra city costs only precision.

What does token_f1("yes yes yes", "yes") return?

0.5. The reference budget holds one yes, so overlap is 1: precision 1/3, recall 1, and 2 · 1 / (3 + 1) = 0.5. Repeating a correct word does not earn more credit; it dilutes precision.

A variant counts overlap as “prediction tokens that appear anywhere in the reference”. What does it return on "yes yes yes" against "yes", and why is that a giveaway?

1.5. All three tokens count, so “recall” is 3/1 = 3 and the harmonic mean of 1 and 3 is 1.5, a score above 1. Without the decrement, overlap can exceed the reference length; the count budget is what bounds it by the smaller side.

Is token_f1(a, b) always equal to token_f1(b, a)?

Yes. The overlap, the sum over tokens of the smaller count, does not depend on which side is which, and F1 equals 2 · overlap / (len(pred) + len(ref)), which is symmetric. Swapping the arguments swaps precision and recall, but their harmonic mean is unchanged: "the cat sat" against "the the cat" also gives 0.667.

What does token_f1("the cat sat on the mat", "the cat") return?

0.5. The overlap is 2, so precision is 2/6 and recall is 2/2 = 1.0, and 2 × (1/3) × 1 / (1/3 + 1) = 0.5. Every correct word is there, but the four extra words halve the score. This is how token F1 punishes a long answer that wraps a short correct one.

Reference solution

Reference solution — Python
def token_f1(prediction, reference):
    pred = prediction.lower().split()
    ref = reference.lower().split()
    if not pred or not ref:
        return 0.0

    # multiset overlap: consume each matched reference token once
    counts = {}
    for t in ref:
        counts[t] = counts.get(t, 0) + 1
    overlap = 0
    for t in pred:
        if counts.get(t, 0) > 0:
            overlap += 1
            counts[t] -= 1
    if overlap == 0:
        return 0.0

    precision = overlap / len(pred)
    recall = overlap / len(ref)
    return 2 * precision * recall / (precision + recall)


print(token_f1("the cat", "the cat sat"))   # -> 0.8
Reference solution — TypeScript
function tokenF1(prediction: string, reference: string): number {
  const pred = prediction.toLowerCase().split(/\s+/).filter(Boolean);
  const ref = reference.toLowerCase().split(/\s+/).filter(Boolean);
  if (pred.length === 0 || ref.length === 0) return 0.0;

  // multiset overlap: consume each matched reference token once
  const counts = new Map<string, number>();
  for (const t of ref) counts.set(t, (counts.get(t) || 0) + 1);
  let overlap = 0;
  for (const t of pred) {
    if ((counts.get(t) || 0) > 0) { overlap++; counts.set(t, counts.get(t)! - 1); }
  }
  if (overlap === 0) return 0.0;

  const precision = overlap / pred.length;
  const recall = overlap / ref.length;
  return (2 * precision * recall) / (precision + recall);
}

console.log(tokenF1("the cat", "the cat sat"));   // -> 0.8

Questions about Token-Level F1

How is token-level F1 calculated?

Lowercase the prediction and the reference and split each into tokens. Count the overlap as a multiset intersection: each shared token counts as many times as it appears in both, up to the smaller count. Precision is the overlap divided by the number of prediction tokens, recall is the overlap divided by the number of reference tokens, and F1 is their harmonic mean, two times precision times recall over their sum. That simplifies to twice the overlap divided by the total number of tokens on both sides. For the cat against the cat sat the overlap is 2, so F1 is 4 over 5, which is 0.8.

Why use token F1 instead of exact match for question answering?

Because exact match gives no credit for answers that are almost right. If the reference is the cat sat and a model answers the cat, exact match scores 0, the same as a completely wrong answer, while token F1 scores 0.8. That partial credit makes F1 move smoothly as a model improves, which is useful during development. The two are usually reported together: exact match says how often the answer is word-for-word right, and F1 says how close the rest come. Neither understands meaning, so a paraphrase with different words still scores poorly.

How does token F1 handle repeated words?

It counts them as a multiset, so a repeated word can match only as many copies as the other side has. For the the against the, only one the matches, so precision is 0.5, recall is 1.0 and F1 is 0.667. Using a set instead would collapse repeats on both sides: for the the cat against the the dog, a set finds one shared token where a multiset finds two, and the score drops from 0.667 to 0.333. Counting the reference once and decrementing a count per match is the simplest correct way to get this right.

Why does F1 use the harmonic mean of precision and recall?

Because the harmonic mean is pulled toward the smaller of the two, so a prediction cannot score well by being strong on one and weak on the other. A one-word answer that appears in a ten-word reference has precision 1.0 and recall 0.1: the ordinary average would be 0.55, while F1 is about 0.18. Likewise, pasting a whole paragraph as the answer gets high recall and low precision. F1 is high only when the prediction is both mostly correct and mostly complete, which is the behavior an answer metric should reward.

Finished this one? 0 / 76 Challenges done
NextThe Loop Engineering Handbook Next in “Become an AI Engineer” · step 15 of 19

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.