Phase 5 · Lesson 1 of 29

Text Processing — Tokenization, Stemming, Lemmatization

Language is continuous. Models are discrete. Preprocessing is the bridge.

Lesson 1 of 29Build~45 minutesPython

Before this: Lesson 14 of Phase 2 (Naive Bayes)

On this page

The Problem

A model cannot read "The cats were running." It reads integers.

Every NLP system opens with the same three questions. Where does a word start. What is the root of the word. How do we treat "run", "running", "ran" as the same thing when it helps, and as different things when it doesn't.

Get tokenization wrong and the model learns from garbage. If your tokenizer treats don't as one token but do n't as two, the training distribution splits. If your stemmer collapses organization and organ to the same stem, topic modeling dies. If your lemmatizer needs part-of-speech context but you don't pass it, verbs get treated as nouns.

This lesson builds the three preprocessing steps from scratch, then shows how NLTK and spaCy do the same work so you can see the tradeoffs.

The Concept

Three operations. Each has a job and a failure mode.

Tokenization splits a string into tokens. "Token" is deliberately vague because the right granularity depends on the task. Word-level for classical NLP. Subword for transformers. Character for languages without whitespace.

Stemming chops suffixes with rules. Fast, aggressive, dumb. running -> run. organization -> organ. That second one is the failure mode.

Lemmatization reduces a word to its dictionary form using grammar knowledge. Slower, accurate, needs a lookup table or morphological analyzer. ran -> run (needs to know "ran" is past tense of "run"). better -> good (needs to know comparative forms).

Rule of thumb. Stem when speed matters and you can tolerate noise (search indexing, rough classification). Lemmatize when meaning matters (question answering, semantic search, anything the user will read).

edit-distance

Build It

Step 1: a regex word tokenizer

The simplest useful tokenizer splits on non-alphanumeric characters while keeping punctuation as its own tokens. Not perfect, not final, but it runs in one line.

import re

def tokenize(text):
    return re.findall(r"[A-Za-z]+(?:'[A-Za-z]+)?|[0-9]+|[^\sA-Za-z0-9]", text)

Three patterns in order of precedence. Words with optional inner apostrophe (don't, it's). Pure numbers. Any single non-whitespace non-alphanumeric character as a standalone token (punctuation).

>>> tokenize("The cats weren't running at 3pm.")
['The', 'cats', "weren't", 'running', 'at', '3', 'pm', '.']

Failure modes to notice. 3pm splits to ['3', 'pm'] because we alternated between letter runs and digit runs. Good enough for most tasks. URLs, emails, hashtags all break. For production, add patterns before the general ones.

Step 2: a Porter stemmer (step 1a only)

The full Porter algorithm has five phases of rules. Step 1a alone covers the most frequent English suffixes and teaches the pattern.

def stem_step_1a(word):
    if word.endswith("sses"):
        return word[:-2]
    if word.endswith("ies"):
        return word[:-2]
    if word.endswith("ss"):
        return word
    if word.endswith("s") and len(word) > 1:
        return word[:-1]
    return word
>>> [stem_step_1a(w) for w in ["caresses", "ponies", "caress", "cats"]]
['caress', 'poni', 'caress', 'cat']

Read the rules top-down. The ies -> i rule is why ponies -> poni, not pony. Real Porter has step 1b that would fix it. Rules compete. Earlier rules win. The order matters more than any single rule.

Step 3: a lookup-based lemmatizer

Lemmatization proper needs morphology. A tractable teaching version uses a small lemma table and a fallback.

LEMMA_TABLE = {
    ("running", "VERB"): "run",
    ("ran", "VERB"): "run",
    ("runs", "VERB"): "run",
    ("better", "ADJ"): "good",
    ("best", "ADJ"): "good",
    ("cats", "NOUN"): "cat",
    ("cat", "NOUN"): "cat",
    ("were", "VERB"): "be",
    ("was", "VERB"): "be",
    ("is", "VERB"): "be",
}

def lemmatize(word, pos):
    key = (word.lower(), pos)
    if key in LEMMA_TABLE:
        return LEMMA_TABLE[key]
    if pos == "VERB" and word.endswith("ing"):
        return word[:-3]
    if pos == "NOUN" and word.endswith("s"):
        return word[:-1]
    return word.lower()
>>> lemmatize("running", "VERB")
'run'
>>> lemmatize("cats", "NOUN")
'cat'
>>> lemmatize("better", "ADJ")
'good'
>>> lemmatize("watched", "VERB")
'watched'

The last case is the key teaching moment. watched is not in our table and our fallback only handles ing. Real lemmatization covers ed, irregular verbs, comparative adjectives, plurals with sound changes (children -> child). This is why production systems use WordNet, spaCy's morphologizer, or a full morphological analyzer.

Step 4: pipe them together

def preprocess(text, pos_tagger=None):
    tokens = tokenize(text)
    stems = [stem_step_1a(t.lower()) for t in tokens]
    tags = pos_tagger(tokens) if pos_tagger else [(t, "NOUN") for t in tokens]
    lemmas = [lemmatize(word, pos) for word, pos in tags]
    return {"tokens": tokens, "stems": stems, "lemmas": lemmas}

The missing piece is a POS tagger. Phase 5 · 07 (POS Tagging) builds one. For now, default everything to NOUN and acknowledge the limitation.

Use It

NLTK and spaCy ship the production versions. A few lines each.

NLTK

import nltk
nltk.download("punkt_tab")
nltk.download("wordnet")
nltk.download("averaged_perceptron_tagger_eng")

from nltk.tokenize import word_tokenize
from nltk.stem import PorterStemmer, WordNetLemmatizer
from nltk import pos_tag

text = "The cats were running."
tokens = word_tokenize(text)
stems = [PorterStemmer().stem(t) for t in tokens]
lemmatizer = WordNetLemmatizer()
tagged = pos_tag(tokens)


def nltk_pos_to_wordnet(tag):
    if tag.startswith("V"):
        return "v"
    if tag.startswith("J"):
        return "a"
    if tag.startswith("R"):
        return "r"
    return "n"


lemmas = [lemmatizer.lemmatize(t, nltk_pos_to_wordnet(tag)) for t, tag in tagged]

word_tokenize handles contractions, Unicode, edge cases your regex misses. PorterStemmer runs all five phases. WordNetLemmatizer needs the POS tag translated from NLTK's Penn Treebank scheme to WordNet's abbreviation set. The translation wiring above is the bit most tutorials skip.

spaCy

import spacy

nlp = spacy.load("en_core_web_sm")
doc = nlp("The cats were running.")

for token in doc:
    print(token.text, token.lemma_, token.pos_)
The      the     DET
cats     cat     NOUN
were     be      AUX
running  run     VERB
.        .       PUNCT

spaCy hides the whole pipeline behind nlp(text). Tokenization, POS tagging, and lemmatization all run. Faster than NLTK at scale. More accurate out of the box. The tradeoff is that you cannot easily swap individual components.

When to pick which

Situation Pick
Teaching, research, swapping components NLTK
Production, multi-language, speed matters spaCy
Transformer pipeline (you'll tokenize with the model's tokenizer anyway) Use tokenizers / transformers and skip classical preprocessing

The two failure modes nobody warns you about

Most tutorials teach the algorithms and stop. Two things will bite a real preprocessing pipeline, and they are almost never covered.

Reproducibility drift. NLTK and spaCy change tokenization and lemmatizer behavior between versions. What produced ['do', "n't"] in spaCy 2.x may produce ["don't"] in 3.x. Your model was trained on one distribution. Inference now runs on a different one. Accuracy quietly degrades and nobody knows why. Pin library versions in requirements.txt. Write a preprocessing regression test that freezes expected tokenization of 20 sample sentences. Run it on every upgrade.

Training / inference mismatch. Train with aggressive preprocessing (lowercase, stopword removal, stemming), deploy on raw user input, watch performance crater. This is the single most common production NLP failure. If you preprocess during training, you must run the identical function during inference. Ship preprocessing as a function inside the model package, not as a notebook cell the serving team rewrites.

Ship It

A reusable prompt that helps engineers pick a preprocessing strategy without reading three textbooks.

Save as outputs/prompt-preprocessing-advisor.md:

---
name: preprocessing-advisor
description: Recommends a tokenization, stemming, and lemmatization setup for an NLP task.
phase: 5
lesson: 01
---

You advise on classical NLP preprocessing. Given a task description, you output:

1. Tokenization choice (regex, NLTK word_tokenize, spaCy, or transformer tokenizer). Explain why.
2. Whether to stem, lemmatize, both, or neither. Explain why.
3. Specific library calls. Name the functions. Quote the POS-tag translation if NLTK is involved.
4. One failure mode the user should test for.

Refuse to recommend stemming for user-visible text. Refuse to recommend lemmatization without POS tags. Flag non-English input as needing a different pipeline.

Exercises

  1. Easy. Extend tokenize to keep URLs as single tokens. Test: tokenize("Visit https://example.com today.") should produce one URL token.
  2. Medium. Implement Porter step 1b. If a word contains a vowel and ends in ed or ing, remove it. Handle the double-consonant rule (hopping -> hop, not hopp).
  3. Hard. Build a lemmatizer that uses WordNet as a lookup table but falls back to your Porter stemmer when WordNet has no entry. Measure accuracy on a tagged corpus against plain WordNet and plain Porter.

Key Terms

Term What people say What it actually means
Token A word Whatever unit the model consumes. Can be word, subword, character, or byte.
Stem Root of a word Result of rule-based suffix stripping. Not always a real word.
Lemma Dictionary form The form you'd look up. Requires grammatical context to compute correctly.
POS tag Part of speech Category like NOUN, VERB, ADJ. Needed to lemmatize accurately.
Morphology Word shape rules How a word changes form based on tense, number, case. Lemmatization depends on it.

Further Reading

Check yourself

Quick quiz

  1. Why does a language model need preprocessing before training?

    1. Models read strings directly
    2. Preprocessing improves GPU utilization
    3. Models consume discrete integer tokens, not raw text
    4. Preprocessing is required only for languages without whitespace
    Show the answer

    C. Models consume discrete integer tokens, not raw text Models operate on integer token IDs; preprocessing bridges continuous language to discrete inputs.

  2. Which preprocessing operation is rule-based suffix stripping?

    1. POS tagging
    2. Stemming
    3. Lemmatization
    4. Tokenization
    Show the answer

    B. Stemming Stemming chops suffixes with rules; it is fast but can be wrong.

  3. What does the Porter stemmer step 1a return for the word 'ponies'?

    1. poni
    2. ponies
    3. pony
    4. ponie
    Show the answer

    A. poni The 'ies' rule replaces the suffix with 'i', producing 'poni'; step 1b in real Porter cleans it up.

  4. Why does lemmatization usually need a POS tag?

    1. Unicode handling
    2. Grammatical context disambiguates forms like 'better' (ADJ) versus 'better' (VERB)
    3. To reduce memory usage
    4. Speed
    Show the answer

    B. Grammatical context disambiguates forms like 'better' (ADJ) versus 'better' (VERB) Lemmas depend on grammatical role; without the tag the lookup is ambiguous.

  5. When translating NLTK Penn Treebank POS tags to WordNet tags, what does a tag starting with 'V' map to?

    1. 'r' (adverb)
    2. 'a' (adjective)
    3. 'v' (verb)
    4. 'n' (noun)
    Show the answer

    C. 'v' (verb) Verb-prefixed Penn Treebank tags map to WordNet 'v'.

  6. What is 'training/inference mismatch' in NLP preprocessing?

    1. A GPU memory issue
    2. Training and serving apply different preprocessing, so the model sees an unfamiliar distribution at inference
    3. Mixing tokenizer libraries within a notebook
    4. Different batch sizes between train and test
    Show the answer

    B. Training and serving apply different preprocessing, so the model sees an unfamiliar distribution at inference Different preprocessing at training vs inference is the most common production NLP failure.

  7. Which tool would you reach for in a transformer pipeline instead of classical preprocessing?

    1. NLTK word_tokenize
    2. The model's own tokenizer (e.g. via tokenizers / transformers)
    3. spaCy en_core_web_sm
    4. A regex tokenizer
    Show the answer

    B. The model's own tokenizer (e.g. via tokenizers / transformers) Transformer models ship a paired tokenizer; classical preprocessing is bypassed.

  8. Why pin NLTK and spaCy versions in requirements?

    1. Newer versions are slower
    2. Older versions support more languages
    3. Tokenizer and lemmatizer behavior shifts between minor releases, silently changing training distribution
    4. License compatibility
    Show the answer

    C. Tokenizer and lemmatizer behavior shifts between minor releases, silently changing training distribution Library upgrades can change tokenization output, drifting from the training distribution.

Questions people ask

What is the difference between stemming and lemmatization?

Stemming chops word endings with simple rules, so "running" may become "run" and "studies" may become "studi". Lemmatization uses a dictionary and grammar to return the real base word, such as "study". Lemmatization is slower but gives cleaner results.

Do I still need text preprocessing if I use ChatGPT or Claude?

Much less. Large language models come with their own tokenizer and handle raw text well. Cleanup still matters for search indexes, keyword reports, older classifiers and any step that compares words directly, such as matching product names in orders.

Next lessonBag of Words, TF-IDF, and Text RepresentationPrevious: World Models & Video Diffusion

Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License

Next step

Rather have it built for you?

Reading is optional. Tell me what's stuck and I'll build it into the business you already run.

  • A real conversation, not a pitch
  • You keep your accounts
  • Nothing sends without your yes