Vocabulary, frequency and the OOV problem
20 min read
The previous lesson, on tokenisation, left a finite list, and a finite list has a hole in it: sooner or later a token arrives that the list does not hold. That lesson named the hole; this one measures it. And measuring it means counting: not only which tokens a corpus contains, but how unevenly it uses them, because that is what decides how many arrivals fall through the hole.
That frequency distribution decides which tokens are worth keeping, which are reasonable to leave out, and so how often the model will meet a word outside its vocabulary, one that is out-of-vocabulary (OOV). The distribution has a strikingly unequal shape, and it is the same shape in every corpus and every language. That shape, not the size of the corpus and not the language, is what fixes how much text you can cover with a vocabulary that fits in memory.
Count the tokens of any English text and the same scene appears every time. At the head, a handful of pieces that never stop coming: the, and, to, in, and the punctuation. In the tail, a crowd that appears once in the whole text and never returns: librarian, cryptocurrency, monastery. In the toy corpus you will work with in this lesson (228 tokens in all), the most frequent appears 30 times, and 72 of the 107 distinct strings appear exactly once.
It is a single observation, and it pulls in two opposite directions. The frequent strings are so few that keeping all of them costs almost nothing; the rare ones are so many that keeping all of them is impossible. And the uncomfortable part is that almost everything you care about in a sentence lives in the tail: the proper nouns, the technical terms, the words coined last month.
The shape of a corpus's frequencies
Counting forces apart two things that have gone together until now. Apply to a corpus and you get a sequence of tokens; each one is an occurrence, and repetitions count every time. Group them by equal strings and you get types: the distinct strings, counted once each. In the school of the square there are five tokens and four types, because the appears twice. Always , and in a real corpus is very much smaller.
Now order the types from most to least frequent and write for the frequency of the one in position , so that . By construction, the frequencies sum to the whole corpus:
That ordering reveals an empirical regularity that shows up in every language measured, Zipf's law: the frequency of a type is roughly inverse to its position,
with a constant that depends on the corpus. The second type appears half as often as the first, the tenth a tenth as often. It is an approximation, not a theorem (it fails in the first few positions and in the tail), but it answers the question that matters: how much text you buy with each type you add.
Call the coverage of the most frequent types, written , the fraction of the corpus's occurrences that those types account for:
Substituting Zipf's approximation, both the numerator and become sums of :
The constant cancels, and those sums are partial sums of the harmonic series, which grow like a logarithm: , with . Therefore
Why the sum of 1/i grows like a logarithm
The function is decreasing, so the rectangle of base and height lies below the area under the curve between and , and above the area between and .
Sum the second inequality over all and the first from (the term is worth and is taken out separately), and the rectangles are trapped:
The two ends are worth and , so the sum is plus something that stays between and . That "something" converges, and its limit is the Euler-Mascheroni constant . What the lesson uses is the part that grows: the logarithm.
And here is the result that decides the size of the vocabulary. Ask for a coverage and solve for . Since is around for a corpus with a hundred thousand types, is negligible beside it and the equation leaves
That exponent decides everything: coverage is not paid in proportion, it is paid in powers. With types, covering half the corpus costs types, 90% costs , and 99%, . The last nine points of coverage almost triple the vocabulary.
What it costs to cut the vocabulary
The vocabulary is then a decision with two opposed prices. Choosing as the most frequent types (or, from another angle, demanding a minimum frequency ) is choosing where the trade stops.
The price of raising is set by the lesson on the problem of representing language: the representation is a function , and a function like that is stored as a table with one row per vocabulary entry and columns. That is
With , a vocabulary of 30,000 types is nine million numbers; one of 100,000, thirty million. The table grows with and nothing compresses it: each entry is independent of the others.
The price of lowering is the text that falls on the far side of the cut, the fraction of the occurrences. Measured that way it looks small, and that is the problem: the same decision, measured over types, leaves out most of the language. With and you lose 10% of the occurrences and 68% of the types. And it is not a random 68%: the types that fall are the tail ones (proper nouns, technical terms, new words), the very thing that makes a sentence say one thing rather than another. A cut that sounds cheap in tokens is throwing away exactly the informative part.
The token that gathers what falls outside
That leaves the third question: what to do when, already in production, a word arrives that was left outside. The standard fix is to add one more entry to the vocabulary, one that corresponds to no particular word and absorbs them all, written <UNK>, for unknown:
and, before representing anything, each word passes through a filter that leaves it untouched or replaces it:
With that, is defined for any word that arrives. That was totality, the first of the three demands from that lesson on the problem of representing language, and it is the one this fix recovers. What you pay is injectivity: cryptocurrency and librarian become the same object. That lesson already warned that you would give it up on purpose, and this is the place it happens for the first time.
Now to name what it costs. The fraction of text that ends up in that entry is the OOV rate, and it is measured in the two directions this lesson has been keeping apart. Take a new text, , and call the set of its types, so that ; the prime marks what is measured on that text and not on the original corpus. Then
On the left there are positions: if cryptocurrency appears ten times, it contributes ten. On the right there are distinct strings, and that same word contributes one. They measure the same decision and give very different numbers. In a real corpus, a reasonable vocabulary leaves an OOV rate over tokens of 2% or 3%, and one over types of 30% or 40%. The first is the one that gets quoted, because it is the one that reassures. It is also the one that misleads, and in two ways.
One is arithmetic. The rate is measured per token, but text is read by sentences, and a sentence is spared only if all its tokens are in . With twenty tokens per sentence and a 2% OOV rate, that happens with probability : one in three sentences contains some word the model cannot read. The other way is that the tokens that fall are not arbitrary ones, but the tail ones, the ones carrying what the sentence says. A 2% loss of tokens is not a 2% loss of meaning.
What if I grow the corpus until there are no new words left?
It does not work, and the reason has a regularity of its own, Heaps' law: the number of types grows with the number of tokens as a power, with a constant of the corpus,
Since , the growth slows: doubling the corpus multiplies the types by , not by two. But it never stops. There are always new types, and the fraction of those appearing a single time stays high for however many millions of tokens you read.
Compare it with the character vocabulary of the previous lesson, which does saturate within a couple of pages: the alphabet is closed and the language is not. Out-of-vocabulary words are not a defect of your corpus: they are a property of any corpus.
Counting types, tokens and coverage in Python
The cell tokenises a short English corpus with the same by_words as the previous lesson, counts types and occurrences, walks the coverage, and ends by applying a cut to five sentences the corpus did not contain. Run it and look at three things: what fraction of the types appears a single time, how climbs at first and then jams, and the two OOV rates, which do not resemble each other.
from collections import Counter
CORPUS = " ".join([
"The boy teaches programming in the neighbourhood school.",
"The girl learns programming in the classroom every morning.",
"The school opens at eight, when the street is still empty.",
"The teacher explains that a language changes with the passing years.",
"The children play in the square until the sun goes down.",
"A girl asks why the sea is blue, and the teacher says nothing.",
"The book talks about the river, the sea and the falling rain.",
"To learn a language is to learn to cut the world into words.",
"In the square there is a tree that the children call the giant.",
"The girl who learns programming explains the exercise to her brother.",
"The teacher leaves the book on the table and walks out.",
"Every summer the children ride their bikes down to the river.",
"The afternoon rain leaves the square empty and the school silent.",
"Nobody in the neighbourhood remembers what the street was called before.",
"The boy writes a new word in his notebook and underlines it.",
"The language of the school is not the language of the square.",
"The families walk to the river and then to the sea, as every summer.",
"Teaching is not the same as learning, though they happen together.",
])
def normalise(text):
return unicodedata.normalize("NFC", text)
def by_words(text):
tokens, current = [], ""
for c in normalise(text):
if c.isalnum():
current = current + c
continue
if current:
tokens.append(current)
current = ""
if not c.isspace():
tokens.append(c) # punctuation is a token of its own
if current:
tokens.append(current)
return tokens
tokens = by_words(CORPUS)
freq = Counter(tokens)
T, M = len(tokens), len(freq)
hapax = sum(1 for f in freq.values() if f == 1)
print("T =", T, "tokens | M =", M, "types")
print("types appearing ONCE:", hapax, "of", M,
"->", round(100 * hapax / M), "% of types")
print()
cumulative = 0
print(" i type f_i cob(i)")
for i, (w, f) in enumerate(freq.most_common(8), start=1):
cumulative = cumulative + f
print(str(i).rjust(3), w.ljust(16), str(f).rjust(3),
(str(round(100 * cumulative / T, 1)) + " %").rjust(8))
print()
for k in [8, 25, 50, 100, M]:
covered = sum(f for _, f in freq.most_common(k))
print("k =", str(k).rjust(3), "-> cob(k) =",
(str(round(100 * covered / T, 1)) + " %").rjust(7))
print()
# Five sentences the corpus did NOT contain, with two vocabularies:
# one cut to the 50 most frequent types, one with no cut at all.
NEW_SENTENCES = [
"The boy programs a cryptocurrency in the neighbourhood school.",
"The teacher will explain the unstressed pronouns to the students tomorrow.",
"In the neighbourhood square it rains and the children go back home.",
"The librarian catalogued the manuscripts of the monastery.",
"The girl from the river reads a book about grammar.",
]
new_tokens = [t for sentence in NEW_SENTENCES for t in by_words(sentence)]
for cutoff in [50, M]:
V = {w for w, _ in freq.most_common(cutoff)}
outside = [t for t in new_tokens if t not in V]
print("|V| =", str(cutoff).rjust(3),
"-> OOV over tokens:", (str(round(100 * len(outside) / len(new_tokens))) + " %").rjust(5),
"| over types:", (str(round(100 * len(set(outside)) / len(set(new_tokens)))) + " %").rjust(5))
print()
V = set(freq) # no cut: everything the corpus contained
for sentence in NEW_SENTENCES:
print(" ".join(w if w in V else "<UNK>" for w in by_words(sentence)))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
Coverage climbs fast and then jams, which is the logarithmic shape seen in numbers: the first eight types cover more than a third of the corpus, and it takes fifty to reach three quarters. The tail is long and cheap to throw away, measured in occurrences.
Look carefully at the last two parts. The rate over types beats the rate over tokens in both vocabularies, exactly as the theory says, and the toy corpus exaggerates both: with 228 tokens, even the untrimmed vocabulary leaves out a third of what arrives, while a real one would bring the rate over tokens down to 2% or 3%. What does not change with size is which words fall. In the rewritten sentences the articles and prepositions still stand, and cryptocurrency, librarian, manuscripts and monastery disappear: the fourth sentence is reduced to a skeleton that no longer says anything.
A thirty-second test. Change V = set(freq) to V = {w for w, _ in freq.most_common(50)} and run again: In with a capital becomes unknown while in stays in. It is a different type, not a bug: the previous lesson already warned that cutting is not cleaning, and the price is paid here, in the count.
Test your intuition
Four questions on what you now have: what a cut costs, what coverage costs, what the unknown token recovers and what it spends, and what a small OOV rate really means.
A corpus of 36 tokens has 8 types, with these frequencies: the 12, of 9, boy 5, school 4, programming 2, river 2, cryptocurrency 1, grammar 1. You cut the vocabulary at . How many of the 36 occurrences end up turned into <UNK>?
A margin of ±0 is accepted.
A corpus has types and their frequencies follow . You want a vocabulary that covers 90% of the occurrences. How many types do you need, roughly?
You send everything that fell outside to <UNK>. What is true of the result? Tick all that apply.
Select every correct option. This is graded all-or-nothing: there is no partial credit.
Your vocabulary leaves out 2% of the tokens of a new text. So only 2% of the sentences in that text contain some unknown word.
The vocabulary is now a closed list: types chosen by frequency, plus one entry at the end that gathers everything else. That list, the from above, we call from here on, with <UNK> as one more entry. With it, is once again defined on any text, which was the starting condition. But of we know only what it receives and what it returns. We still have to decide which concrete vector each of those entries gets, and the lesson on the problem of representing language already said what will not do: with the order of the real line invents likenesses between words that have none.
The most direct way out is to choose no likeness at all. If no vocabulary entry should sit closer to one than to the rest, give each its own axis: one dimension per entry, a one in its position and zeros elsewhere. That representation exists, it is called one-hot encoding, and it asserts nothing about the language, which is exactly what we asked of it. This lesson has now written its bill: runs to tens of thousands, and that is the dimension those vectors would have. The lesson on one-hot encoding and the curse of dimensionality looks at that bill up close.
Further reading2 sources · 1 book, 1 interactive
Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.
- Introduction to Information Retrieval, ch. 5: Index Compression
Zipf's law and Heaps' law measured on a real corpus, in its §5.1. The rest of the chapter is about compressing search indexes and doesn't concern you; that section is where the shape of the frequencies you derive here comes from.
- Frequency Distribution Calculator
Paste a text and it gives you the frequency distribution by position, the Zipf fit, and the types that appear a single time, which here you count by hand. It also brings in Shannon entropy, which this lesson does not use.