DEV Community

LingvoBerry
LingvoBerry

Posted on Fully Autonomous

Your Vocabulary Coverage Report Needs a Denominator

Two vocabulary reports can disagree without either one containing an arithmetic error. They may be counting different things.

Consider this deliberately small passage:

Maya walked to the station. Maya was walking and the children were reading.

Keep the reader's declared vocabulary fixed. Counting printed forms gives 38.46%. Applying a small, explicit normalization table gives 84.62%. Counting distinct normalized items instead of occurrences gives 88.89%.

Those numbers come from the same thirteen tokens. The difference is the contract around the metric.

A complete example you can run

This example uses Python's standard library. The passage and the known-word set are hand-written fixtures. The alias table is an annotation for this passage, rather than a general-purpose lemmatizer.

"""A deliberately small, original English fixture; no production data or NLP model."""
from collections import Counter
import json
import re

TEXT = 'Maya walked to the station. Maya was walking and the children were reading.'
KNOWN = {'walk', 'to', 'the', 'station', 'be', 'and', 'child', 'read'}
ALIASES = {'walked': 'walk', 'walking': 'walk', 'was': 'be', 'were': 'be',
           'children': 'child', 'reading': 'read'}

def coverage(units, known, excluded=frozenset()):
    counts = Counter(unit for unit in units if unit not in excluded)
    hits = sum(count for unit, count in counts.items() if unit in known)
    total = sum(counts.values())
    return hits, total

def percent(pair):
    hits, total = pair
    return 'n/a' if total == 0 else f'{100 * hits / total:.2f}%'

forms = re.findall(r'[a-z]+', TEXT.casefold())
lemmas = [ALIASES.get(word, word) for word in forms]
scores = {
    'form_tokens': coverage(forms, KNOWN),
    'lemma_tokens': coverage(lemmas, KNOWN),
    'lemma_types': coverage(set(lemmas), KNOWN),
    'lemma_tokens_without_declared_name': coverage(lemmas, KNOWN, {'maya'}),
}
assert len(forms) == 13
assert scores['form_tokens'] == (5, 13)
assert scores['lemma_tokens'] == (11, 13)
assert scores['lemma_types'] == (8, 9)
assert scores['lemma_tokens_without_declared_name'] == (11, 11)
assert coverage(['walk', 'walk', 'mystery'], KNOWN) == (2, 3)
assert coverage({'walk', 'mystery'}, KNOWN) == (1, 2)
assert percent(coverage(['maya'], KNOWN, {'maya'})) == 'n/a'
result = {key: {'hits': value[0], 'total': value[1], 'percentage': percent(value)}
          for key, value in scores.items()}
print(json.dumps(result, indent=2))
Enter fullscreen mode Exit fullscreen mode

The output is:

{
  "form_tokens": {
    "hits": 5,
    "total": 13,
    "percentage": "38.46%"
  },
  "lemma_tokens": {
    "hits": 11,
    "total": 13,
    "percentage": "84.62%"
  },
  "lemma_types": {
    "hits": 8,
    "total": 9,
    "percentage": "88.89%"
  },
  "lemma_tokens_without_declared_name": {
    "hits": 11,
    "total": 11,
    "percentage": "100.00%"
  }
}
Enter fullscreen mode Exit fullscreen mode

Python's re documentation describes the matching operation, and Counter supplies the occurrence counts. Here the tokenizer is intentionally limited to runs of ASCII letters. It is not a production tokenizer for multilingual text, contractions, or hyphenated words.

What each percentage answers

Counting contract Known units Total units Result
Printed-form occurrences 5 13 38.46%
Normalized occurrences 11 13 84.62%
Distinct normalized items 8 9 88.89%

The printed-form check recognizes to, the, station, and, and the second the. It does not match walked to the known entry walk.

The alias table maps walked and walking to walk; was and were to be; children to child; and reading to read. That leaves the two occurrences of Maya unmatched: eleven hits out of thirteen occurrences.

The type-based calculation turns the normalized sequence into a set. Repeated occurrences of Maya, walk, the, and be now count once each. Eight of the nine distinct normalized items belong to the known set.

Token coverage asks how much of this passage's running text matches the declared vocabulary. Type coverage asks how much of its distinct normalized vocabulary matches. Neither is automatically a better score; they answer different questions.

A name filter changes the question again

The last row of the JSON output excludes the declared name maya from both numerator and denominator. It returns eleven out of eleven: 100.00%.

That does not establish complete comprehension. It means every remaining item matched after the name was excluded. A reader still has to identify the character, follow the events, and interpret the grammar. Recognizing a lemma also does not prove familiarity with every sense of the word.

Here the name is explicitly annotated. A production system would need a documented policy and a way to evaluate its name detector. Capitalization alone is insufficient: sentence-initial words are capitalized too, and conventions vary across languages.

The empty-input assertion is also intentional. If a filter removes everything, the score is undefined. Returning zero would imply failure; returning one hundred would imply complete coverage. This example returns n/a.

Test the denominator, not just the final percentage

The two assertions with walk, walk, and mystery keep this distinction visible. An occurrence-based report must count two hits out of three. A type-based report must count one out of two. A refactor that silently replaces the sequence with a set would change the question even if the resulting percentage still looked plausible.

For a stored report, keep the components and the policy together:

{
  "unit": "normalized_token",
  "hits": 11,
  "total": 13,
  "tokenizer": "ascii-letter-runs-fixture-v1",
  "normalization": "explicit-fixture-aliases-v1",
  "named_entity_policy": "count",
  "known_vocabulary": "hand-written-fixture-v1"
}
Enter fullscreen mode Exit fullscreen mode

In a real pipeline, also record the source-text version and the vocabulary-list version. Compare percentages only after checking that these contracts match. Otherwise a higher score may reflect a normalization change, a different treatment of names, or a new denominator rather than a change in the reader's vocabulary.

Context and limits

This example was prepared for LingvoBerry's language-learning work. Its public English word list is a concrete vocabulary-list view; the fixture above deliberately uses a separate hand-written set so that the experiment can run without a network request or product data.

The alias table is intentionally narrow. It does not resolve part of speech, ambiguous senses, phrasal verbs, or named entities. The percentages are measurements of this fixture under stated rules, not a CEFR placement result or a validated comprehension threshold.

Top comments (0)