🎯 What You'll Learn
- Why NLP is fundamentally harder than image or tabular data — ambiguity, context, idioms, long-range dependencies
- Build a complete text preprocessing pipeline: lowercasing, punctuation removal, HTML stripping, stopword removal, stemming, and lemmatization
- Understand word, sentence, character, and subword tokenization — and when to use each
- Understand Byte-Pair Encoding (BPE) and WordPiece — the algorithms behind the tokenizers of GPT and BERT (the famous models you'll work with hands-on in Lessons 54–55)
- Represent documents with Bag of Words (
CountVectorizer) and understand its limitations - Compute TF-IDF scores from scratch and use
TfidfVectorizercorrectly - Build a spam classifier pipeline and understand why TF-IDF works — and where it fails
Language is the hardest data type for computers. An image is a grid of numbers. A sound wave is a sequence of numbers. But text? Text is symbols that carry meaning through context, grammar, idioms, sarcasm, culture, and shared knowledge. "The bank was steep" — does that describe a riverbank or a financial institution? You need the surrounding sentence to know. Before we can feed text to any model, we need to convert it into numbers — and that conversion process (tokenization and vectorization) is what this lesson is about. Every choice we make in this pipeline has consequences for model quality.
1 Why NLP Is Hard
Before writing a single line of preprocessing code, it's worth understanding why text is so challenging. If you understand the challenges, every preprocessing step you add will make intuitive sense rather than feeling like an arbitrary checklist.
Ambiguity at Every Level
Human language is fundamentally ambiguous at almost every level of analysis:
- Lexical ambiguity: the word "bank" means a riverbank, a financial institution, a verb (to bank on something), or a term in aviation (banking a turn). One word, four different meanings — context resolves the ambiguity.
- Syntactic ambiguity: "I saw the man with the telescope." Did I use a telescope to see him? Or is the man carrying a telescope? Both parses are grammatically valid. A computer that can't resolve this will misunderstand the sentence.
- Semantic ambiguity: "The chicken is ready to eat." Is the chicken hungry? Or is it a meal? Context again.
- Pragmatic ambiguity: "Can you pass the salt?" is syntactically a yes/no question, but pragmatically it's a request for action.
Sarcasm and Irony
"Oh great, another Monday." The words "great" and "Monday" are both neutral — but every English speaker reads this as negative. A keyword-based model will completely miss the sarcasm. Even modern neural models struggle significantly with sarcasm and irony because they require understanding of shared cultural knowledge.
Long-Range Dependencies
Consider: "The students who attended the lecture given by the professor who won the Nobel Prize in Physics were exhausted." The verb "were exhausted" refers to "students" — but 17 words separate them. A model that can only look at local context will misidentify the subject. This is exactly the problem that LSTMs (Lesson 48) and Transformers (Lessons 52–53) were designed to solve.
Vocabulary Explosion
The English language has over 470,000 words, plus proper nouns, technical terms, abbreviations, slang, and multiword expressions. A model trained on Wikipedia will never see most medical terms. A model trained on medical text won't know internet slang. There is no single complete vocabulary.
A naive approach to NLP might seem like: create a giant dictionary mapping every word to a number, and train on that. The problem: (1) new words appear constantly — what happens to "COVID", "selfie", "deepfake"? (2) the same word means different things in different contexts. (3) a dictionary ID doesn't capture that "happy" is related to "joyful" but unrelated to "banana". This is why we need the full pipeline covered in this lesson, and why it ultimately leads to word embeddings (Lesson 50) and transformers.
Multilinguality
German compounds words aggressively: "Donaudampfschifffahrtsgesellschaft" (Danube Steamship Company) is one word. Finnish inflects nouns into 15+ cases. Chinese has no spaces between words — "wordtokenization" would be one token. Arabic reads right-to-left and letters change shape based on position. A single preprocessing approach cannot handle all human languages — this is why language-agnostic tokenizers (SentencePiece) were invented.
2 Text Preprocessing Pipeline
A preprocessing pipeline is a sequence of transformations that cleans and normalises raw text before tokenization. The goal: remove noise that doesn't carry semantic meaning, and standardize variations that should be treated as identical.
Think of it like data cleaning for images: you'd rescale pixel values to [0, 1], convert color images to a consistent color space, and remove corrupt files. For text, the equivalent is: lowercase, remove HTML, remove special characters, and normalize word forms.
Step 1: Lowercase
The simplest step: convert all characters to lowercase. Why? "Python", "python", and "PYTHON" should all be treated as the same word. Without lowercasing, your vocabulary triples unnecessarily.
text = "The Quick Brown Fox Jumps Over The LAZY Dog"
lower = text.lower()
print(lower)
# the quick brown fox jumps over the lazy dog
When to skip it: named entity recognition (NER) — "Apple" (company) vs "apple" (fruit). Proper casing is often the first signal that a word is a named entity.
Step 2: Remove HTML Tags
Web-scraped text often contains HTML tags like <p>, <br>, <span class="...>. These are noise for any text model.
from bs4 import BeautifulSoup
html_text = "<p>The movie was <strong>absolutely</strong> fantastic!</p>"
clean = BeautifulSoup(html_text, "html.parser").get_text()
print(clean) # The movie was absolutely fantastic!
Step 3: Remove Special Characters and Punctuation
Punctuation is generally noise for bag-of-words models (though it matters for syntactic parsing). Regular expressions handle this efficiently.
import re
text = "Hello, World! This is NLP: amazing... isn't it? #NLP @python"
# Remove everything that isn't a letter or space
clean = re.sub(r'[^a-zA-Z\s]', '', text)
print(clean)
# Hello World This is NLP amazing isnt it NLP python
When to be careful: "U.S.A." → "USA" is fine. But "don't" → "dont" loses the negation. Be aware that aggressive punctuation removal can harm meaning.
Step 4: Remove Stopwords
Stopwords are extremely common words that carry little discriminative meaning: "the", "a", "is", "in", "of", "and". In a bag-of-words model, they appear in almost every document and add noise to the feature space.
import nltk
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize
# Download NLTK data (first time only)
nltk.download('stopwords', quiet=True)
nltk.download('punkt', quiet=True)
stop_words = set(stopwords.words('english'))
print(f"Number of English stopwords: {len(stop_words)}")
print(f"Sample: {list(stop_words)[:10]}")
# Number of English stopwords: 179
# Sample: ['i', 'me', 'my', 'myself', 'we', 'our', ...]
text = "The movie was absolutely the best film I have ever seen in my life"
tokens = word_tokenize(text.lower())
filtered = [w for w in tokens if w not in stop_words and w.isalpha()]
print("Original tokens:", tokens)
print("After stopword removal:", filtered)
# Original: ['the', 'movie', 'was', 'absolutely', 'the', 'best', 'film', 'i', 'have', ...]
# Filtered: ['movie', 'absolutely', 'best', 'film', 'ever', 'seen', 'life']
BERT was pre-trained on complete English sentences including all stopwords. Removing them creates a distribution mismatch — the model has never seen input without stopwords during training. More importantly, stopwords contribute to grammatical meaning: "not good" vs "good" — if you remove "not" as a stopword, these sentences become identical. For BoW/TF-IDF: remove stopwords. For transformer models: keep them.
Step 5: Stemming
Stemming crudely trims word endings to find a common root form. "running", "runs", "runner" all stem to "run". It's fast but imprecise — it applies rules mechanically without understanding the word.
from nltk.stem import PorterStemmer
stemmer = PorterStemmer()
words = ["running", "runner", "runs", "studies", "studying", "studied",
"easily", "fairness", "happiness", "university"]
for word in words:
stem = stemmer.stem(word)
print(f" {word:15s} → {stem}")
# running → run
# runner → runner
# runs → run
# studies → studi ← over-stemmed! Not a real word
# studying → studi ← both map to same broken form
# studied → studi
# easily → easili ← over-stemmed
# fairness → fair
# happiness → happi ← over-stemmed
# university → univers
Notice the problems: "studies" → "studi" is not a valid English word. But crucially, "studies" and "studying" and "studied" all map to the same stem — which is what we want for BoW models where these should be treated as the same concept.
Step 6: Lemmatization
Lemmatization uses a vocabulary and morphological analysis to return the correct dictionary form of a word (the "lemma"). It's slower than stemming but produces valid English words and more accurate results.
import spacy
# Load English model: python -m spacy download en_core_web_sm
nlp = spacy.load("en_core_web_sm")
text = "The geese were flying and the cats ran quickly through the forests"
doc = nlp(text)
for token in doc:
print(f" {token.text:15s} → lemma: {token.lemma_:15s} POS: {token.pos_}")
# The → lemma: the POS: DET
# geese → lemma: goose POS: NOUN ← irregular plural
# were → lemma: be POS: AUX
# flying → lemma: fly POS: VERB
# cats → lemma: cat POS: NOUN
# ran → lemma: run POS: VERB ← irregular past tense
# quickly → lemma: quickly POS: ADV
# through → lemma: through POS: ADP
# forests → lemma: forest POS: NOUN
Lemmatization correctly handles irregular forms: "geese" → "goose", "ran" → "run", "were" → "be". Stemming would either over-cut or miss these entirely.
Let's trace one real sentence — the same one from Step 4 — through every stage of the pipeline, end to end:
The example sentence from Step 4 traced through the full pipeline. Each stage strips away noise: casing differences (step 2), word boundaries become explicit (step 3), high-frequency low-signal words disappear (step 4, in amber), and remaining words collapse to their dictionary form (step 5) — "best" → "good". What survives are 7 tokens carrying most of the sentence's meaning in a fraction of the words.
Full Preprocessing Pipeline
import re
import nltk
import spacy
from bs4 import BeautifulSoup
from nltk.corpus import stopwords
nltk.download('stopwords', quiet=True)
nltk.download('punkt', quiet=True)
nlp = spacy.load("en_core_web_sm")
stop_words = set(stopwords.words('english'))
def preprocess_text(text, remove_stopwords=True, use_lemmatization=True):
"""
Full NLP preprocessing pipeline.
Returns: list of cleaned tokens
"""
# 1. Remove HTML tags
text = BeautifulSoup(text, "html.parser").get_text()
# 2. Lowercase
text = text.lower()
# 3. Remove URLs
text = re.sub(r'http\S+|www\S+', '', text)
# 4. Remove special characters (keep letters and spaces)
text = re.sub(r'[^a-z\s]', '', text)
# 5. Remove extra whitespace
text = re.sub(r'\s+', ' ', text).strip()
# 6. Lemmatize (using spaCy) OR just tokenize
if use_lemmatization:
doc = nlp(text)
tokens = [token.lemma_ for token in doc if token.is_alpha]
else:
tokens = text.split()
# 7. Remove stopwords (optional — skip for BERT!)
if remove_stopwords:
tokens = [t for t in tokens if t not in stop_words]
return tokens
# Test the pipeline
examples = [
"<p>The movie was absolutely FANTASTIC! Best film I've <b>ever</b> seen.</p>",
"Running faster than the fastest runners in the world is running.",
"Check out https://example.com for more info!! 123 special @chars#"
]
for text in examples:
result = preprocess_text(text)
print(f"Input: {text[:60]}...")
print(f"Output: {result}")
print()
Stopwords: skip for BERT/transformer models, keep for BoW/TF-IDF. Lemmatization: skip for BERT (it uses subword tokenization that handles morphology differently). Lowercase: skip for NER tasks where case matters. HTML removal: skip if your data isn't from the web. The right pipeline depends entirely on your model and your data source.
3 Tokenization: Splitting Text Into Units
Tokenization is the process of splitting a string of text into discrete units called tokens. It sounds simple — just split on spaces, right? In practice, tokenization has several important variants, each with different tradeoffs.
Word Tokenization
The most natural approach: split on whitespace (and punctuation). This is what you do implicitly when you read. But English typography makes it surprisingly tricky.
from nltk.tokenize import word_tokenize
texts = [
"I can't believe it's not butter!", # contractions
"Dr. Smith visited the U.S.A. yesterday.", # abbreviations
"The price is $9.99 + tax.", # symbols and numbers
"The email is user@example.com.", # email addresses
"She said 'hello world' to me." # quotes
]
for text in texts:
tokens = word_tokenize(text)
print(f"Input: {text}")
print(f"Tokens: {tokens}")
print()
# Input: I can't believe it's not butter!
# Tokens: ['I', 'ca', "n't", 'believe', 'it', "'s", 'not', 'butter', '!']
# ↑ splits contractions intelligently!
# Input: Dr. Smith visited the U.S.A. yesterday.
# Tokens: ['Dr.', 'Smith', 'visited', 'the', 'U.S.A.', 'yesterday', '.']
# ↑ keeps abbreviations with their periods!
NLTK's word_tokenize handles contractions correctly: "can't" → ["ca", "n't"], not ["can't"] as one token. This matters because "can" and "n't" (negation) have different semantic roles.
Sentence Tokenization
Sometimes you need to split text into sentences first, then words. This is useful for tasks where sentence boundaries matter (text summarization, question answering, reading comprehension).
from nltk.tokenize import sent_tokenize
text = """
Dr. Smith gave a presentation on A.I. research.
The audience was amazed. "Will this work?" asked Prof. Johnson.
It remains to be seen. However, the early results are promising.
"""
sentences = sent_tokenize(text.strip())
for i, sent in enumerate(sentences, 1):
print(f"Sentence {i}: {sent}")
# Sentence 1: Dr. Smith gave a presentation on A.I. research.
# ↑ Correctly treats "Dr." and "A.I." as abbreviations, not sentence endings!
# Sentence 2: The audience was amazed.
# Sentence 3: "Will this work?" asked Prof. Johnson.
# Sentence 4: It remains to be seen.
# Sentence 5: However, the early results are promising.
Character Tokenization
Instead of words, use individual characters as tokens. Every possible input is a sequence of characters — no vocabulary is ever too small, and there are no unknown tokens.
text = "Hello, NLP!"
# Character tokenization: each character is a token
char_tokens = list(text)
print(char_tokens)
# ['H', 'e', 'l', 'l', 'o', ',', ' ', 'N', 'L', 'P', '!']
# Encoding: map characters to integers
vocab = sorted(set(char_tokens))
char_to_id = {c: i for i, c in enumerate(vocab)}
ids = [char_to_id[c] for c in char_tokens]
print(f"Vocabulary: {vocab}")
print(f"Token IDs: {ids}")
# Pros:
# - No OOV (out-of-vocabulary) words — every character is known
# - Small fixed vocabulary (~100 characters for English)
# Cons:
# - Extremely long sequences — "The cat" = 7 characters vs 2 words
# - Each character carries little semantic information
# - Harder for model to learn word-level patterns from characters
Word: best for BoW/TF-IDF and classical ML. Large vocabulary, but each token is semantically rich. Character: good for very noisy text (social media, typos), spell checking, character-level language models. Very long sequences. Subword (next section): the best of both worlds — used by virtually all modern transformer models (BERT, GPT, T5). Medium vocabulary, handles OOV gracefully.
4 Subword Tokenization: The Best of Both Worlds
Word tokenization has a fundamental problem: what happens to words your model has never seen? If your training data contains "running" but the test sentence contains "sprinting", your word-level model treats "sprinting" as unknown. This is the Out-of-Vocabulary (OOV) problem.
Character tokenization fixes OOV but creates sequences that are too long and semantically sparse. The elegant solution: subword tokenization. Common words stay as single tokens ("cat", "the", "running"). Rare or unknown words get split into smaller, known subword pieces ("tokenization" → ["token", "ization"], "unhappiness" → ["un", "happiness"]).
Byte-Pair Encoding (BPE) — Used by GPT
BPE starts with a character-level vocabulary and iteratively merges the most frequent adjacent pair of tokens into a new single token. Repeat until the vocabulary reaches a target size.
# BPE algorithm intuition (simplified)
# Start with character-level vocabulary of training corpus
# Corpus: "low low lower lowest new newer"
corpus_frequencies = {
('l','o','w','</w>'): 5, # "low" appears 5 times
('l','o','w','e','r','</w>'): 2, # "lower"
('l','o','w','e','s','t','</w>'): 1, # "lowest"
('n','e','w','</w>'): 6, # "new"
('n','e','w','e','r','</w>'): 3, # "newer"
}
# Step 1: Find most frequent pair → ('e', 'r') with freq 2+3=5
# Merge: now 'er' is a token
# Step 2: Find most frequent pair → ('l', 'o') with freq 5+2+1=8
# Merge: now 'lo' is a token
# ... after many merges, the vocabulary contains:
# 'low', 'er', 'est', 'new', 'lo', 'w', etc.
# Real BPE with the `tokenizers` library (Hugging Face)
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
# Train a small BPE tokenizer
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()
trainer = BpeTrainer(vocab_size=1000, special_tokens=["[UNK]", "[CLS]", "[SEP]"])
# In practice, train on a text file:
# tokenizer.train(files=["corpus.txt"], trainer=trainer)
# GPT-2 uses BPE with vocab_size=50,257
# Example output for an unseen word:
print("Tokenization examples (BPE-like):")
print(" 'unhappiness' → ['un', 'happiness']")
print(" 'tokenization' → ['token', 'ization']")
print(" 'COVID-19' → ['CO', 'VID', '-', '19']")
print(" 'supercalifragilistic' → ['super', 'cal', 'if', 'rag', 'ilis', 'tic']")
WordPiece — Used by BERT
WordPiece is similar to BPE but uses a different merge criterion (maximises likelihood of the training data rather than raw frequency). BERT's vocabulary has 30,522 tokens. The distinctive feature: continuation subwords are prefixed with "##".
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
examples = [
"tokenization is fascinating",
"Let's do some NLP!",
"unhappiness and disappointment",
"COVID-19 vaccination rates are increasing",
"antidisestablishmentarianism"
]
for text in examples:
tokens = tokenizer.tokenize(text)
ids = tokenizer.encode(text)
print(f"Text: {text}")
print(f"Tokens: {tokens}")
print(f"IDs: {ids[:8]}{'...' if len(ids)>8 else ''}")
print()
# Text: tokenization is fascinating
# Tokens: ['token', '##ization', 'is', 'fascinating']
# ↑ ## means "continuation of previous token"
# Text: Let's do some NLP!
# Tokens: ['let', "'", 's', 'do', 'some', 'nl', '##p', '!']
# ↑ NLP splits to nl + ##p
# Text: antidisestablishmentarianism
# Tokens: ['anti', '##dis', '##establishment', '##arian', '##ism']
# ↑ Long rare word split into 5 known subwords, all in BERT vocabulary!
The ## prefix signals that this subword continues the previous token without a space. "token" + "##ization" → "tokenization". This allows BERT to reconstruct the original word when needed. During fine-tuning, you must use the same tokenizer that was used for pre-training — mixing tokenizers will corrupt the input and break the model. Always use BertTokenizer with BERT, GPT2Tokenizer with GPT-2, etc.
SentencePiece — Language-Agnostic
Unlike BPE and WordPiece which assume space-separated words (Latin-script languages), SentencePiece works directly on raw Unicode characters. This makes it ideal for Chinese (no spaces), Japanese, Korean, and multilingual models.
import sentencepiece as spm
# SentencePiece is used by: T5, ALBERT, XLM-R, mT5, and many multilingual models
# It treats the input as a stream of characters, including spaces (represented as ▁)
# Load a pre-trained SentencePiece model (e.g., from T5)
# sp = spm.SentencePieceProcessor(model_file='t5_tokenizer.model')
# tokens = sp.encode("Hello world!", out_type=str)
# → ['▁Hello', '▁world', '!']
# The ▁ symbol marks the beginning of a word (replaces the space)
# Advantage: same model works for English, Chinese, Arabic, Finnish
print("SentencePiece examples:")
print(" English: 'Hello world' → ['▁Hello', '▁world']")
print(" Chinese: '你好世界' → ['▁你好', '世界'] (no spaces needed)")
print(" German compound: 'Donaudampfschiff' → ['▁Don', 'au', 'dam', 'pf', 'schiff']")
Comparing Tokenization Levels Side by Side
Sections 3 and 4 covered word, character, and subword tokenization separately. Here is the same rare word — "unhappiness" — run through each approach, so the tradeoffs are visible at a glance:
The same rare word tokenized three ways. Word-level either has this in its vocabulary or replaces it with a meaningless [UNK]. Subword tokenization splits it into two known, meaningful pieces ("un" + "##happiness") — the approach virtually all modern transformers use. Character-level always works but turns 1 word into 11 low-information tokens.
5 Bag of Words: Simplest Vectorization
Once we have tokens, we need to convert them to numbers. The simplest approach: Bag of Words (BoW). Ignore word order entirely; just count how many times each word appears in a document.
The Intuition
Imagine you're sorting emails into spam/not-spam. Spam emails tend to contain words like "URGENT", "FREE", "WINNER", "CLAIM". You don't need to understand the grammar — just seeing these words frequently is enough signal. BoW captures exactly this: the presence and frequency of words.
from sklearn.feature_extraction.text import CountVectorizer
import pandas as pd
# Small corpus: 4 documents
corpus = [
"The cat sat on the mat",
"The cat sat on the hat",
"The cat in the hat",
"The dog sat on the mat"
]
# CountVectorizer builds vocabulary from corpus and counts occurrences
vectorizer = CountVectorizer()
X = vectorizer.fit_transform(corpus)
# Show vocabulary
print("Vocabulary:", vectorizer.vocabulary_)
# {'cat': 0, 'dog': 2, 'hat': 3, 'in': 4, 'mat': 5, 'on': 6, 'sat': 7, 'the': 8}
# Document-term matrix
df = pd.DataFrame(
X.toarray(),
columns=vectorizer.get_feature_names_out(),
index=[f"Doc {i+1}" for i in range(len(corpus))]
)
print("\nDocument-Term Matrix:")
print(df.to_string())
# cat dog hat in mat on sat the
# Doc 1 1 0 0 0 1 1 1 2
# Doc 2 1 0 1 0 0 1 1 2
# Doc 3 1 0 1 1 0 0 0 2
# Doc 4 0 1 0 0 1 1 1 2
# Notice: "the" appears twice in every document — it's noise!
# "cat" vs "dog" actually distinguishes the documents
Pros and Cons of Bag of Words
| Property | BoW Behavior | Consequence |
|---|---|---|
| Word order | Completely ignored | "dog bites man" = "man bites dog" |
| Semantics | None — words are atomic | "car" and "automobile" are unrelated |
| Common words | Get high counts | "the" dominates — use TF-IDF instead |
| Vector size | = vocabulary size (30k–100k) | Sparse, high-dimensional — but works well with linear models |
| OOV handling | Silently ignored | New words at test time contribute nothing |
| Speed | Extremely fast | Good baseline for any NLP task |
6 TF-IDF: Smarter Weighting
The problem with raw word counts: a word like "the" might appear 50 times in every document. Its count is always the highest, but it carries zero discriminative information. TF-IDF (Term Frequency–Inverse Document Frequency) solves this by weighting words by how specific they are to a particular document.
The Core Intuition
Imagine you're a librarian trying to summarize what each book is about using keyword scores. "The" appears on every page of every book — it's not a keyword. "Mitochondria" appears in 2 out of 1000 books in the library — it's extremely informative when it does appear. TF-IDF formalises this intuition: give high scores to words that appear frequently within a document but rarely across documents.
The Formula
TF-IDF(t, d) = TF(t, d) × IDF(t)
- TF(t, d) = count of term t in document d / total terms in d (how prominent is this word in this document?)
- IDF(t) = log(N / df(t)) + 1 where N = total documents, df(t) = documents containing t (how rare is this word across the whole corpus?)
- Result: high score = word is frequent in this document but rare in other documents = discriminative keyword
import numpy as np
import math
# Manual TF-IDF calculation on a small corpus
corpus = [
"data science machine learning python", # Doc 0
"machine learning deep learning neural network", # Doc 1
"python programming web development flask", # Doc 2
"neural network deep learning computer vision", # Doc 3
]
# Tokenize (simple split)
tokenized = [doc.split() for doc in corpus]
N = len(corpus) # 4 documents
# Build vocabulary
vocab = sorted(set(word for doc in tokenized for word in doc))
print(f"Vocabulary ({len(vocab)} words): {vocab}")
# Compute TF for each document
def tf(word, doc_tokens):
return doc_tokens.count(word) / len(doc_tokens)
# Compute IDF for each word
def idf(word, all_docs):
df = sum(1 for doc in all_docs if word in doc)
return math.log(N / df) + 1 # sklearn uses this formula
# Compute TF-IDF matrix
tfidf_matrix = np.zeros((N, len(vocab)))
for i, doc_tokens in enumerate(tokenized):
for j, word in enumerate(vocab):
tfidf_matrix[i, j] = tf(word, doc_tokens) * idf(word, tokenized)
# Show which words have highest TF-IDF in each document
for i, doc in enumerate(corpus):
scores = {vocab[j]: tfidf_matrix[i, j] for j in range(len(vocab))}
top3 = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:3]
print(f"\nDoc {i}: '{doc[:40]}...'")
print(f" Top TF-IDF words: {top3}")
The chart below plots every TF-IDF score computed by that code for Doc 0 ("data science machine learning python") against the 4-document corpus above. Words are ordered by score — hover a bar to see its document frequency (df), which is what drives the ranking:
TF-IDF scores for Doc 0 ("data science machine learning python"). "data" and "science" score highest because each appears in only 1 of 4 documents (df=1) — rare and specific to this document. "learning" scores lowest of the words present because it appears in 3 of 4 documents (df=3) — common across the corpus, so less discriminative.
from sklearn.feature_extraction.text import TfidfVectorizer
import pandas as pd
# sklearn's TfidfVectorizer handles everything automatically
corpus = [
"The quick brown fox jumps over the lazy dog",
"The lazy dog sleeps all day long",
"The quick brown fox is very quick and very clever",
"Brown foxes are often quick and clever animals"
]
tfidf = TfidfVectorizer(
max_features=15, # keep only top 15 features (by TF-IDF)
stop_words='english' # built-in stopword removal
)
X = tfidf.fit_transform(corpus)
# Show as dataframe
df = pd.DataFrame(
X.toarray().round(3),
columns=tfidf.get_feature_names_out(),
index=[f"Doc {i+1}" for i in range(len(corpus))]
)
print(df.to_string())
# Notice: "the" and "the" are removed (stopwords)
# "quick" has high TF-IDF in Doc 1 and Doc 3 (appears multiple times, less common in Doc 4)
# "clever" has high TF-IDF in Doc 3 and Doc 4 (rare across corpus)
Without the log, a word appearing in 1 out of 1000 documents would get IDF = 1000, while a word in 500 documents gets IDF = 2. The difference is 500×, which would overwhelmingly dominate any TF score. With log: IDF(1/1000) = log(1000) ≈ 6.9, IDF(500/1000) = log(2) ≈ 0.7. The difference is ~10×, which is much more manageable. The logarithm compresses the scale of document frequency, preventing extremely rare words from completely dominating the model.
7 The Vocabulary and OOV Problem
Every text vectorization approach requires a vocabulary — the set of all known tokens. When the model encounters a token not in this vocabulary during inference, it's out-of-vocabulary (OOV).
How Different Approaches Handle OOV
from sklearn.feature_extraction.text import TfidfVectorizer
# Train on a corpus that doesn't contain "cryptocurrency"
train_corpus = [
"bitcoin price has risen sharply",
"ethereum blockchain technology is advancing",
"digital assets are becoming mainstream"
]
test_doc = ["cryptocurrency and bitcoin prices are volatile and unpredictable"]
vectorizer = TfidfVectorizer()
vectorizer.fit(train_corpus)
# Transform the test document
test_vec = vectorizer.transform(test_doc)
feature_names = vectorizer.get_feature_names_out()
# Check which words from test_doc are in vocabulary
test_words = test_doc[0].split()
known = [w for w in test_words if w in vectorizer.vocabulary_]
oov_words = [w for w in test_words if w not in vectorizer.vocabulary_]
print(f"Training vocabulary size: {len(feature_names)}")
print(f"Test words: {test_words}")
print(f"Known words: {known}")
print(f"OOV words: {oov_words}") # ['cryptocurrency', 'volatile', 'unpredictable']
print(f"Non-zero features in test vec: {test_vec.nnz}")
# OOV words are SILENTLY IGNORED — their information is simply lost!
Vocabulary Size Tradeoffs
from sklearn.feature_extraction.text import TfidfVectorizer
# max_features controls vocabulary size
for max_features in [1000, 5000, 10000, 50000, None]:
vec = TfidfVectorizer(max_features=max_features)
# Smaller vocab → more OOV, less memory, faster training
# Larger vocab → less OOV, more memory, slower training
label = str(max_features) if max_features else 'all'
print(f"max_features={label:>6s}: keeps top {label} words by TF-IDF score")
# Typical vocabulary sizes:
print("\nTypical NLP vocabulary sizes:")
print(" Bag of Words / TF-IDF: 10,000–100,000 words")
print(" BERT WordPiece: 30,522 subword tokens")
print(" GPT-2 BPE: 50,257 subword tokens")
print(" GPT-4 (estimated BPE): ~100,000 tokens")
print(" SentencePiece (T5): 32,000 tokens")
Subword tokenizers (BPE, WordPiece, SentencePiece) dramatically reduce the OOV problem. Since any word can be decomposed into known character n-grams, the OOV rate for well-designed subword tokenizers approaches zero — even for completely novel words, they can be tokenized character by character if necessary.
8 Limitations of BoW and TF-IDF
BoW and TF-IDF are powerful baselines — often achieving 90%+ accuracy on simple classification tasks. But they have fundamental limitations that motivate the need for word embeddings and transformers. Understanding these limitations is as important as understanding the methods themselves.
Limitation 1: Word Order Is Lost
from sklearn.feature_extraction.text import CountVectorizer
import numpy as np
vec = CountVectorizer()
sentences = [
"The dog bit the man",
"The man bit the dog"
]
X = vec.fit_transform(sentences).toarray()
print("Vocabulary:", vec.vocabulary_)
print("Doc 1:", X[0])
print("Doc 2:", X[1])
print("Identical?", np.array_equal(X[0], X[1]))
# Identical? True ← SAME VECTOR for opposite-meaning sentences!
# BoW literally cannot distinguish subject from object.
Limitation 2: No Semantic Similarity
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.feature_extraction.text import TfidfVectorizer
docs = [
"I love cars and automobiles", # "cars" and "automobiles" are synonyms
"I love cars",
"I love automobiles",
"I love bananas" # completely different topic
]
vec = TfidfVectorizer()
X = vec.fit_transform(docs).toarray()
# Cosine similarity between first document and others
sim = cosine_similarity(X[0:1], X[1:])
print("Similarity between 'cars and automobiles' and:")
print(f" 'I love cars': {sim[0][0]:.3f}")
print(f" 'I love automobiles': {sim[0][1]:.3f}")
print(f" 'I love bananas': {sim[0][2]:.3f}")
# 'cars' and 'automobiles' are in completely different vocabulary dimensions
# so 'I love cars and automobiles' is NOT highly similar to either synonym alone!
# This is the fundamental problem: no semantic relationship between words.
Limitation 3: Context-Independence
# BoW cannot distinguish different senses of the same word
sentences = [
"I deposited money at the bank", # financial bank
"We sat by the river bank", # river bank
"He banked the aircraft steeply", # aviation bank (verb)
]
# In BoW, "bank" contributes the exact same feature value
# regardless of which sense is meant.
# Word embeddings (Lesson 50) also have this problem — they're static.
# Contextual embeddings (BERT) finally solve it.
print("Fundamental BoW / static embedding limitations:")
print(" 'dog bites man' ≡ 'man bites dog' (word order)")
print(" 'car' ≈ 0 similarity to 'automobile' (synonyms)")
print(" 'bank' (finance) ≡ 'bank' (river) (polysemy)")
print(" 'not good' can look like 'good' (negation)")
print("\nMotivation for next steps:")
print(" Word Embeddings (Lesson 50) → fix: semantic similarity")
print(" Seq2Seq + Attention (L51-52) → fix: context")
print(" Transformers (Lesson 53) → fix: all of the above")
Despite their limitations, TF-IDF + logistic regression is still competitive with or better than BERT on many short-text classification tasks — especially with limited labeled data. Transformers need large amounts of fine-tuning data to outperform TF-IDF baselines. Always start with TF-IDF as your baseline before spending compute on transformers. You'll be surprised how often the simple approach is already good enough.
Real-World Spotlight: Spam Detection Pipeline
Let's build a complete spam detection system using the preprocessing and vectorization techniques from this lesson. This pipeline demonstrates exactly how a production NLP system worked before deep learning — and why it achieves 97%+ accuracy on this task.
import re
import nltk
import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
from nltk.corpus import stopwords
from nltk.stem import PorterStemmer
nltk.download('stopwords', quiet=True)
# ── Step 1: Load SMS Spam Collection dataset ──
# You can download from: https://archive.ics.uci.edu/ml/datasets/SMS+Spam+Collection
# For this example, we'll create a realistic mini-dataset
spam_samples = [
"URGENT: You have won a $1000 prize. Call now to claim your free reward!",
"FREE: Get your complimentary iPhone now! Limited time offer. Reply YES",
"Congratulations! You've been selected for a special offer. Click here to claim",
"WIN a brand new car! You are one of the lucky winners. Call 0800 FREE now",
"Your account has been suspended. Verify immediately at http://fake-bank.com",
]
ham_samples = [
"Hey, are you free for lunch tomorrow?",
"The meeting has been moved to 3pm on Thursday",
"Can you pick up some milk on your way home?",
"I'm running 10 minutes late, start without me",
"Great work on the presentation today!",
]
texts = spam_samples + ham_samples
labels = ['spam'] * len(spam_samples) + ['ham'] * len(ham_samples)
# ── Step 2: Preprocessing function ──
stop_words = set(stopwords.words('english'))
stemmer = PorterStemmer()
def preprocess(text):
text = text.lower()
text = re.sub(r'http\S+|www\S+', ' URL ', text) # replace URLs with token
text = re.sub(r'\d+', ' NUM ', text) # replace numbers
text = re.sub(r'[^a-z\s]', ' ', text)
tokens = text.split()
tokens = [stemmer.stem(t) for t in tokens if t not in stop_words and len(t) > 2]
return ' '.join(tokens)
processed = [preprocess(t) for t in texts]
print("Sample preprocessing:")
for orig, proc in zip(texts[:3], processed[:3]):
print(f" Original: {orig[:60]}...")
print(f" Processed: {proc}")
print()
# ── Step 3: Compare BoW vs TF-IDF ──
X_train, X_test, y_train, y_test = train_test_split(
processed, labels, test_size=0.3, random_state=42, stratify=labels
)
results = {}
for name, vectorizer in [
('Bag of Words', CountVectorizer(max_features=1000)),
('TF-IDF', TfidfVectorizer(max_features=1000, ngram_range=(1, 2))),
]:
X_tr = vectorizer.fit_transform(X_train)
X_te = vectorizer.transform(X_test)
clf = LogisticRegression(max_iter=1000, random_state=42)
clf.fit(X_tr, y_train)
preds = clf.predict(X_te)
acc = accuracy_score(y_test, preds)
results[name] = (acc, vectorizer, clf)
print(f"{name}: accuracy = {acc:.3f}")
# ── Step 4: Inspect most informative spam features ──
best_name, (best_acc, best_vec, best_clf) = max(
results.items(), key=lambda x: x[1][0]
)
feature_names = best_vec.get_feature_names_out()
spam_idx = list(best_clf.classes_).index('spam')
top_spam_coefs = np.argsort(best_clf.coef_[0])[-10:] # highest coefficients = spam
print(f"\nTop spam indicators ({best_name}):")
for idx in reversed(top_spam_coefs):
print(f" '{feature_names[idx]}': coef={best_clf.coef_[0][idx]:.3f}")
Notice that replacing URLs with the token "URL" and numbers with "NUM" as preprocessing steps creates highly informative features — spam emails almost always contain URLs and numbers (prizes, phone numbers). This kind of domain-knowledge preprocessing dramatically improves model performance.
A sophisticated attacker can easily evade TF-IDF spam filters: replace "FREE" with "Fr33", use synonyms ("complimentary" instead of "free"), or write legitimate-seeming text. A TF-IDF model trained on today's spam will be obsolete next month as attackers adapt. This is a core reason why contextual models like BERT are increasingly used for spam detection — they understand meaning rather than just keywords. But even BERT can be evaded; adversarial robustness is an active research area.
✍️ Practice Exercises
- Install NLTK and spaCy, then write a preprocessing function that accepts a list of raw strings and returns a document-term matrix using TF-IDF. Apply it to at least 20 product reviews from Amazon (can be made up or scraped). Inspect which features have the highest TF-IDF scores.
- Compare stemming vs lemmatization on a set of 10 words with irregular forms (e.g., "geese", "mice", "went", "brought", "children"). How many does Porter Stemmer get wrong? How many does spaCy lemmatize correctly?
- Use
TfidfVectorizerwithngram_range=(1,2)to capture bigrams ("not good", "very expensive"). Train a sentiment classifier on 50 movie reviews. Does adding bigrams improve accuracy? Why? - Load a pre-trained BERT tokenizer. Tokenize 10 sentences with very long or unusual words (compound words, rare scientific terms, social media slang). Observe how WordPiece handles each case. Count the number of subword pieces per original word.
▶ Show Solution (Exercise 2 — Stemming vs Lemmatization)
import nltk
import spacy
from nltk.stem import PorterStemmer
nltk.download('punkt', quiet=True)
nlp = spacy.load("en_core_web_sm")
stemmer = PorterStemmer()
irregular_words = [
"geese", "mice", "went", "brought", "children",
"teeth", "oxen", "best", "worse", "swam"
]
correct_lemmas = [
"goose", "mouse", "go", "bring", "child",
"tooth", "ox", "good", "bad", "swim"
]
print(f"{'Word':<12} {'Stem':<15} {'Lemma':<12} {'Correct':<12} {'Stem✓':<6} {'Lemma✓':<6}")
print("-" * 65)
for word, correct in zip(irregular_words, correct_lemmas):
stem = stemmer.stem(word)
doc = nlp(word)
lemma = doc[0].lemma_
stem_ok = "✓" if stem == correct else "✗"
lemma_ok = "✓" if lemma == correct else "✗"
print(f"{word:<12} {stem:<15} {lemma:<12} {correct:<12} {stem_ok:<6} {lemma_ok:<6}")
# Expected: stemmer gets many wrong (mice→mic, went→went, best→best)
# spaCy lemmatizer handles most irregular forms correctly
📚 Primary Source for This Lesson
Jurafsky & Martin — "Speech and Language Processing" (3rd ed. draft, free online)
The standard NLP textbook covering tokenization, TF-IDF, and the full text-preprocessing pipeline in depth. For the TF-IDF weighting scheme specifically, see Salton & Buckley (1988) "Term-weighting approaches in automatic text retrieval."