Module 9: Classical NLP 40 min8 min quick readNLP
From text to vectors
Tokenize and normalize text, build bag-of-words and n-gram features, compute TF-IDF and cosine similarity by hand, and train a strong classical text baseline.
Before this: Vectors and matrices, Naive Bayes
Quick read: the intuition, interactive visualization, summary, and interview questions. The math, implementation, engineering, and knowledge-check sections are hidden.
Mental model. Give every word in the vocabulary its own column and count how often it appears in each document. Then down-weight words that appear everywhere ("the") and up-weight words that are frequent in this document but rare elsewhere. That weighted count vector is TF-IDF.
You will learn to
- Tokenize and normalize text, and know which cleaning steps matter for which models.
- Build a document-term matrix with unigrams and n-grams.
- Compute term frequency, document frequency, IDF, and TF-IDF for a word by hand.
- Compute cosine similarity between documents.
- Build a TF-IDF plus linear-model classifier and know its limits.
Why it matters. TF-IDF with a linear model is fast, cheap, interpretable, and an excellent baseline that transformers must beat to justify their cost. Sparse retrieval (BM25, a TF-IDF relative) is still a core part of production search and hybrid RAG.
1. Intuition
Models need numbers. The simplest way to turn a document into numbers:
- Tokenize: split text into units (words, subwords, or characters).
- Normalize: lowercase, normalize Unicode, maybe remove punctuation. Aggressive cleaning can destroy meaning ("not" removed as a stop word flips sentiment), and transformers generally need none of it.
- Build a vocabulary from the training set.
- Count: each document becomes a vector with one entry per vocabulary word. This is bag-of-words: word order is thrown away.
N-grams recover a little order by also counting adjacent pairs ("not good") or triples.
Raw counts overweight common words. TF-IDF multiplies each count by how rare the word is across documents, so distinctive words dominate.
Cosine similarity compares two document vectors by angle, not length, so a long and a short document about the same topic still look similar.
2. Visualization
TF-IDF lab
Edit the corpus (one document per line). Click any cell to see its calculation. Formula: TF × ln(N / DF), with raw counts for TF.
| d1 | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| d2 | ||||||||||||||||
| d3 | ||||||||||||||||
| d4 | ||||||||||||||||
| IDF | 1.39 | 0.69 | 1.39 | 1.39 | 1.39 | 1.39 | 1.39 | 1.39 | 0.29 | 1.39 | 1.39 | 1.39 | 1.39 | 1.39 | 0.00 | 1.39 |
Cosine similarity between documents (TF-IDF vectors)
| d1 | d2 | d3 | d4 | |
|---|---|---|---|---|
| d1 | 1.00 | 0.02 | 0.00 | 0.09 |
| d2 | 0.02 | 1.00 | 0.00 | 0.01 |
| d3 | 0.00 | 0.00 | 1.00 | 0.00 |
| d4 | 0.09 | 0.01 | 0.00 | 1.00 |
Documents sharing no rare words score 0, even if they mean similar things. Embeddings fix that; see the next lesson.
Try it (predict first, then check):
- Predict the IDF of "the" before clicking its column. Then check the worked calculation.
- Add a near-duplicate document. Which pair of documents gets the highest cosine similarity?
3. The math
Symbols
| Symbol | Meaning |
|---|---|
| a term (word or n-gram) | |
| a document | |
| number of documents | |
| count of in (other variants normalize by length) | |
| number of documents containing | |
| inverse document frequency |
Formulas
A word in every document has . scikit-learn's default uses a smoothed variant, , and L2-normalizes each row, so its numbers differ from hand calculations.
Worked example 1: bag-of-words
Documents "machine learning is useful" and "machine learning learning." Vocabulary (alphabetical): is, learning, machine, useful.
| is | learning | machine | useful | |
|---|---|---|---|---|
| doc 1 | 1 | 1 | 1 | 1 |
| doc 2 | 0 | 2 | 1 | 0 |
With bigrams, "the movie was not good" contributes "not good" as its own feature, which a unigram model can't represent.
Worked example 2: TF-IDF (the course example)
documents. "gradient" occurs 3 times in document and appears in 5 documents overall.
Worked example 3: a three-document corpus
Corpus: (1) "the model learns from data", (2) "gradient descent trains the model", (3) "the cat sat on the mat". For document 2:
| Word | TF | DF | IDF | TF-IDF |
|---|---|---|---|---|
| the | 1 | 3 | 0 | |
| model | 1 | 2 | 0.405 | |
| gradient | 1 | 1 | 1.099 |
"gradient" is the most distinctive word in document 2; "the" carries nothing.
Worked example 4: cosine similarity
and : ; ; . .
4. Implementation
Build it from scratch
The whole pipeline is about 40 lines of NumPy. We build it on the three-document corpus from worked example 3, so you can check every number against section 3.
Tokenize. A regular expression keeps runs of letters (with one optional apostrophe part, so "don't" stays one token) and runs of digits. Everything else, including punctuation, is dropped. Then lowercase.
TOKEN_RE = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?|[0-9]+")
def tokenize(text):
return [t.lower() for t in TOKEN_RE.findall(text)]Predict: what does tokenize("Don't pay $40.99!") return?
Vocabulary and counts. The vocabulary maps each training word to a column index. Sorting it matches scikit-learn's column order, which makes the comparison at the end easy. The count matrix has shape : one row per document, one column per vocabulary word.
def build_vocab(docs):
return {w: j for j, w in enumerate(sorted({t for doc in docs for t in doc}))}
def count_matrix(docs, vocab):
X = np.zeros((len(docs), len(vocab)))
for i, doc in enumerate(docs):
for t in doc:
if t in vocab: # words unseen at fit time are silently dropped
X[i, vocab[t]] += 1
return XPredict: a query contains a word that never appeared in training. What does it contribute to the query vector?
Document frequency and IDF. DF counts the documents with a nonzero count in each column. We support both the course formula and scikit-learn's smoothed .
def idf(X, smooth=False):
N = X.shape[0]
df = (X > 0).sum(axis=0) # [V] number of documents containing each term
if smooth:
return np.log((1 + N) / (1 + df)) + 1
return np.log(N / df)Predict: with smooth=True, what is the IDF of "the", which is in all 3 documents? Is it still zero?
TF-IDF rows and search. Multiply each column by its IDF, then scale each row to unit length. After that, cosine similarity is a plain dot product, so ranking all documents against a query is one matrix-vector product.
def tfidf(X, idf_vec):
W = X * idf_vec # [N, V] broadcast: column j scaled by idf[j]
norms = np.linalg.norm(W, axis=1, keepdims=True)
return W / np.where(norms == 0, 1, norms)
def search(query, vocab, idf_vec, D):
q = tfidf(count_matrix([tokenize(query)], vocab), idf_vec)[0]
return D @ q # [N] cosine scores (both sides unit length)Predict: a query shares no words with any document. What scores come back, and why doesn't the code divide by zero?
Use the library
scikit-learn's TfidfVectorizer does the same steps, with the smoothed IDF and L2 rows by default. The script checks that our matrix matches it to the last bit. In practice you pair it with a linear model:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
text_model = make_pipeline(
TfidfVectorizer(ngram_range=(1, 2), min_df=2, max_features=50_000, sublinear_tf=True),
LogisticRegression(max_iter=1000, C=4.0),
)
text_model.fit(train_texts, train_labels)
# Which words push predictions most? (interpretability for free)
vec, clf = text_model[0], text_model[-1]
top = clf.coef_[0].argsort()[-15:]
print(vec.get_feature_names_out()[top])What the library adds over our version: a sparse matrix (a real vocabulary has 10⁵ or more columns, almost all zero), n-grams, min_df/max_df pruning, sublinear TF (), and a fitted object you can save and apply to new text with exactly the training vocabulary.
Run it
From guide/:
uv run code/09-classical-nlp/tfidf_scratch.pyKey output:
'the': TF in doc 2 = 1, DF = 3, IDF = 0.000, TF-IDF = 0.000
'model': TF in doc 2 = 1, DF = 2, IDF = 0.405, TF-IDF = 0.405
'gradient': TF in doc 2 = 1, DF = 1, IDF = 1.099, TF-IDF = 1.099
max |scratch - sklearn| = 0.00e+00
cosine similarity matrix:
[[1. 0.236 0.152]
[0.236 1. 0.152]
[0.152 0.152 1. ]]
query scores: [0.182 0.81 0. ] -> best doc 2The first three lines are worked example 3. The similarity matrix is the one in the figure. The query "how does gradient descent train a model" scores 0 against the cat document because they share no words. Note that "train" does not match "trains": without stemming, they are different columns.
The figure script (bag-of-words, bigrams, the course example, the embedding preview) is code/09-classical-nlp/nlp.py.
5. Engineering
Strengths. Trains in seconds on millions of documents; runs on a CPU in microseconds; coefficients explain predictions; works with little labeled data; strong for topic and keyword-driven tasks (spam, ticket routing, document categorization).
Limits. No semantics: "cat" and "kitten" are unrelated columns, so "my cat sleeps" and "a kitten sleeps" share only "sleeps." Order is lost beyond the n-gram window. The vocabulary is frozen at training time, so new words are ignored. Vectors are huge and sparse.
Preprocessing. Fit the vocabulary and IDF on training data only. Stop-word removal and stemming can help classical models on small data; test rather than assume. Keep negations. Use min_df to drop typos and max_df to drop near-universal words.
Sparse retrieval. BM25 refines TF-IDF with term-frequency saturation and document-length normalization, and remains a strong first-stage retriever, often combined with dense embeddings (hybrid search).
Watch outFailure modes. Fitting the vectorizer on all data (leakage); removing words that carry meaning ("not", "no"); assuming two documents are unrelated because they share no words; a vocabulary that goes stale as language drifts.
Common mistakes
- Comparing hand-computed TF-IDF with scikit-learn's smoothed, normalized values and assuming a bug.
- Using raw counts with cosine similarity and forgetting how stop words dominate.
- Huge n-gram ranges that explode the feature count with mostly unique features.
6. Knowledge check
Quiz: From text to vectors
documents; a term appears in 10 of them. What is its IDF, ? (3 decimals)
Practice exercise. In a corpus of 50 documents, "transformer" appears in 2 documents and occurs 4 times in document . Compute its TF-IDF in with the course formula.
Solution
IDF . TF-IDF .
Implementation challenge. Extend code/09-classical-nlp/tfidf_scratch.py with a BM25 scorer: for each query term in document , add with , , where is the document's token count and avgdl the average. Rank the three documents for the same query and compare with the TF-IDF ranking.
Solution sketch
def bm25(query, docs, vocab, X, k1=1.5, b=0.75):
idf_vec = idf(X, smooth=True)
lens = X.sum(axis=1) # [N] document lengths
q = [vocab[t] for t in tokenize(query) if t in vocab]
tf = X[:, q] # [N, |q|]
norm = k1 * (1 - b + b * lens / lens.mean())
return (idf_vec[q] * tf * (k1 + 1) / (tf + norm[:, None])).sum(axis=1)Document 2 still ranks first. The difference shows on longer corpora: a term repeated 20 times scores far less than 20 times a single occurrence (saturation), and long documents are not favored just for being long.
Summary
- Tokenize, build a training vocabulary, and count: bag-of-words; n-grams keep short phrases.
- TF-IDF up-weights distinctive words and zeroes words found everywhere.
- Cosine similarity compares direction, not length.
- TF-IDF plus a linear model is a fast, interpretable baseline; it has no notion of meaning beyond shared words.
Next: Word embeddings
Related: Naive Bayes · The RAG pipeline
Go deeper, code-first (AI Engineering from Scratch): Text Processing — Tokenization, Stemming, Lemmatization · Sentiment Analysis · POS Tagging and Syntactic Parsing
Parts of this lesson are adapted from AI Engineering from Scratch by Rohit Ghumare (MIT license, notice): Bag of Words, TF-IDF, and Text Representation.
Interview angle
Explain TF-IDF. Why is the IDF term needed?
TF-IDF weights a term in a document by how often it appears there (TF) times how rare it is across the corpus, . Raw counts are dominated by frequent words like "the" and "is" that appear everywhere and say nothing about topic; IDF scales them toward zero (a word in every document gets ) and boosts distinctive words. With documents, a term found in 5 of them gets , and three occurrences give TF-IDF . Rows are usually L2-normalized so long documents don't dominate cosine similarity. Libraries differ: scikit-learn uses a smoothed , so hand calculations won't match exactly. Limits: no semantics and no word order beyond n-grams. BM25 refines TF-IDF with term-frequency saturation and length normalization and remains a strong retrieval baseline.
TF-IDF with logistic regression, or a fine-tuned transformer, for text classification? How do you decide?
Start with TF-IDF plus a linear model: it trains in seconds on a CPU, serves in microseconds, needs little labeled data, and its coefficients explain predictions. On keyword-driven tasks (spam, ticket routing, topic labeling) it's often within a few points of a transformer. Choose a transformer when meaning depends on context, word order, negation, or paraphrase ("not bad at all"), when vocabulary varies widely (synonyms, misspellings, many languages), or when labels are scarce and pretrained knowledge helps; sentiment and intent tasks usually gain the most. The costs: GPU training, tens of milliseconds or more of inference latency, more memory, and harder debugging. A middle option is frozen sentence embeddings plus logistic regression. Decide by measurement: build the TF-IDF baseline first and take on transformer complexity only if the gap matters for the business metric.
Your bag-of-words sentiment classifier, built with stop-word removal, labels "not good" reviews as positive. What went wrong?
Two compounding issues. Many standard stop-word lists include negations such as "not," "no," and "nor," so removal turns "not good" into "good." And even with negations kept, a unigram bag-of-words treats "not" and "good" as independent features, so it can't represent that "not" flips "good." Fixes: keep negation words (use a custom stop-word list, or skip stop-word removal, which rarely helps regularized linear models anyway); add bigrams with ngram_range=(1, 2) so "not good" becomes its own feature, which usually gives a clear gain on sentiment; or apply negation marking, prefixing tokens after "not" until the next punctuation. Long-range negation and sarcasm need contextual models. The broader lesson: validate preprocessing choices on held-out data instead of applying them by default, and read the misclassified examples.
In a 1,000-document corpus, "refund" appears in 10 documents and "the" in all 1,000. A ticket contains "refund" twice and "the" 20 times. Compute their TF-IDF weights. What would raw counts have said?
IDF of "refund" is , so its TF-IDF is . IDF of "the" is , so its weight is despite 20 occurrences. With raw counts, "the" would be the ticket's largest coordinate, ten times "refund," and cosine similarity between tickets would mostly measure how many function words they share. TF-IDF flips this: the vector points toward "refund," which is what a router or search engine needs. Two follow-ups: scikit-learn's smoothed IDF gives "the" a weight of per occurrence rather than zero, which row normalization then dilutes; and TF is often made sublinear, , so that 20 repetitions don't count 20 times as much as one.