Chapter 2 · Applied Natural Language Processing
scikit-learnThe models in this chapter work on numerical inputs. To give them text, we have to turn strings of characters into a numerical representation.
Preprocessing decides what information reaches the model, so it is part of the modelling process.
The and the both occur, and count as two different tokens; fail; and it's stay units of their ownThe and the to the same form? Keep the semicolon in fail; or remove it? Keep it's whole, or map it to it is?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.
| 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.
The recommendations are a starting point: compare the suitable options on validation data, and keep the test set apart for the final evaluation.
str.lower() applies Unicode’s default lowercase mapping; it takes no locale parameter for Turkish. Turkish has two separate mappings: I → ı and İ → iiCountVectorizer produces the token stanbul from the input İSTANBULOnce the preprocessing is fixed, the vocabulary and the models that depend on it may have to be rebuilt.
| 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 |
kullanıyorum and kullandıkPorter 1980
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 usednot, is removed, the information needed to tell meanings apart can be lostWeigh the information lost when you remove stop words. Whether that is a saving or a mistake depends on the task.
Subword methods are covered in chapter 8, together with the pretrained models that use them. For now we continue with words.
Both are approximate, observed regularities; neither dictates how tokenization has to be done.
Lü, Zhang & Zhou 2010
pump a hundred times is one type and a hundred tokens\[f(r) \propto r^{-\alpha}\]
| 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.
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 occurrencesThe numbers belong to this corpus and this threshold; how the filter affects accuracy has to be measured separately.
\[|V(N)| \approx K N^{\beta}\]
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
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.
roman.bmp, with lines of the form >'AX>'AX>'AXLooking at the list of most frequent tokens is a simple way to catch data and preprocessing problems before any weighting.
'ax the corpus total is high and the number of documents containing it is lowscikit-learn’s default token_pattern produces ax from the same documents, and ax still ranks second after the, with 62,387 occurrencesTF-IDF combines the term’s frequency in each document (TF) with an IDF weight computed from how many documents contain the term.
failed→0, pump→2, the→6pump = [0 0 1 0 0 0 0 0 0][1 0 1 0 0 0 1 0 0][1.51 0 1.51 … 1.00 …][0.64 0 0.64 … 0.42 …]Every number shown is the one the code actually produces for d1 = "the pump failed".
pump is exactly as similar to valve as it is to repairedOne-hot is an intermediate structure here, not the final way to represent word meaning. Learned dense vectors — embeddings — are chapter 3.
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 treatmentTo give a term a high weight we want two things:
scikit-learn does not divide it by document lengthTF-IDF is the product of the answers. “TF-IDF” on its own is not a specification: say which variant produced a number you report.
\[\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
\[\mathrm{idf}(t) = \ln\!\left(\frac{1 + n}{1 + \mathrm{df}(t)}\right) + 1\]
\[\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”.
the is in all four documents and lands exactly on the floor of 1.0; every word unique to one document gets 1.9163The 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.
\[\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\]
the pump failed: three positions are non-zero, each with a count of 1, each multiplied by its IDFTfidfVectorizer agree to the four decimal places displayedThat final division by \(\lVert v \rVert_2\) is L2 normalization.
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?
scikit-learn returns a cosine of 0, which is a convention: count these rows and handle them separatelyscikit-learn’s default; other tools may choose a different formula and normalizationOnly 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
\[\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\]
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 equallyThe weighting is what decides which shared word counts as evidence.
the; by reducing the relative contribution of the shared the, TF-IDF brings that score down to 0.11A 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.
| 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 |
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.
Information discarded at the representation stage cannot be recovered by a stronger classifier.
The feature space grows quickly as the context gets longer; long-range, context-sensitive relations need a more general representation.
scikit-learn’s built-in English list does — the two vectors become identical (cosine 1)pump failed and pump broke share nothing beyond pumpIn tasks where negation changes the decision, the stop word list needs to be checked separately.
Four newsgroups, 2,327 training documents, 1,550 test documents, logistic regression; no hyperparameter search was done.
This table is a demonstration of a mechanism and a baseline comparison. Do not read small differences as universal superiority.
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 sidescikit-learn’s own documentation calls stop_words='english' problematic. If stop words matter to your result, write the list yourself and print it.
| 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
C and reverses under an equal tuning budgetThe transferable lesson is the method, not the ranking.
| 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 | 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.
\[\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)}\]
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
Your TF-IDF matrix has 200,000 columns and 5,000 rows. Should you worry?
p ≫ n problemmin_dfmin_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 occurrencesShould IDF be computed on the training set or on all your data?
scikit-learn: fit_transform on train and transform on test. Calling fit_transform twice is a real and common bugA word appears in every document. What is its TF-IDF weight?
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 valuesmooth_idf=False too: scikit-learn still keeps the outer \(+1\) under that setting, and the result is not the textbook formulaWhy do we normalize document vectors but not feature columns?
float64 arrayStandardScaler(with_mean=False) can keep the sparse structureYour search engine returns nothing for the part number “P-101”. Why?
scikit-learn’s default token_pattern requires two or more word characters and does not cross the hyphen: P-101 silently became 101get_feature_names_out() and read itWhen 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:
Five of the chapter’s interview questions. The chapter groups them by what they test.
The rule to state out loud: preprocessing must be chosen against the task, not applied because it is the default.
Your document-term matrix is 10⁶ × 10⁵. How do you store and work with it?
float64 values, the data area alone is 800 GBYour text classifier performs well offline and badly in production. Walk me through your debugging.
TfidfVectorizer, its vocabulary and its IDF vector?| 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) |
CountVectorizer on ten documents from your own domain, print get_feature_names_out(), and find at least two tokens that are wrongTfidfVectorizermin_df=5The 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.
Uygulamalı Doğal Dil İşleme · Applied NLP