Text as Data: Tokens, Normalization, Zipf

Chapter 2 · Applied Natural Language Processing

Dr. Batuhan Bardak

After this lecture you can

  • turn raw text into model-ready units and defend each preprocessing choice
  • describe the distribution of word frequencies with Zipf’s law, and the growth of the number of distinct words with Heaps’ law
  • derive a TF-IDF vector by hand and get the same numbers as scikit-learn
  • explain with examples what a bag-of-words representation keeps and what it loses, and justify which tasks it suits
  • explain how Turkish lowercasing and agglutinative structure affect normalization decisions and vocabulary coverage

The problem, in one sentence

The models in this chapter work on numerical inputs. To give them text, we have to turn strings of characters into a numerical representation.

  • Two basic decisions: which units to cut the text into (tokenization) and the value of each unit in the document vector (weighting)
  • This chapter builds sparse representations: most components of a document vector are zero. Chapter 3 moves to dense vectors
  • Simple models on sparse representations can be a strong baseline where labelled examples are few, where long documents are classified by topic, or where literal word matching matters

Preprocessing decides what information reaches the model, so it is part of the modelling process.

Split on whitespace — and see what it leaves

text = "The pump P-101 didn't fail; it's the seal (again!)."
print(text.split())
['The', 'pump', 'P-101', "didn't", 'fail;', "it's", 'the', 'seal', '(again!).']
  • The and the both occur, and count as two different tokens; fail; and it's stay units of their own
  • Three decisions: map The and the to the same form? Keep the semicolon in fail; or remove it? Keep it's whole, or map it to it is?
  • A word-level tokenizer can use separate rules for punctuation, apostrophes, URLs, numbers and abbreviations

Normalization decisions are separate operations from deciding where tokens begin and end. Whether a normalization erases a useful distinction has to be judged for the task.

The normalization ladder

Operation Possible benefit Possible loss or cost
Lowercasing Pump = pump: two columns merge into one and their counts add up US and us become the same form; str.lower() does not apply the Turkish mappings I–ı and İ–i
Removing punctuation by removing selected marks, can match fail; with fail deleting indiscriminately can change identifiers such as P-101, decimals and URLs
Stop word removal leaves the listed words out of the count: 43.9% of all tokens on this chapter’s corpus if not is on the list, negation can be lost
Stemming can merge word forms into one output: Porter turns replaced into replac can wrongly merge different words: university and universe both become univers
Lemmatization aims at the dictionary form: replace for replaced used as a verb can need a dictionary, rules, part-of-speech or context; can err on ambiguous forms

This table is for a pipeline you build yourself. If the text is going into a pre-trained model, stay with the tokenizer and the preprocessing assumptions that model was trained under.

Reading the ladder

  • The table is not a required order of application; stemming and lemmatization are in most cases alternatives to each other
  • Each operation rests on the assumption that the distinction it removes is not needed for the task
  • Lowercasing can help in some topic classification tasks, and removes a cue that comes from capital letters in named entity recognition (NER)
  • Take the model’s requirements and the properties of the data into account

The recommendations are a starting point: compare the suitable options on validation data, and keep the test set apart for the final evaluation.

Lowercasing Turkish: the default is not Turkish

"İSTANBUL".lower()              # 'i̇stanbul': starts with U+0069 + U+0307; 9 code points in all
"İSTANBUL".lower() == "istanbul"  # False
"IĞDIR".lower()                 # 'iğdir' — should be 'ığdır'
  • Python’s str.lower() applies Unicode’s default lowercase mapping; it takes no locale parameter for Turkish. Turkish has two separate mappings: I → ı and İ → i
  • The first result starts with U+0069 + U+0307, not with a plain i
  • No error is raised, but spellings of the same word may fail to match: the default CountVectorizer produces the token stanbul from the input İSTANBUL

Once the preprocessing is fixed, the vocabulary and the models that depend on it may have to be rebuilt.

Stemming and lemmatization do different things

What it does Examples
Stemming (Porter) rules aimed at inflectional or derivational suffixes; no guarantee that the result is a word found in a dictionary replaced → replac; university and universe → univers
Lemmatization aims at the base form in a dictionary: the infinitive of a verb, the uninflected form of a noun replaced (as a verb) → replace; left can need context
Snowball Turkish stemmer the stemmer used in this book kitaplarımızdan → kitap; kullanıyorum → kullanıyor; kullandık → kulla
  • With a pretrained transformer, use the model’s tokenizer and the preprocessing it expects; do not add stemming or lemmatization on your own
  • Stemming can help in lexical search or sparse classification, but check which forms your tool actually merges: Snowball does not merge kullanıyorum and kullandık

Porter 1980

Stop words: 0.4% of the vocabulary, 43.9% of tokens

  • scikit-learn’s English stop word list has 318 entries; in this chapter’s 20 Newsgroups measurement, 311 of them occur in the corpus under the tokenization rule used
  • They are about 0.4% of the vocabulary (78,649 distinct token types) but 43.9% of all tokens: nearly half of the tokens are left out of the count
  • That share does not mean the text shrinks by the same share in bytes, or the sparse matrix in memory
  • If a word that matters for the task, such as not, is removed, the information needed to tell meanings apart can be lost

Weigh the information lost when you remove stop words. Whether that is a saving or a mistake depends on the task.

The limit of the word level

  • For the rest of this chapter a token is a normalized word form; that unit is enough for bag-of-words and TF-IDF
  • Once the vocabulary is fixed, new text can contain word forms that are not in it: a word-level representation has no column for those forms and leaves them out of the counts
  • In an agglutinative language such as Turkish, where one stem has many inflected forms, that happens more often
  • Today’s language models get past this limit with units smaller than a word: the vocabulary holds pieces of words learned from data (subwords), and a new word is written with those pieces

Subword methods are covered in chapter 8, together with the pretrained models that use them. For now we continue with words.

Zipf and Heaps: two separate questions

  1. How are frequencies distributed between the commonest and the rarest words in a corpus? Zipf’s law
  2. As you add more text, how does the number of distinct words grow? Heaps’ law
  • Zipf’s law describes how frequencies are distributed within a given corpus; Heaps’ law describes how the number of distinct word forms changes as the corpus grows
  • The two can be connected under specific assumptions

Both are approximate, observed regularities; neither dictates how tokenization has to be done.

Lü, Zhang & Zhou 2010

20 Newsgroups: 2.29 million tokens, 78,649 types

tokens: 2288401  distinct types: 78649
top     10 types -> 20.99% of all tokens
top    100 types -> 46.99% of all tokens
top   1000 types -> 71.04% of all tokens
top  10000 types -> 92.50% of all tokens

types appearing exactly once: 38571  (49.0% of the vocabulary,
                                      1.69% of all tokens)
  • A type is each distinct string our splitting produces; a token is each occurrence: pump a hundred times is one type and a hundred tokens
  • The ten most frequent types make up about a fifth of the tokens, while about 49% of the types are seen exactly once

Zipf’s law: an approximate power relationship

\[f(r) \propto r^{-\alpha}\]

  • \(r\) is the type’s position in the frequency ranking, \(f(r)\) its number of occurrences; on log-log axes the relationship looks approximately like a straight line
  • Take the 106,425 occurrences of the as the scale and \(\alpha = 1\): about 10,600 expected at rank ten. The observed value is 26,985, roughly two and a half times that
  • The Zipf–Mandelbrot form, \(f(r) \propto (r + b)^{-\alpha}\), gives the head of the distribution more flexibility
rank occurrences
1 106,425
2 62,043
10 26,985
97 3,027
1,113 221
10,352 13
77,121 1

Take the straight line as a reference that summarizes the shape of the distribution, not as a model estimated from the data.

Three points for vocabulary design

  1. A fixed word vocabulary may not cover new input completely. Text whose topic and source are not restricted keeps bringing new names, part numbers, spellings and compounds
  2. Reliable statistics are hard to learn for rare types, yet a rare term can still matter. On this corpus, with CountVectorizer’s defaults, min_df=2 removes 62,208 of 101,631 columns (61%); the removed columns carry only 3.4% of token occurrences. The English stop word list removes 309 columns, but those carry 43% of the occurrences
  3. How frequent a term is does not by itself determine how useful it is for a task. Terms found in many documents may do less to tell documents apart; but occurring in few documents is no guarantee of meaning or usefulness either

The numbers belong to this corpus and this threshold; how the filter affects accuracy has to be measured separately.

Heaps’ law: the vocabulary keeps growing

|V| ~ 16.5 * N^0.580
new types in the first 100,000 tokens: 13497
new types in tokens 2,180,001-2,280,000: 1814

\[|V(N)| \approx K N^{\beta}\]

  • \(K\) and \(\beta\) depend on the corpus and on the preprocessing: here \(K \approx 16.5\) and \(\beta \approx 0.58\); the values reported for Reuters-RCV1 are \(K \approx 44\) and \(\beta \approx 0.49\)
  • The first 100,000 tokens bring 13,497 types; the last 100,000 tokens we measured bring only 1,814 new ones. New types keep arriving, but ever more rarely.

Heaps’ law is an empirical regularity that models that growth approximately over a given range, not a proven theorem.

Heaps 1978 · Manning, Raghavan & Schütze 2008

Always look at your top-50 list

In this count the second most frequent token is 'ax, with 62,043 occurrences, ahead of to, a and of. It is not a word of natural language.

  • All of its occurrences sit in 13 of the 11,314 documents; twelve of those are parts 3–14 of a fourteen-part posting of a file called roman.bmp, with lines of the form >'AX>'AX>'AX
  • One document alone, part 12, accounts for 11,147 of the 62,043 occurrences
  • Second by total count, yet found in only 0.1% of the documents: frequency in the corpus as a whole and spread across documents are different things
  • TF-IDF does not recognize non-language content by itself: a noise string that occurs in few documents can receive a high IDF

Looking at the list of most frequent tokens is a simple way to catch data and preprocessing problems before any weighting.

Three counts to keep apart

  1. the number of times a term occurs in one particular document
  2. its total number of occurrences in the whole corpus
  3. the number of documents that contain it
  • For 'ax the corpus total is high and the number of documents containing it is low
  • The form of the token depends on a preprocessing decision: scikit-learn’s default token_pattern produces ax from the same documents, and ax still ranks second after the, with 62,387 occurrences

TF-IDF combines the term’s frequency in each document (TF) with an IDF weight computed from how many documents contain the term.

From string to vector: six steps

corpus = [
    "the pump failed",           # d1
    "the pump was replaced",     # d2
    "the valve failed",          # d3
    "the seal leak was repaired", # d4
]
  1. Index: failed→0, pump→2, the→6
  2. One-hot: pump = [0 0 1 0 0 0 0 0 0]
  3. Sum = counts: [1 0 1 0 0 0 1 0 0]
  1. TF-IDF weights: [1.51 0 1.51 … 1.00 …]
  2. L2 normalize: [0.64 0 0.64 … 0.42 …]
  3. Compare: cos(d1, d3) = 0.528

Every number shown is the one the code actually produces for d1 = "the pump failed".

Step 1 · the vocabulary is just an index

from sklearn.feature_extraction.text import CountVectorizer

cv = CountVectorizer()
X = cv.fit_transform(corpus)
print(cv.get_feature_names_out())
['failed' 'leak' 'pump' 'repaired' 'replaced' 'seal' 'the' 'valve' 'was']
  • The column number is the term’s address in the vocabulary; by itself it says nothing about closeness in meaning
  • Nine terms, so every vector from here on lives in \(\mathbb{R}^9\)
  • The order in which the columns are numbered does not change any result, as long as training and use keep the same mapping

Step 2 · one-hot: a word as a vector

index of 'pump': 2
one-hot: [0 0 1 0 0 0 0 0 0]
pump  : [0 0 1 0 0 0 0 0 0]
valve : [0 0 0 0 0 0 0 1 0]
        ------------------- multiply position by position
        [0 0 0 0 0 0 0 0 0]  -> sum = 0
  • The one-hot vectors of different words are orthogonal: under this encoding pump is exactly as similar to valve as it is to repaired
  • The vector says which column the word is kept in, and nothing about what it is like

One-hot is an intermediate structure here, not the final way to represent word meaning. Learned dense vectors — embeddings — are chapter 3.

Step 3 · a document is the sum of its one-hots

d1 tokens: ['the', 'pump', 'failed']
d1 vector: [1 0 1 0 0 0 1 0 0]
  • Add up the one-hot vectors of every token in a document: a count vector. That is bag-of-words: the multiset of tokens, order discarded
  • Do it for all four documents: the document-term matrix
  • Standard layout: rows are documents, columns are terms — here 4 documents × 9 terms. The block is only shown transposed
          d1  d2  d3  d4
failed     1   0   1   0
leak       0   0   0   1
pump       1   1   0   0
repaired   0   0   0   1
replaced   0   1   0   0
seal       0   0   0   1
the        1   1   1   1
valve      0   0   1   0
was        0   1   0   1

Step 4 · does every word deserve the same weight?

  • the appears in all four documents: it contributes to every comparison while distinguishing nothing. valve appears once and is the only thing that makes d3 distinctive. Raw counts give them the same treatment

To give a term a high weight we want two things:

  1. It is frequent in this document. The factor that measures this is TF
  2. It is not ordinary across all documents. The factor that measures this is IDF
  • Term frequency is the raw count we already have: scikit-learn does not divide it by document length

TF-IDF is the product of the answers. “TF-IDF” on its own is not a specification: say which variant produced a number you report.

Why invert df — and why with a logarithm

  • Document frequency, \(\mathrm{df}(t)\), is how many documents contain the word at all. We want the weight to fall as df rises, so we invert it
  • Raw inversion, \(1/\mathrm{df}\), has far too wide a range: in a collection of a thousand documents, a term in one document gets 1 and a term in every document gets 0.001
  • A logarithm compresses that range: \(\log(n/\mathrm{df})\) stays between \(\ln 1000 \approx 6.9\) and 0, and doubling the document frequency costs the same amount (\(\ln 2 \approx 0.69\)) wherever it happens

\[\mathrm{idf}(t) = \ln\!\left(\frac{n}{\mathrm{df}(t)}\right)\]

A second justification comes from information theory: in a model where documents are drawn with equal probability, \(\log(n/\mathrm{df})\) is the self-information of the event that the drawn document contains the term.

Spärck Jones 1972 · Robertson 2004

scikit-learn’s smoothed IDF, by hand

\[\mathrm{idf}(t) = \ln\!\left(\frac{1 + n}{1 + \mathrm{df}(t)}\right) + 1\]

  • The \(1+\) terms are smoothing: the formula behaves as if the collection held one extra document containing every term once
  • The trailing \(+1\) makes the IDF of a term that occurs in every document 1 rather than 0: the term is not zeroed out

\[\mathrm{idf}(\texttt{pump}) = \ln\!\left(\frac{1+4}{1+2}\right) + 1 = 0.511 + 1 = 1.511 \qquad \mathrm{idf}(\texttt{the}) = \ln\!\left(\frac{1+4}{1+4}\right) + 1 = 1\]

This is scikit-learn’s exact formula; other libraries differ in the details, which is a frequent source of “why don’t my numbers match”.

IDF on four documents, and on a real corpus

          df     idf
failed     2  1.5108
leak       1  1.9163
pump       2  1.5108
repaired   1  1.9163
replaced   1  1.9163
seal       1  1.9163
the        4  1.0000
valve      1  1.9163
was        2  1.5108
the            df=  9477  df/N= 0.838  idf= 1.18
is             df=  7034  df/N= 0.622  idf= 1.48
car            df=   394  df/N= 0.035  idf= 4.35
encryption     df=   169  df/N= 0.015  idf= 5.20
gravitational  df=     8  df/N= 0.001  idf= 8.14
  • Left: the is in all four documents and lands exactly on the floor of 1.0; every word unique to one document gets 1.9163
  • Right: on the real corpus the spread is much larger
  • Computed by hand and checked against the library: the largest absolute difference is 0.0

The weight is a monotone decreasing function of how many documents a word appears in. It shows how widespread a term is across documents, not whether it is a content word.

Step 5 · multiply, then normalize

counts        : [1 0 1 0 0 0 1 0 0]
times idf     : [1.5108 0.     1.5108 0.     0.     0.     1.     0.     0.    ]
l2 norm       : 2.3591
normalized    : [0.6404 0.     0.6404 0.     0.     0.     0.4239 0.     0.    ]
sklearn row d1: [0.6404 0.     0.6404 0.     0.     0.     0.4239 0.     0.    ]

\[\lVert v \rVert_2 = \sqrt{1.5108^2 + 1.5108^2 + 1.0000^2} \approx 2.3591 \qquad \frac{1.5108}{2.3591} = 0.6404 \qquad \frac{1.0000}{2.3591} = 0.4239\]

  • d1 is the pump failed: three positions are non-zero, each with a count of 1, each multiplied by its IDF
  • The hand computation and TfidfVectorizer agree to the four decimal places displayed

That final division by \(\lVert v \rVert_2\) is L2 normalization.

What L2 normalization changes

Why should two documents that use similar terms in similar proportions — one 20 words long, the other 2,000 — look far apart only because one is longer?

  • L2 normalization brings the norm of a non-zero vector to 1 while keeping the ratios between its components: every non-zero document vector moves onto the unit sphere
  • Without it, every comparison based on a dot product or Euclidean distance — including a linear classifier’s score — becomes partly a comparison of vector sizes
  • A document left with no active term gives the zero vector, and cosine with it is \(0/0\), undefined. scikit-learn returns a cosine of 0, which is a convention: count these rows and handle them separately
  • Raw TF, smoothed IDF and L2 is scikit-learn’s default; other tools may choose a different formula and normalization

Only direction, the weighted mix of terms, is left in the comparison. Its effect on classification accuracy is something we measure on validation data.

Salton & Buckley 1988

Step 6 · cosine compares direction, not length

\[\cos(a, b) = \frac{a \cdot b}{\lVert a \rVert \, \lVert b \rVert} = \sum_{i} a_i b_i \quad \text{(when both are already unit length)}\]

\[\cos(\text{d1}, \text{d3}) = \underbrace{0.6404 \times 0.5729}_{\texttt{failed}} + \underbrace{0.4239 \times 0.3792}_{\texttt{the}} = 0.3669 + 0.1607 = 0.528\]

  • For the non-zero vectors without negative components used here, 1 means identical direction and 0 means no shared term with positive weight
  • failed supplies 70% of the similarity and the supplies 30%, even though each word occurs exactly once in each document; under raw counts they would have contributed equally

The weighting is what decides which shared word counts as evidence.

The same four documents, two weightings

       d1     d2     d3     d4          <- TF-IDF
d1  1.000  0.458  0.528  0.112
d2  0.458  1.000  0.125  0.286
d3  0.528  0.125  1.000  0.100
d4  0.112  0.286  0.100  1.000

       d1     d2     d3     d4          <- raw counts
d1  1.000  0.577  0.667  0.258
d2  0.577  1.000  0.289  0.447
d3  0.667  0.289  1.000  0.258
d4  0.258  0.447  0.258  1.000
  • Under raw counts, d1 and d4 sit at a cosine of 0.26, but they share only the word the; by reducing the relative contribution of the shared the, TF-IDF brings that score down to 0.11

A cosine is the cosine of the angle between the two vectors. It is not a calibrated percentage of semantic similarity, which is what reporting it as “26% similar” implies.

Why cosine and not Euclidean distance?

Pair, as raw count vectors over two words Euclidean distance on raw counts After L2 normalization
A (2, 1) and B (20, 10) 20.12 — far away, but B is exactly 10 × A the same point
A (2, 1) and C (1, 4) 3.16 — nearest, but C has different term proportions an angle of 49.4°: \(\cos = 0.651\), distance 0.836
  • On raw counts, Euclidean distance puts A more than six times closer to C than to B
  • After L2 normalization all vectors lie on the unit sphere, and there the two measures are monotonically related: \(\lVert a - b \rVert^2 = 2 - 2\cos(a,b)\)
  • For L2-normalized non-zero vectors the two give the same ranking in an exact nearest-neighbour search; numerical thresholds have to be converted

Cosine looks only at direction: it compares the proportions of the terms, not their total amount. This is why classical text pipelines normalize before they compare.

What bag-of-words cannot do · word order

pair = ["the pump failed and the valve is fine",
        "the valve failed and the pump is fine"]
vocabulary: ['and','failed','fine','is','pump','the','valve']
row 1: [1 1 1 1 1 2 1]
row 2: [1 1 1 1 1 2 1]
vectors identical: True
cosine similarity: 1.0
  • It cannot represent word order. Not “represents it poorly” — cannot represent it at all
  • Two reports naming opposite pieces of failed equipment are the same point in space

Information discarded at the representation stage cannot be recovered by a stronger classifier.

n-grams buy back a little

bigram vocabulary: ['and the','failed and','is fine','pump failed',
                    'pump is','the pump','the valve','valve failed','valve is']
cosine similarity: 0.714
vocabulary size: unigrams 7 -> bigrams 9
  • Using pairs of adjacent words instead of single words, the two reports are distinguishable
  • The cost: in the experiment below, adding bigrams took the feature count from 32,318 to 249,522, a 7.7× increase, and accuracy fell
  • For jobs like language identification, spam filtering or short-text classification, n-grams can be enough on their own

The feature space grows quickly as the context gets longer; long-range, context-sensitive relations need a more general representation.

What bag-of-words cannot do · negation and meaning

"no leak was found"  vs  "a leak was found"
vocabulary (the default token_pattern drops the one-letter "a"):
    ['found', 'leak', 'no', 'was']
raw counts  ->  cosine 0.866
TF-IDF      ->  cosine 0.777
  • not is a feature like any other; what a bag of words cannot recover is what was negated
  • If your stop word list contains no — as scikit-learn’s built-in English list does — the two vectors become identical (cosine 1)
  • It does not encode synonymy or polysemy directly: pump failed and pump broke share nothing beyond pump

In tasks where negation changes the decision, the stop word list needs to be checked separately.

Does any of this still matter?

Four newsgroups, 2,327 training documents, 1,550 test documents, logistic regression; no hyperparameter search was done.

raw counts               accuracy 0.767   features   32318
counts + stop words      accuracy 0.795   features   32014
tf-idf                   accuracy 0.831   features   32318
tf-idf + stop words      accuracy 0.853   features   32014
tf-idf + bigrams         accuracy 0.819   features  249522
tf-idf + sublinear tf    accuracy 0.843   features   32318

This table is a demonstration of a mechanism and a baseline comparison. Do not read small differences as universal superiority.

Four conclusions from six lines

  1. TF-IDF is worth 6.4 accuracy points over raw counts, without collecting more training documents and without a hyperparameter search
  2. Stop word removal helps here (+2.2 points); in sentiment analysis or negation detection the same step can hurt if the list removes words that matter for the task
  3. Accuracy fell when bigrams were added, while the feature count grew 7.7-fold. More features do not guarantee higher accuracy
  4. sublinear_tf points the same way (0.843 vs 0.831): it replaces the raw count with \(1 + \log(\text{tf})\), the range-compression argument we made for IDF, applied to the term-frequency side

scikit-learn’s own documentation calls stop_words='english' problematic. If stop words matter to your result, write the list yourself and print it.

How uncertain are these numbers?

  • On 1,550 test documents, a single accuracy near 0.83 carries a 95% interval of roughly ±1.9 points (binomial approximation)
  • The six models see the identical test documents: the setting for a paired test such as McNemar’s
Comparison Only the first correct Only the second correct p
TF-IDF and raw counts 119 20 2.4 × 10⁻¹⁸
TF-IDF + stop words and TF-IDF 86 52 0.0048
TF-IDF + bigrams and TF-IDF 17 35 0.018
TF-IDF + sublinear tf and TF-IDF 29 11 0.0064

With four comparisons, a Bonferroni correction puts the threshold at 0.05/4 = 0.0125. The TF-IDF, stop word and sublinear_tf differences fall below it; the bigram difference does not.

McNemar 1947

Two of the four lessons reverse on another task

  • Every number here comes from English topic classification on long documents
  • On Turkish product reviews — short documents, sentiment rather than topic — the first table in chapter 4 points the opposite way on both counts: at a fixed label budget bigrams give the largest gain, and plain counting beats TF-IDF
  • But the second result does not come from IDF: that chapter’s ablation shows L2 normalization costs the points, and adding IDF on top wins them back
  • The result hinged on an untuned C and reverses under an equal tuning budget

The transferable lesson is the method, not the ranking.

Readable, and small

rec.autos            car, cars, engine, ford, oil, dealer, drive, price
sci.med              msg, doctor, disease, medical, pain, patients, treatment
sci.space            space, nasa, orbit, moon, launch, earth, spacecraft
talk.politics.guns   gun, guns, weapons, fbi, law, government, firearms
shape: (2327, 32318)
stored non-zeros: 234281
density: 0.312%
dense float64 would need :    601.6 MB
sparse csr actually needs:      2.8 MB
ratio: 213x
  • The terms with the largest coefficients for each class; a large coefficient is not by itself a causal explanation
  • The matrix is 99.7% zeros: 2.8 MB instead of 601.6 MB. That is the storage cost of the sparse matrix alone

Bag-of-words, honestly

Why bag-of-words survives Why it is not enough
A small sparse matrix. In this example the matrix for 2,327 documents takes 2.8 MB. No GPU needed No word order. pump failed, valve fine and valve failed, pump fine are the same vector
Legible. The model names its own features — gun, orbit, doctor No synonymy. car and automobile are as unrelated as car and banana
Strong baseline. 0.853 here, and it beat averaged embeddings in chapter 3 — then beat them again on Turkish data in chapter 4 No polysemy. One column for bank, whatever it meant
No training data beyond your own. Nothing pretrained, nothing downloaded, no vendor Out-of-vocabulary terms can occur in new documents
Reproducible with a fixed pipeline. The same input gives the same output Negation has no scope. What a bag cannot recover is what was negated

The two columns show the strengths and the limits of one and the same representation. Which side weighs more depends on what the task requires.

Where this is actually deployed

Where The idea from this chapter, in use
Ticket triage at Uber COTA, described in 2018: clean, tokenize, remove stop words, lemmatize, TF-IDF, reduce with LSA; a cosine similarity from those vectors feeds a random-forest matching model
Search ranking Lucene, and therefore Elasticsearch and OpenSearch, scores documents with BM25 by default
Google’s text classification guide reports TF-IDF n-grams as the most accurate of the encodings it compares
Hybrid retrieval dense embedding search combined with sparse BM25 search: the sparse half catches the exact matches
Spam and abuse filtering sparse features as a first-line filter; feature hashing maps terms to a fixed number of columns
Near-duplicate detection simhash fingerprints; MinHash over sets of shingles, fixed-length windows of words

For tasks it suits, such as text classification and lexical retrieval, include a TF-IDF baseline in the comparison.

BM25 in one formula

\[\mathrm{BM25}(q, d) = \sum_{t \in q} \mathrm{idf}(t) \cdot \frac{c(t,d)\,(k_1 + 1)}{c(t,d) + k_1\!\left(1 - b + b\,\frac{|d|}{\mathrm{avgdl}}\right)}\]

  • The \(\mathrm{idf}\) factor is the same idea as ours (BM25 uses a probabilistic variant)
  • The fraction replaces raw term frequency with a saturating one: with the other variables fixed, it approaches the limit \(k_1 + 1\) as \(c(t,d)\) grows
  • \(b \in [0,1]\) interpolates between ignoring document length (\(b = 0\)) and dividing the term frequency by the document’s length relative to the corpus average (\(b = 1\))
  • In Lucene 9.12.1 the defaults of the BM25Similarity class are \(k_1 = 1.2\) and \(b = 0.75\)

The same concern L2 normalization addresses — long documents should not win on length alone — handled by a different formula and with a dial instead of a switch; the two are not the same operation.

Robertson & Zaragoza 2009

Intuition check 1 of 6

Your TF-IDF matrix has 200,000 columns and 5,000 rows. Should you worry?

  • Not about memory: in a sparse matrix the real memory cost depends mostly on the number of non-zeros, not on rows × columns
  • The real worry is statistical: 5,000 observations against 200,000 features. That is a p ≫ n problem
  • The usual answers are strong regularization (L2 or L1) and cutting the feature count with min_df
  • On the four-category corpus, min_df=5 takes the vocabulary from 32,318 down to 6,281 — 81% of the columns gone. The deleted columns carry 11.6% of the token occurrences

Intuition check 2 of 6

Should IDF be computed on the training set or on all your data?

  • In a supervised classification evaluation the vocabulary and the IDF are learned from the training portion only and applied unchanged to validation and test data
  • IDF is a parameter estimated from data, so computing it over train + test leaks information about the test distribution into your features
  • In scikit-learn: fit_transform on train and transform on test. Calling fit_transform twice is a real and common bug
  • In production the IDF vector is a model artifact you version and ship, not something you recompute per batch

Intuition check 3 of 6

A word appears in every document. What is its TF-IDF weight?

  • With scikit-learn’s smoothed formula the IDF factor is \(\ln\frac{1+n}{1+n} + 1 = 1\) when \(\mathrm{df} = n\); before normalization the TF-IDF value is the term’s TF value
  • With the textbook unsmoothed formula \(\log(n/\mathrm{df})\) it would be exactly 0 and the term would drop out of the vector
  • The IDF is 1 under smooth_idf=False too: scikit-learn still keeps the outer \(+1\) under that setting, and the result is not the textbook formula

Intuition check 4 of 6

Why do we normalize document vectors but not feature columns?

  • The two normalizations answer different questions: L2 row normalization brings non-zero document vectors to unit norm, so comparisons rest on the proportions of the weighted terms
  • Subtracting non-zero column means and storing the result explicitly turns the existing zeros into non-zero values: this 2.8 MB matrix would need about 601.6 MB as a dense float64 array
  • Not every column scaling behaves this way: StandardScaler(with_mean=False) can keep the sparse structure
  • Sparsity is a constraint that determines which operations are practical

Intuition check 5 of 6

Your search engine returns nothing for the part number “P-101”. Why?

default    : ['101', '102', 'pump', 'tripped']
custom     : ['p-101', 'p-102', 'pump', 'tripped']
  • scikit-learn’s default token_pattern requires two or more word characters and does not cross the hyphen: P-101 silently became 101
  • Getting no result at all can also come from a mismatch between the query and index analysers, or from other filters
  • The habit that prevents it: after fitting any vectorizer, print get_feature_names_out() and read it

Intuition check 6 of 6

When is a bag-of-words model the right choice in 2026?

A bag-of-words baseline is especially attractive when several of these hold together:

  • you have very few labelled examples per class
  • your vocabulary is domain-specific and exact matching matters: part numbers, gene names, legal citations
  • you must explain every prediction to someone with authority over you
  • you have no GPU or a hard latency budget
  • your documents are long and topical rather than short and nuanced

Interview questions for discussion

Five of the chapter’s interview questions. The chapter groups them by what they test.

  1. Explain TF-IDF to someone who has not seen it. Then explain why the IDF term uses a logarithm
  2. When would raw term counts outperform TF-IDF?
  3. You add bigrams and accuracy drops. What happened?
  4. Why does removing stop words sometimes hurt a sentiment model?
  5. Stemming versus lemmatization — and would you use either today?

The rule to state out loud: preprocessing must be chosen against the task, not applied because it is the default.

Interview question 1 of 2

Your document-term matrix is 10⁶ × 10⁵. How do you store and work with it?

  • A million documents against a hundred-thousand-word vocabulary is \(10^{11}\) cells: stored as 8-byte float64 values, the data area alone is 800 GB
  • Text matrices typically run 0.1–1% non-zero. Compressed sparse row (CSR) holds three lists: the non-zero values, the column each value belongs to, and where each row starts
  • At 0.1% density that is \(10^8\) values, at 12 bytes per entry roughly 1.2 GB: a saving of roughly 670×
  • Sparsity is not a storage trick, it is a constraint on which operations you are allowed to perform

Interview question 2 of 2

Your text classifier performs well offline and badly in production. Walk me through your debugging.

  1. Train/serve skew: is the identical preprocessing pipeline running in both places — including the fitted TfidfVectorizer, its vocabulary and its IDF vector?
  2. Vocabulary drift: new product names and new error codes map to no column at all, so the model never sees them
  3. Distribution shift in the label mix
  4. A leak in the offline evaluation, such as fitting the vectorizer before the train/test split
  5. Only then, the model

Where to go deeper

Your goal Starting sources
I have thirty minutes the 3Blue1Brown video, the scikit-learn text-features page
I want the TF-IDF and IR side Manning et al.’s IR book (the idf and BM25 pages), the scikit-learn docs
I want the academic reference Jurafsky & Martin, the original papers (Zipf, Heaps)
  • The list in the chapter is long, and you do not need all of it: decide what you want first
  • All the links are on the chapter page

What to remember

  1. Tokenization is a design decision about which units the model is shown
  2. Normalization rules can merge some distinctions of spelling or encoding; which to keep depends on the task and on the input the model in use expects
  3. A fixed word-level vocabulary cannot cover every word form in new text; today’s language models get past that limit with units smaller than a word, learned from data (chapter 8)
  4. Zipf describes the approximate empirical relation between word frequencies and their ranks, Heaps the one between the amount of text processed and the number of distinct terms observed
  5. Bag-of-words represents a document by which terms occur and how often, and loses word order
  6. Other things being equal, TF-IDF gives more weight to a term that is frequent in the document and found in few documents of the corpus
  7. Cosine similarity compares the direction of vectors rather than document length; L2 normalization is what makes that geometry convenient
  8. Bag-of-words is a fast, sparse, readable baseline that cannot directly represent order, the scope of negation, or semantic similarity

Lab and exercises

  • The chapter has six exercises, each labelled Basic, Applied or Advanced
  • Exercise 1: fit a CountVectorizer on ten documents from your own domain, print get_feature_names_out(), and find at least two tokens that are wrong
  • Exercise 2: compute the TF-IDF vector for d3 with pen and paper, then verify against TfidfVectorizer
  • Exercise 3: reproduce the classification table, then add one row: TF-IDF with min_df=5

The chapter: Text as Data. The lab follows the chapter notebook, en/chapters/02-text-as-data/02-text-as-data.ipynb in the notebooks repository.