Text Preprocessing¶
Raw text is not model-ready. It contains case variations, punctuation, URLs, HTML tags, stopwords, misspellings, and dozens of other sources of noise that inflate your vocabulary and dilute your signal. Preprocessing is where you make deliberate choices about what information to keep — and those choices have a larger impact on model performance than most hyperparameter tuning.
Learning Objectives¶
- Explain why each preprocessing step exists, not just how to apply it
- Build a reusable preprocessing function using Python and regex
- Understand when to skip stopword removal (negations, sentiment tasks)
- Distinguish stemming from lemmatisation and choose appropriately
- Wrap preprocessing into a scikit-learn
Pipeline-compatible transformer
Why Preprocessing Matters¶
Consider these two sentences:
To a human: identical sentiment. To a bag-of-words model without preprocessing: four different tokens (AMAZING!!!, amazing, :), amazing) and a vocabulary bloated with punctuation variants. Every unnecessary variation in your vocabulary costs you a column in your feature matrix and dilutes the model's ability to learn the underlying pattern.
Info
Preprocessing is a form of feature engineering for text. You are manually encoding assumptions about what information is and is not relevant to your task. A preprocessing choice that helps a spam filter (remove all URLs) might destroy signal in a web-content classifier (URLs carry domain information).
Step 1: Lowercasing¶
The most common and most universally beneficial step. "Python", "PYTHON", and "python" should be the same token in almost every task.
text = "This MOVIE was Amazing but the Ending was terrible."
lowered = text.lower()
print(lowered)
# Output: this movie was amazing but the ending was terrible.
Warning
Do not lowercase when case carries genuine meaning. Named entity recognition (NER) relies heavily on capitalisation — "apple" (fruit) vs "Apple" (company). If you are building a NER system, skip lowercasing or handle it carefully.
Step 2: Removing Noise with Regex¶
Real-world text contains URLs, email addresses, HTML tags, and special characters that rarely contribute signal to classification tasks.
import re
def remove_noise(text: str) -> str:
"""Remove URLs, HTML tags, and non-alphabetic characters."""
# Remove URLs
text = re.sub(r"https?://\S+|www\.\S+", "", text)
# Remove HTML tags
text = re.sub(r"<[^>]+>", "", text)
# Remove anything that is not a letter or whitespace
text = re.sub(r"[^a-zA-Z\s]", "", text)
# Collapse multiple whitespace into one
text = re.sub(r"\s+", " ", text).strip()
return text
raw = "Check out https://example.com — <b>best deal</b> ever!!! #sale"
print(remove_noise(raw))
# Output: Check out best deal ever sale
Tip
Keep emojis when building sentiment models for social media data. Emojis like "😍" and "💔" carry strong sentiment signals. You will need to either convert them to text tokens (":heart_eyes:") or treat them as special tokens rather than stripping them.
Step 3: Tokenisation¶
Tokenisation splits a string into a list of meaningful units (tokens). The simplest approach is whitespace splitting, but dedicated tokenisers handle edge cases better.
# Naive: whitespace split
text = "I'm not going, but she'll be there."
naive_tokens = text.split()
print(naive_tokens)
# Output: ["I'm", 'not', 'going,', 'but', "she'll", 'be', 'there.']
# Problem: punctuation is attached to words
# Better: NLTK word tokeniser
import nltk
nltk.download("punkt", quiet=True)
nltk.download("punkt_tab", quiet=True)
from nltk.tokenize import word_tokenize
tokens = word_tokenize(text)
print(tokens)
# Output: ['I', "'m", 'not', 'going', ',', 'but', 'she', "'ll", 'be', 'there', '.']
# Better: contractions are split, punctuation is separated
Sentence tokenisation¶
When you need to work at the sentence level (for summarisation, or to preserve sentence structure before word-level processing):
from nltk.tokenize import sent_tokenize
paragraph = "Dr. Smith graduated in 2019. She now works at St. Mary's hospital. It's a great place."
sentences = sent_tokenize(paragraph)
for s in sentences:
print(s)
# Output:
# Dr. Smith graduated in 2019.
# She now works at St. Mary's hospital.
# It's a great place.
# Note: sent_tokenize correctly handles "Dr." and "St." without splitting on them
Step 4: Stopword Removal¶
Stopwords are extremely frequent words — "the", "is", "at", "which" — that rarely carry discriminative information for classification. Removing them reduces vocabulary size and speeds up training.
import nltk
nltk.download("stopwords", quiet=True)
from nltk.corpus import stopwords
stop_words = set(stopwords.words("english"))
tokens = ["this", "movie", "is", "not", "amazing", "at", "all"]
filtered = [token for token in tokens if token not in stop_words]
print(filtered)
# Output: ['movie', 'amazing']
# Problem: 'not' was removed, flipping the meaning!
Warning
Removing stopwords blindly is one of the most common mistakes in NLP. The word "not" is a stopword in NLTK's default list. Removing it turns "not amazing" into "amazing" — a complete meaning reversal. For sentiment analysis, always check whether negation words ("not", "never", "no", "isn't", "wasn't") are in your stopword list and explicitly exclude them.
# Safe stopword removal for sentiment tasks
negative_words = {"not", "no", "never", "neither", "nor", "nothing",
"nowhere", "nobody", "isn't", "wasn't", "won't",
"wouldn't", "can't", "couldn't", "shouldn't", "doesn't"}
safe_stopwords = stop_words - negative_words
tokens = ["this", "movie", "is", "not", "amazing", "at", "all"]
filtered = [token for token in tokens if token not in safe_stopwords]
print(filtered)
# Output: ['movie', 'not', 'amazing']
# 'not' is preserved — meaning is intact
Step 5: Stemming vs Lemmatisation¶
Both techniques reduce words to a common base form to group variants together. They differ in method and output quality.
Stemming¶
Stemming chops word endings using rules — fast but crude. The result is not always a real word.
from nltk.stem import PorterStemmer
stemmer = PorterStemmer()
words = ["running", "runs", "runner", "easily", "fairly", "studies", "studying"]
stemmed = [stemmer.stem(w) for w in words]
print(list(zip(words, stemmed)))
# Output:
# [('running', 'run'), ('runs', 'run'), ('runner', 'runner'),
# ('easily', 'easili'), ('fairly', 'fairli'),
# ('studies', 'studi'), ('studying', 'studi')]
# Note: 'easily' → 'easili' is not a real word, but it works for grouping
Lemmatisation¶
Lemmatisation uses a vocabulary and morphological analysis to return the actual base word (lemma). Slower, but produces real words and handles irregular forms correctly.
import nltk
nltk.download("wordnet", quiet=True)
nltk.download("omw-1.4", quiet=True)
from nltk.stem import WordNetLemmatizer
lemmatizer = WordNetLemmatizer()
words = ["running", "runs", "runner", "better", "studies", "studying"]
# Must specify part of speech for accuracy
# 'v' = verb, 'n' = noun, 'a' = adjective
lemmas_verb = [lemmatizer.lemmatize(w, pos="v") for w in words]
print("As verbs:", lemmas_verb)
# Output: As verbs: ['run', 'run', 'runner', 'better', 'study', 'study']
lemmas_adj = [lemmatizer.lemmatize("better", pos="a")]
print("'better' as adjective:", lemmas_adj)
# Output: 'better' as adjective: ['good']
# Lemmatisation knows 'better' is the comparative of 'good'
When to use which¶
| Scenario | Recommendation |
|---|---|
| Speed matters, large dataset, any topic | Stemming |
| Accuracy matters, smaller dataset | Lemmatisation |
| Production sentiment classifier | Lemmatisation (or neither — TF-IDF handles it) |
| Search index | Stemming (better recall, users expect broad matches) |
| When you have no preprocessing budget at all | Neither — TF-IDF + sublinear_tf=True handles variance well |
Info
In practice, when you use TfidfVectorizer with a reasonable vocabulary size, the performance difference between stemming and lemmatisation is often small — because TF-IDF already down-weights high-frequency variants. Run a quick experiment on your data before committing to the extra complexity of lemmatisation.
Putting It Together: A Reusable Preprocessing Pipeline¶
import re
import nltk
nltk.download("punkt", quiet=True)
nltk.download("punkt_tab", quiet=True)
nltk.download("stopwords", quiet=True)
nltk.download("wordnet", quiet=True)
nltk.download("omw-1.4", quiet=True)
from nltk.tokenize import word_tokenize
from nltk.corpus import stopwords
from nltk.stem import WordNetLemmatizer
lemmatizer = WordNetLemmatizer()
# Stopwords with negations preserved
STOP_WORDS = set(stopwords.words("english")) - {
"not", "no", "never", "nor", "nothing", "nobody",
"isn't", "wasn't", "won't", "wouldn't", "can't",
"couldn't", "shouldn't", "doesn't", "didn't",
}
def preprocess_text(text: str) -> str:
"""
Full preprocessing pipeline for sentiment classification.
Returns a cleaned, tokenised, lemmatised string.
"""
# 1. Lowercase
text = text.lower()
# 2. Remove URLs and HTML
text = re.sub(r"https?://\S+|www\.\S+", "", text)
text = re.sub(r"<[^>]+>", "", text)
# 3. Remove punctuation and digits
text = re.sub(r"[^a-z\s]", "", text)
text = re.sub(r"\s+", " ", text).strip()
# 4. Tokenise
tokens = word_tokenize(text)
# 5. Remove stopwords (negations preserved)
tokens = [t for t in tokens if t not in STOP_WORDS and len(t) > 1]
# 6. Lemmatise
tokens = [lemmatizer.lemmatize(t, pos="v") for t in tokens]
return " ".join(tokens)
# Test it
reviews = [
"I absolutely LOVED this product!!! Best purchase ever 😊",
"This was NOT worth the money. Totally disappointed.",
"Check out https://deals.com — <b>Amazing</b> quality for the price.",
]
for review in reviews:
print(preprocess_text(review))
# Output:
# absolutely love product best purchase ever
# not worth money totally disappoint
# amazing quality price
Scikit-learn Compatible Transformer¶
When you want preprocessing inside a scikit-learn Pipeline, wrap it in a FunctionTransformer:
import pandas as pd
from sklearn.preprocessing import FunctionTransformer
def preprocess_series(texts):
"""Apply preprocess_text to an array of strings."""
return [preprocess_text(t) for t in texts]
# Creates a drop-in sklearn transformer
text_cleaner = FunctionTransformer(preprocess_series)
sample_texts = [
"Absolutely terrible product. Would NOT recommend.",
"BEST movie I've seen all year!!!",
]
cleaned = text_cleaner.transform(sample_texts)
for original, clean in zip(sample_texts, cleaned):
print(f"Before: {original}")
print(f"After: {clean}\n")
# Output:
# Before: Absolutely terrible product. Would NOT recommend.
# After: absolutely terrible product not recommend
#
# Before: BEST movie I've seen all year!!!
# After: best movie see year
Success
The goal of preprocessing is controlled vocabulary reduction. Every step should reduce the number of distinct tokens your vectoriser sees, while preserving the tokens that carry predictive signal. If a preprocessing step does not measurably help your downstream model, it is complexity you do not need.
What's Next¶
You've covered the full text preprocessing pipeline — lowercasing, URL and HTML removal, tokenisation, stop word filtering (with negation preservation), stemming vs lemmatisation, and wrapping the pipeline into a sklearn FunctionTransformer. Next up: 03-bow-tfidf — where you'll convert preprocessed text into numeric features using CountVectorizer and TfidfVectorizer, understand how IDF downweights common words, add bigrams for phrase-level signal, and handle the sparse matrix format that makes large document-term matrices tractable.
Optional Deep Dive
Read the NLTK documentation on corpora and lexical resources at https://www.nltk.org/book/ch02.html — it covers WordNet's synsets, hypernym hierarchies, and morphological derivations that underpin WordNetLemmatizer, giving you the linguistic database context that explains why lemmatisation is more accurate than rule-based stemming.