Solving tips
- Normalize both strings the same way before you compare anything. Exact match and F1 that disagree on casing or punctuation are measuring two different things and neither is the one you meant.
- Token F1 counts shared tokens with multiplicity, not set overlap. Use a multiset intersection (min of per-token counts) so a gold answer that says 'the the' is not fully matched by a single 'the'.
- Nail the empty-string cases explicitly. When a predicted or gold answer normalizes to no tokens, F1 is 1.0 only if both are empty and 0.0 otherwise — don't divide by zero into a silent NaN.
Once an agent or a RAG pipeline produces an answer, you need a number that says how good it was. Two metrics do most of the work in open-domain QA: exact match, which is strict and unforgiving, and token-level F1, which gives partial credit for overlapping words. Both live or die on normalization, because “The Eiffel Tower.” and “eiffel tower” are the same answer and a raw string compare would call them different. This exercise implements both from scratch, the same way the SQuAD evaluation script does.
Why normalize first
A predicted answer and a gold answer rarely match byte for byte even when they mean the same thing. Casing differs, trailing punctuation sneaks in, and filler articles (“a”, “an”, “the”) pad the text without adding meaning. Before comparing, both strings are lowercased, stripped of punctuation, cleared of articles, and squeezed to single-spaced tokens. Every metric here runs on that normalized form, so the same helper feeds both exact match and F1.
The two metrics
- Exact match is a hard 0.0 or 1.0: after normalization, do the two strings match exactly? It is the metric that rewards getting the whole answer right and nothing partial.
- Token F1 grades on overlap. Split both normalized answers into tokens, count how many tokens they share (with multiplicity — a token appearing twice in each counts twice), and combine precision (shared over predicted length) and recall (shared over gold length) into their harmonic mean. This gives partial credit when the prediction has the right words buried in extra ones, or covers only part of the gold answer.
Task
Complete qa_metrics(pred, gold):
- Normalize both strings: lowercase, remove punctuation, drop the articles
a/an/the, and collapse whitespace. - Compute
exact_matchas1.0if the normalized strings are identical, else0.0. - Tokenize each normalized string on whitespace. Compute the multiset intersection count
shared(sum ofmin(pred_count, gold_count)over all tokens). - Compute
precision = shared / len(pred_tokens),recall = shared / len(gold_tokens), andf1as their harmonic mean. - Handle the empty case: if either token list is empty, all three of precision, recall, and f1 are
1.0when both are empty and0.0otherwise. - Return the dict
{"exact_match", "precision", "recall", "f1"}with float values.
Example
m = qa_metrics("The Eiffel Tower.", "eiffel tower")
m["exact_match"] # -> 1.0 (both normalize to "eiffel tower")
round(m["f1"], 3) # -> 1.0
m = qa_metrics("Paris, France", "Paris")
m["exact_match"] # -> 0.0 ("paris france" != "paris")
round(m["precision"], 3) # -> 0.5 (1 shared / 2 predicted tokens)
round(m["recall"], 3) # -> 1.0 (1 shared / 1 gold token)
round(m["f1"], 3) # -> 0.667
m = qa_metrics("no", "")
m["exact_match"] # -> 0.0
m["f1"] # -> 0.0 (gold empty, pred not)
Constraints
- Do not use any external metric library (no sklearn, no
evaluate, no NLTK). Standard library only. - Punctuation is every character in
string.punctuation; removing it must not merge or split words unexpectedly (strip it, leaving whitespace to separate tokens). - Article removal is token-level: drop standalone
a,an,the, not those letters inside other words. - F1 uses multiset (count-aware) overlap, not set overlap.
- All four returned values are floats.