Chapter 2: Text Preprocessing
Master the essential techniques for cleaning and transforming raw text into structured input for NLP models. From tokenization and stemming to normalization and encoding, these preprocessing steps form the foundation of every NLP pipeline.
- Topics
- 6
- Demos
- 3
- Videos
- 3
- Min read
- 47
Chapter Overview
Raw text is messy. It contains inconsistent casing, special characters, HTML artifacts, contractions, and encoding issues. Before any NLP model can extract meaning from text, we need a systematic preprocessing pipeline to clean, normalize, and structure the input.
Text preprocessing is not a one-size-fits-all process. The right pipeline depends on your task: sentiment analysis benefits from keeping exclamation marks and emoticons, while topic modeling usually removes stopwords and punctuation. A search engine needs stemming for recall, but a chatbot needs lemmatization for grammatical responses.
This chapter covers the six core preprocessing techniques every NLP practitioner must master:
- Tokenization: splitting text into meaningful units (words, subwords, characters)
- Stemming & Lemmatization: reducing words to their base forms
- Stopword Removal: filtering out low-information words
- Regular Expressions: pattern-based text extraction and cleaning
- Text Normalization: standardizing text format (casing, unicode, contractions)
- Text Encoding: handling character sets, UTF-8, and multilingual text
Chapter Roadmap
Click any topic to jump in
Tokenization
Splitting text into tokens — word, subword (BPE), and character strategies and their trade-offs.
Normalizing word forms and removing noise
Stemming & Lemmatization
Reducing words to base forms — fast heuristic stemming vs. linguistically correct lemmatization.
Stopword Removal
Filtering low-information words — standard lists, domain-specific words, and frequency-based detection.
Pattern-based extraction and format normalization
Regex for Text
Pattern matching and substitution — extracting emails, URLs, and cleaning HTML artifacts.
Text Normalization
Case folding, unicode normalization, contraction expansion — standardizing text before modeling.
Text Encoding
ASCII, UTF-8, and multilingual byte sequences — ensuring every character survives the pipeline.
Topics
6 topics in this chapter
Before a model can count, embed, or classify anything, it must decide what the units of text are. Is "don't" one unit or two? Is "U.S." a word followed by a sentence end? What should happen to "unhappiest" if it never appeared in training, or to a Chinese sentence with no spaces at all? Every later step, from stopword lists to Transformer inputs, inherits the answer, and a bad split silently corrupts everything downstream.
The previous chapter, Introduction to NLP, introduced tokenization as one of the core NLP tasks. This chapter, Text Preprocessing, covers the steps that turn raw text into clean model input, and this topic begins with tokenization itself. It starts with word tokenization, which splits on spaces and punctuation using rules for contractions and abbreviations. Subword tokenization with BPE and WordPiece follows, the method behind modern language models. Sentence tokenization handles the ambiguous period, and character-level tokenization closes the topic by showing the cost of the smallest possible units.
Definition
Tokenization is the segmentation of a character string into a sequence of tokens drawn from a vocabulary . Word tokenizers use whitespace and punctuation rules; subword tokenizers such as BPE and WordPiece learn a vocabulary of frequent character sequences; character and byte tokenizers use individual symbols, trading a tiny vocabulary for longer sequences.
In this topic
- 1Word Tokenization
- 2Subword Tokenization (BPE & WordPiece)
- 3Sentence Tokenization
- 4Character-Level Tokenization
Word Tokenization
Word tokenization splits text into words and punctuation marks. The naive approach, Python's str.split(), breaks only on whitespace, so punctuation stays attached: "cheap!" and "cheap" become different tokens. Rule-based tokenizers do better. NLTK's word_tokenize() follows Penn Treebank conventions, splitting contractions such as "isn't" into "is" and "n't" and possessives such as "Smith's" into "Smith" and "'s", while keeping known abbreviations like "Dr." intact. spaCy uses prefix, suffix, and infix rules plus exception lists. The failure modes are new abbreviations, URLs, hashtags, hyphenated compounds, and languages without spaces between words, where whitespace rules do not apply at all.
Word tokenization splits on whitespace and punctuation boundaries. For a string of length characters, a regex-based tokenizer runs in time. The resulting vocabulary for a corpus follows Heaps' law: where is total tokens, , and . A 1M-word corpus typically has unique words.
Tokenize the sentence: "Dr. Smith's car isn't in the U.S. yet!" using NLTK's word_tokenize, and compare the result with str.split().
Subword Tokenization (BPE & WordPiece)
Subword tokenizers keep the vocabulary small without unknown words. Byte Pair Encoding starts from single characters or bytes, counts adjacent pairs in a training corpus, and repeatedly merges the most frequent pair into a new symbol until the vocabulary reaches a target size, such as 50,257 for GPT-2. WordPiece, used by BERT with about 30,000 entries, picks merges that most increase training-data likelihood and marks word-internal pieces with ##. The formula's point is that the subword vocabulary is far smaller than a full word vocabulary . Frequent words stay whole, rare words split into pieces, and the splits follow corpus statistics rather than morphemes.
Byte-level BPE starts with byte tokens and performs merges, giving plus any special tokens. Each merge picks the most frequent adjacent pair in the training corpus and replaces its occurrences with the new symbol , so every merge shortens the encoded corpus by the number of times that pair occurred. GPT-2 uses 50,000 merges, for 50,257 tokens including one special token; GPT-4's cl100k encoding has about 100,000. The merge list is applied in learned order at encoding time, so the same word always splits the same way.
Explain why subword tokenization handles the unseen word "unhappiest" gracefully.
Sentence Tokenization
Sentence tokenization splits a document into sentences, the input unit for summarization, translation, and many annotation tools. The hard part is the period, which can end a sentence or mark an abbreviation ("Dr.", "p.m."), a decimal ("3.5"), or an ellipsis. Question and exclamation marks inside quotations add more ambiguity. NLTK's sent_tokenize() uses Punkt, an unsupervised algorithm by Kiss and Strunk that learns abbreviations, collocations, and likely sentence starters from raw text without labels. Rules fail when an abbreviation really does end a sentence, as in "He arrived at 5 p.m." followed by a new sentence, and on informal text with missing capitals or punctuation.
The Punkt sentence tokenizer models abbreviation detection as a classification problem. It estimates using features like word length, final period, and capitalization of the next word. The decision boundary is: if , classify as abbreviation (not sentence boundary). This handles 'Dr.', 'U.S.', and 'etc.' without a hand-curated list.
Split into sentences: "Dr. Smith arrived at 3 p.m. He said, 'Hello!' Ms. Jones replied."
Character-Level Tokenization
Character-level tokenization makes every character a token, and byte-level tokenization makes every UTF-8 byte a token. The vocabulary is tiny, at most 256 for bytes, and nothing is ever unknown, so typos and rare names are handled naturally. The cost is length: as the formula notes, English text is roughly five to six times longer in characters than in words. Self-attention compares every pair of positions, so its cost grows with the square of the sequence length, and a fivefold longer sequence costs about 25 times more. Byte fallback inside subword tokenizers keeps the robustness without most of the cost.
Character-level tokenization has vocabulary (byte-level) but sequence length . For transformers with attention, this means the compute cost. A 512-word document becomes characters — attention needs operations vs for word-level, a increase.
Compare token counts for "Machine Learning" at word vs character level. What is the cost implication for a transformer?
Theory Exercise
Problem:
A search engine processes queries in 47 languages. You must choose a tokenization strategy. Word-level tokenization fails for Chinese (no spaces) and agglutinative languages like Turkish (one word = one sentence). Compare word, subword (BPE), and character tokenization for this use case. Which do you recommend and why?
Hints:
- Consider languages without whitespace word boundaries
Coding Exercise
Problem:
Write a Python function that tokenizes text using three methods (whitespace, NLTK word_tokenize, and character-level) and compares the token counts.
Hints:
- Use str.split() for whitespace tokenization
Related Problems on PixelBank
A user searches for "running shoes", but the best product page says "run" and "runner". A bag-of-words classifier sees "study", "studies", and "studying" as three unrelated features, each with a third of the training evidence. Inflection spreads one idea across many surface forms, which hurts recall in search and wastes data in classical models. Can we map those forms to one shared key, and what is lost when we do?
The previous topic, Tokenization, split text into word tokens. This topic reduces those tokens to base forms. It begins with the Porter stemmer, a fast set of suffix-stripping rules that produces stems such as "studi" that need not be real words. The Snowball (Porter2) stemmer follows, refining those rules and supporting other languages. The WordNet lemmatizer then returns true dictionary forms such as "mouse" for "mice", but needs a part-of-speech tag to do it well. The topic closes with guidance on when to use each method, and when modern subword models make both unnecessary.
Definition
Stemming reduces a word to a stem by applying suffix-stripping rules, and the result need not be a real word. Lemmatization maps a word to its lemma, the dictionary form, using a vocabulary and morphological analysis, usually conditioned on the word's part of speech. Both collapse inflected forms so they are counted as one feature.
In this topic
- 1Porter Stemmer
- 2Snowball Stemmer
- 3WordNet Lemmatizer
- 4Stemming vs Lemmatization: When to Use Which
Porter Stemmer
Martin Porter's 1980 algorithm strips English suffixes in five ordered steps. Step 1 handles plurals and -ed or -ing, step 2 maps double suffixes such as -ational to -ate, and later steps remove suffixes such as -ness and -ate and tidy a final e. Each rule fires only if the remaining stem is long enough, measured by , the number of vowel-consonant sequences in it, which stops short words from being destroyed. The algorithm is fast and needs no dictionary. Its errors are over-stemming, conflating unrelated words ("organization", "organic", and "organ" all become "organ"), and under-stemming, missing irregular forms ("ran" stays "ran", "mice" stays "mice").
Porter writes every word as , where is a run of consonants, a run of vowels, and the measure counts the vowel-consonant pairs. Rules carry conditions on for the stem left after removing the suffix: step 4 removes suffixes such as -ate or -ment only when , so short stems survive. For 'relational', step 2 rewrites -ational to -ate, and the final step drops the trailing e from 'relate' because its stem 'relat' has . The algorithm uses about 60 rules and runs in time linear in word length, with no dictionary.
Apply the Porter Stemmer to: "caresses", "ponies", "cats", "troubles", "relational".
Snowball Stemmer
Snowball is a small language for writing stemmers, created by Martin Porter, and its English stemmer, often called Porter2, is a revised version of the original algorithm. It changes several rules: it defines the stem regions R1 and R2 more carefully, adds exceptions for words such as "generous", handles a final -ly better ("fairly" becomes "fair" rather than "fairli"), and treats a word-initial y as a consonant. Snowball also provides stemmers for many other languages; NLTK's SnowballStemmer covers 15, including French, German, Spanish, and Russian. It remains a suffix stripper, so it still misses irregular forms and still conflates some unrelated words.
Porter2 replaces the measure with two regions: R1 is the part of the word after the first non-vowel that follows a vowel, and R2 is the same construction applied again inside R1. A suffix is removed only if it lies inside the required region, which is a clearer condition than counting vowel-consonant pairs. For 'general', the standard definition gives R1 = 'eral' and R2 = 'al', so the rule for -al, which requires R2, would strip it to 'gener'. Porter2 special-cases words beginning with 'gener', setting R1 to 'al', which leaves R2 empty and keeps 'general' intact. The result is fewer conflations of unrelated words, though suffix rules still cannot handle irregular forms.
Compare Porter and Snowball stemming for: "generously", "generate", "general".
WordNet Lemmatizer
NLTK's WordNetLemmatizer looks words up in WordNet, a lexical database with about 155,000 English words grouped into roughly 117,000 synonym sets. It applies inflection rules and an exception list, so irregular forms work: "mice" becomes "mouse" and "geese" becomes "goose". The output is always a real dictionary word. Its weakness is that it needs the part of speech, given as n, v, a, or r, and defaults to noun. Without the tag, "running" stays "running" and "better" stays "better"; with verb and adjective tags they become "run" and "good". In practice you run a POS tagger first and map its tags to WordNet's four categories.
Lemmatization maps surface forms to lemmas using a dictionary lookup: . WordNet contains lemmas organized into synsets. The POS tag disambiguates: vs . Without POS, the default noun lookup misses verb and adjective forms entirely.
Lemmatize "running", "better", "mice", "geese" with and without POS tags.
Stemming vs Lemmatization: When to Use Which
Choose by what the base form is used for. Stemming is fast, needs no tagger or dictionary, and raises recall in search, because more surface forms match the same index key; the cost is lower precision when unrelated words collapse together. Lemmatization is slower and needs POS tags, but it returns real words and handles irregular forms, which matters when people read the output, for instance in keyword extraction or display text. Both throw away inflections that can carry meaning, such as tense. Pretrained models with subword tokenizers usually skip both steps, because they learn from all inflected forms and benefit from that information.
Collapsing forms shrinks the vocabulary, and the size of the reduction depends on the corpus and language: richly inflected languages gain far more than English. The trade-off is precision against recall in retrieval. If a stemmer maps surface forms to one key, a query for any of them matches documents containing all , raising recall, but any unrelated word sharing the key, such as 'organ' and 'organization', lowers precision. Irregular forms escape stemming entirely: Porter leaves 'ran' and 'mice' unchanged, while a lemmatizer with the right POS tags maps them to 'run' and 'mouse'.
You are building (a) a document search engine for 10M legal documents and (b) a medical chatbot. Which do you use for each?
Theory Exercise
Problem:
A multilingual search engine needs to support English, French, and Arabic. Stemming algorithms exist for English and French but not Arabic (which has complex root-pattern morphology). Propose a preprocessing strategy for each language and explain your reasoning.
Hints:
- Arabic uses a root-pattern system where 3-letter roots combine with vowel patterns
Coding Exercise
Problem:
Implement a function that compares Porter Stemmer, Snowball Stemmer, and WordNet Lemmatizer on a list of words. Include POS tagging for accurate lemmatization.
Hints:
- Use nltk.stem.PorterStemmer and nltk.stem.SnowballStemmer
Count the words in almost any English document and the top of the list is the same: the, of, and, to, a, in. These words make up a large share of all tokens, appear in nearly every document, and say almost nothing about topic. In a bag-of-words model they dominate the vectors and make unrelated documents look similar. Yet "not" is also a frequent function word, and dropping it turns "not good" into "good". When should frequent words be removed, and who decides which words count as stopwords?
The previous topic, Stemming & Lemmatization, reduced words to shared base forms. This topic decides which words to drop entirely. It starts with standard stopword lists from NLTK, spaCy, and scikit-learn, which differ more than most people expect. Domain-specific stopwords follow, words like "patient" in clinical notes that are frequent but uninformative in one corpus. The trade-offs come next, showing where removal helps and where it destroys meaning. The topic ends with frequency-based detection, which derives stopwords from document frequency and connects directly to IDF.
Definition
Stopword removal deletes tokens that belong to a stopword list, a set of high-frequency, low-content words such as articles, prepositions, pronouns, and auxiliary verbs. The list can be fixed, such as NLTK's English list, domain-specific, or derived from corpus statistics such as document frequency.
In this topic
- 1Standard Stopword Lists
- 2Domain-Specific Stopwords
- 3Stopword Removal Trade-offs
- 4Frequency-Based Stopword Detection
Standard Stopword Lists
Libraries ship ready-made stopword lists, and they disagree. NLTK's current English list has 198 entries, and NLTK provides lists for 33 languages. spaCy's English list has 326 words and scikit-learn's has 318. The lists cover articles, prepositions, conjunctions, pronouns, auxiliaries, and negations such as "not" and "no". They were built for different purposes, and Nothman et al. (2018) found inconsistencies and surprising entries in several popular lists, such as scikit-learn including "system", "fire", and "bill". Whichever list you use, inspect it before applying it: removing negations, question words, or numbers can delete the very signal your task depends on.
Stopword lists contain roughly 100 to 350 words: NLTK's English list has 198, scikit-learn's 318, and spaCy's 326. By Zipf's law, word frequency falls roughly as with rank , so a handful of top-ranked words account for a large fraction of all tokens; in typical English corpora the 100 most frequent words cover close to half of the running text. Removing them shortens documents dramatically, but those same words carry the grammar that parsers, translation systems, and Transformers rely on.
Remove stopwords from: "The quick brown fox jumps over the lazy dog near the river."
Domain-Specific Stopwords
A general list cannot know which words are uninformative in your corpus. In clinical notes, "patient", "history", and "denies" appear in almost every document; in legal filings, "court" and "section"; in machine-learning papers, "model" and "results". Such words carry little information for telling documents apart within that collection, so they crowd topic models and nearest-neighbor search. Adding them to the stopword list, or capping document frequency, often sharpens results. The risk is removing a word that matters in a subset: "patient" is noise in topic modeling but signal when separating patient-reported symptoms from clinician observations. Always check a candidate list against the downstream task.
Domain-specific stopwords are identified by their document frequency . In a medical corpus, 'patient' might appear in 95% of documents — high df but domain-relevant. The heuristic with catches domain stopwords, but must be validated manually because some high-df terms are genuinely informative. The optimal depends on the downstream task.
You are building a topic model for 50,000 scientific papers about machine learning. Which domain-specific stopwords would you add?
Stopword Removal Trade-offs
Removing stopwords shrinks bag-of-words vectors, speeds up indexing, and stops function words from inflating similarity between unrelated documents. It hurts whenever function words carry meaning. Negations such as "not", "no", and "nor" are in NLTK's list, so sentiment can flip. Question words such as "who" and "where" are in the list, so questions lose their type. Phrases lose structure, as in "to be or not to be", which disappears entirely. Order-sensitive models such as Transformers use stopwords for grammar, so removal usually hurts them. A good default is to remove stopwords for topic models and lexical search and keep them for sentiment, question answering, translation, and pretrained models.
Removing stopwords changes document similarity. Consider with and without stopwords. Since stopwords are shared across most documents, they inflate cosine similarity: two unrelated documents share 'the', 'is', 'a', giving a baseline similarity . Removing them is equivalent to projecting out the common subspace, making the remaining similarity more discriminative.
Explain why removing stopwords is harmful for sentiment analysis with the sentence: "This movie is not really all that bad."
Frequency-Based Stopword Detection
Instead of trusting a fixed list, you can let the corpus choose. Compute each term's document frequency , the number of documents containing it, out of documents. Terms above a threshold, often around 80 to 90 percent of documents, act as stopwords for that corpus. The formula connects this to IDF: when approaches , approaches 0, so TF-IDF already shrinks such terms toward zero. scikit-learn exposes the same idea as max_df in its vectorizers. The method adapts to any domain, but on a small corpus it can flag topic words that just happen to be common there.
Zipf's law predicts word frequency as for rank . Setting a frequency threshold and defines a band: words above are stopwords, words below are too rare to be useful. For a corpus with tokens, the 10th most common word appears times while the 10,000th appears times — a 1000:1 ratio that Zipf predicts exactly.
In a corpus of 10,000 documents, "algorithm" appears in 800 and "the" appears in 9,950. Calculate IDF for each.
Theory Exercise
Problem:
You are building three different NLP systems: (1) a keyword extractor for news articles, (2) a sentiment analyzer for product reviews, and (3) a machine translation system (English to French). For each, decide whether to remove stopwords and justify your answer.
Hints:
- Think about what information stopwords carry in each task
Coding Exercise
Problem:
Write a function that removes stopwords from text, with an option to preserve negation words. Test it on sentences where negation changes the meaning.
Hints:
- Use NLTK's stopwords list as the base
Scraped text arrives full of HTML tags, URLs, email addresses, phone numbers in five formats, hashtags, and runs of whitespace. You need to remove some of these, extract others into structured fields, and validate the rest, across millions of documents, before any model sees the data. Writing a custom loop for each pattern is slow and error-prone. You need a compact language for describing patterns in text.
The previous topic, Stopword Removal, dropped whole tokens by list membership. This topic matches patterns inside text with regular expressions, using Python's re module. It starts with character classes and quantifiers, the vocabulary for describing what to match and how many times. Capture groups follow, which extract specific parts of a match, such as the domain of an email address. Substitution with re.sub then turns matching into cleaning. The topic ends with a set of common NLP patterns for emails, URLs, hashtags, mentions, and numbers, along with the edge cases where each one fails. Regular expressions also have limits: they cannot parse nested structures.
Definition
A regular expression is a pattern that describes a set of strings using literal characters, character classes, quantifiers, alternation, and groups. A regex engine scans text for substrings in that set, which supports searching, extracting captured groups, and substituting replacements.
In this topic
- 1Character Classes and Quantifiers
- 2Pattern Extraction with Groups
- 3Text Cleaning with Substitution
- 4Common NLP Regex Patterns
Character Classes and Quantifiers
A character class matches one character from a set. Square brackets list the set, as in [aeiou], and ranges such as [A-Z] cover sequences. Shorthand classes cover common sets: \d is a digit, \w a word character (letter, digit, or underscore), and \s whitespace, with capital versions negating them. \b matches a word boundary without consuming a character. Quantifiers say how many times the preceding item repeats: * means zero or more, + one or more, ? zero or one, and {n,m} between n and m. Quantifiers are greedy by default and match as much as possible; adding ? makes them lazy. Python's \w matches Unicode letters by default.
A character class like tests one character in constant time. Regular expressions can be matched in time for a string of length and a pattern of size by simulating a Thompson NFA, which tracks all possible states at once. Python's re module, like PCRE and Perl, uses backtracking instead, which supports backreferences but can take exponential time on patterns with nested quantifiers such as (a+)+ applied to a long string of a's followed by a non-matching character. That behavior enables regular-expression denial-of-service attacks, so avoid nested quantifiers on untrusted input.
Write regex patterns to match: (a) a US phone number like (555) 123-4567, (b) a word starting with a capital letter.
Pattern Extraction with Groups
Parentheses do two jobs in a pattern: they group items so a quantifier or alternation applies to all of them, and they capture the matched text for later use. When a pattern contains capture groups, re.findall() returns the group contents rather than the whole match, which makes extraction a one-liner. Named groups, written (?P<name>...), label each part, so code reads m.group('domain') instead of a group number. Non-capturing groups, written (?:...), group without capturing, which keeps findall output clean when grouping is only needed for alternation. A common bug is adding a group for alternation and suddenly getting only part of each match back.
Capturing groups (...) extract matched substrings. The pattern (\d{4})-(\d{2})-(\d{2}) on '2024-01-15' produces groups ('2024', '01', '15'). Each group adds overhead per match. Named groups (?P<year>\d{4}) improve readability. Non-capturing groups (?:...) save the group-allocation cost when you need grouping for alternation but do not need the captured text.
Extract the domain from email addresses in the text: "Contact [email protected] or [email protected]".
Text Cleaning with Substitution
re.sub(pattern, replacement, text) replaces every match with the replacement, which can be a string, a string with group references such as \1, or a function that computes each replacement. It is the main tool for cleaning: stripping tags, deleting URLs, collapsing runs of whitespace, and masking numbers or emails with placeholder tokens. Order matters, because each step changes the text the next step sees; for example, removing tags before URLs avoids leaving fragments of markup. Regex substitution suits flat patterns but cannot parse nested structures such as HTML, so for real web pages use an HTML parser to extract text and keep regex for the leftovers.
Substitution re.sub(pattern, replacement, text) runs the regex engine and replaces matches in for simple patterns. Cleaning HTML tags with re.sub(r'<[^>]+>', '', html) removes everything between angle brackets. The pattern [^>]+ is a negated character class that matches any character except '>', preventing catastrophic backtracking by never crossing a tag boundary.
Clean the following text: "<p>Hello & welcome! Check http://example.com for details.</p>"
Common NLP Regex Patterns
A few patterns cover most preprocessing needs: emails, [\w.+-]+@[\w-]+\.[\w.-]+; URLs, https?://\S+; hashtags, #\w+; mentions, @\w+; numbers with thousands separators and decimals, \d{1,3}(?:,\d{3})*(?:\.\d+)?; and simple dates. Each is a trade-off between precision and recall: no short regex matches every valid email address, and the simple URL pattern grabs trailing punctuation. Compile patterns once with re.compile, test them against real samples including edge cases, and prefer masking matches with placeholder tokens over deleting them when the model may need to know that a URL or number was present.
Common NLP patterns: email [\w.+-]+@[\w-]+\.[\w.]+, URL https?://[^\s]+, hashtag #\w+, mention @\w+. Each pattern targets a specific token type for extraction or normalization. The email pattern has complexity because character classes are non-overlapping. Composing patterns with alternation (email|url|hashtag) lets a single pass extract all special tokens.
Extract all hashtags and mentions from: "@alice shared #MachineLearning tips with @bob about #NLP and #DeepLearning".
Theory Exercise
Problem:
You receive a dataset of customer reviews scraped from the web. The reviews contain: HTML tags, URLs, email addresses, excessive whitespace, and special characters like & and <. Design a regex-based cleaning pipeline (ordered sequence of re.sub() calls) that produces clean, readable text.
Hints:
- Order matters: remove HTML tags before decoding HTML entities
Coding Exercise
Problem:
Write a text cleaning function using regex that handles HTML removal, URL removal, email extraction, whitespace normalization, and special character cleaning. Return both the cleaned text and any extracted emails/URLs.
Hints:
- Use re.findall() to extract before re.sub() removes
Related Problems on PixelBank
Text normalization is the process of transforming text into a consistent, canonical form. This includes lowercasing, expanding contractions ("don't" to "do not"), removing accents ("cafe" vs "café"), normalizing unicode representations, and correcting common misspellings. Without normalization, "Cafe", "cafe", "CAFE", "café", and "cafè" would all be treated as different words.
Normalization choices depend heavily on the task and language. Lowercasing is standard for most English NLP tasks, but case carries meaning for named entity recognition ("Apple" the company vs "apple" the fruit). Unicode normalization is essential for multilingual text where the same character can have multiple representations. Contraction expansion helps with consistency but can introduce errors in informal text.
The goal is to reduce surface-level variation so that the model or algorithm can focus on semantic differences rather than superficial ones. A well-designed normalization pipeline can significantly improve model performance by reducing vocabulary size and increasing the overlap between training and test vocabularies.
Definition
Text normalization maps different surface forms of the same content to one canonical form. Typical steps are case folding, Unicode normalization to a standard form such as NFC or NFKC, contraction expansion, and optional accent removal, chosen so that differences irrelevant to the task disappear while meaningful ones survive.
In this topic
- 1Case Normalization
- 2Unicode Normalization
- 3Contraction Expansion
- 4Accent and Diacritic Removal
Case Normalization
Lowercasing merges "The", "THE", and "the" into one token, which shrinks the vocabulary and pools training evidence for each word. It is standard for search and for uncased models such as bert-base-uncased. The cost is that case often carries meaning: "Apple" the company versus "apple" the fruit, "US" the country versus "us" the pronoun, and capitals that mark sentence starts or names, a key clue for named entity recognition. Truecasing restores the most likely casing from context, and cased models keep the information at the price of a larger vocabulary. For full Unicode text, str.casefold() is more aggressive than lower(), mapping German ß to "ss".
Lowercasing maps every character to in ASCII. This reduces vocabulary by collapsing case variants: 'The', 'THE', 'the' 'the'. The vocabulary reduction is for English. The trade-off: 'Apple' (company) and 'apple' (fruit) become indistinguishable. Case-sensitive models preserve this distinction at the cost of larger vocabularies.
"WHO recommends masks." vs "Who recommends masks?" -- Why does lowercasing cause problems here?
Unicode Normalization
Unicode can encode the same visible text in different ways. "é" can be one code point, U+00E9, or two: "e" followed by the combining acute accent U+0301. The two strings look identical but compare unequal, so lookups, deduplication, and counting fail silently. Unicode defines four normalization forms: NFC composes characters where possible, NFD decomposes them, and NFKC and NFKD additionally fold compatibility characters, mapping the ligature "fi" to "f" and "i" or full-width letters to ASCII. Python's unicodedata.normalize('NFC', text) applies a form. NFC is the usual default for storage; NFKC suits search, but it is lossy, changing superscripts and other formatting distinctions.
Unicode normalization form NFC composes characters: 'e' + '\u0301' (combining acute) '\u00e9' ('e'). NFD decomposes: '\u00e9' 'e' + '\u0301'. For string comparison, both strings must use the same form. Without normalization, byte-identical comparison fails: 'cafe\u0301' 'caf\u00e9' even though they render identically. NFC is preferred because it produces shorter byte sequences.
A search engine indexes "café" (NFC) but the user types "cafe\u0301" (NFD). Will the search match?
Contraction Expansion
Contractions fuse two words, as in "don't", "I'm", and "they've". Expanding them to "do not", "I am", and "they have" gives every word its own token, which helps whitespace tokenizers and makes negation explicit for bag-of-words models. A lookup table handles standard forms, but several are ambiguous: "he's" can be "he is" or "he has", and "I'd" can be "I would" or "I had", so resolving them correctly needs context such as the following verb. Penn Treebank tokenizers sidestep the problem by splitting "don't" into "do" and "n't", and subword models learn contractions directly, so expansion is mostly a concern for classical pipelines.
Contraction expansion maps shortened forms to full forms using a dictionary: "don't" "do not", "I'm" "I am". A contraction dictionary for English has entries. Expansion increases token count by but ensures each word is a separate, standard token. For models that split on whitespace, unexpanded contractions create non-standard vocabulary entries like "don't" that confuse tokenizers.
Expand contractions in: "I can't believe they're not coming. Who's gonna tell 'em?"
Accent and Diacritic Removal
Accent removal maps "café" to "cafe" and "naïve" to "naive", so users who type without accents still find matches. The standard recipe is to decompose with NFD, which splits accented letters into a base letter plus combining marks, then delete every character in Unicode category Mn (nonspacing mark), then optionally recompose with NFC. It is deliberately lossy. In many languages accents distinguish words: Spanish "papá" (dad) and "papa" (potato), or French "où" (where) and "ou" (or). Letters that are not built from combining marks, such as "ø" or "ß", pass through unchanged. Use accent folding for matching and search keys, not for text a model needs to understand.
Accent removal strips diacritical marks: 'cafe\u0301' 'cafe', 'na\u00efve' 'naive'. Unicode NFD decomposes accented characters, then a regex strips category 'Mn' (non-spacing marks). This is lossy — 'resume' and 'r\u00e9sum\u00e9' become identical — but useful for search where users may omit accents. For multilingual NLP, accent removal is often harmful because accents carry meaning (e.g., Spanish 'si' vs 's\u00ed').
Remove accents from: "Résumé for naïve café in Zurüch".
Theory Exercise
Problem:
You are preprocessing a multilingual dataset containing English, French, German, and Chinese text for a cross-lingual search engine. Design a normalization strategy that handles: (1) case normalization with named entity awareness, (2) unicode normalization across 4 languages, (3) accent handling for French and German without destroying meaning. Explain your trade-offs.
Hints:
- Consider whether to lowercase Chinese text (it has no case)
Coding Exercise
Problem:
Write a comprehensive text normalization function that handles lowercasing, unicode normalization, accent removal, contraction expansion, and whitespace normalization. Make each step optional via parameters.
Hints:
- Use unicodedata.normalize() for unicode normalization
Related Problems on PixelBank
At the lowest level, text is just bytes. Understanding character encodings---how characters map to byte sequences---is essential for any NLP practitioner. Encoding errors are among the most frustrating bugs in NLP pipelines: mojibake (garbled text like "café" instead of "café"), silent data corruption, and crashes on unexpected byte sequences.
The modern standard is UTF-8, which can represent every Unicode character using 1--4 bytes. ASCII characters (English letters, digits, basic punctuation) use 1 byte, making UTF-8 backward-compatible with ASCII. European accented characters use 2 bytes, East Asian characters use 3 bytes, and emoji use 4 bytes. Understanding this variable-width encoding is critical for correctly counting characters, slicing strings, and processing multilingual text.
Python 3 uses Unicode strings internally (UTF-8 is just the encoding used for I/O). But data from files, web scraping, databases, and APIs may arrive in any encoding: UTF-8, Latin-1 (ISO-8859-1), Windows-1252, Shift-JIS, or even raw bytes. Detecting and handling these correctly is a core skill.
Definition
A character encoding maps characters to byte sequences. Unicode assigns each character a code point, and UTF-8 encodes each code point in 1 to 4 bytes, with ASCII characters as single bytes. Decoding with the wrong encoding produces mojibake or errors, so every byte input must be decoded with its true encoding.
In this topic
- 1ASCII and UTF-8
- 2Encoding Detection and Conversion
- 3Handling Special Characters
- 4Multilingual Text and Encoding Best Practices
ASCII and UTF-8
ASCII, standardized in 1963, uses 7 bits for 128 characters: English letters, digits, punctuation, and control codes. Unicode assigns a code point to every character in the world's scripts, more than 150,000 so far, and UTF-8 encodes each code point in 1 to 4 bytes. As the formula shows, code points up to U+007F take 1 byte, so every ASCII file is already valid UTF-8; Latin accents, Greek, Cyrillic, Arabic, and Hebrew take 2; most Chinese, Japanese, Korean, and Indic characters take 3; and emoji take 4. The variable width means byte length and character count differ, which breaks code that slices bytes or assumes one byte per character.
ASCII uses 7 bits to encode 128 characters (). UTF-8 is a variable-width encoding: 1 byte for ASCII (), 2 bytes for Latin/Greek (), 3 bytes for CJK (), and 4 bytes for emoji. A string of characters takes between and bytes in UTF-8. English text averages bytes/char; Chinese text averages bytes/char.
How many bytes does UTF-8 use for the string "Hello" vs "你好" (Chinese: hello) vs "😀" (grinning face emoji)?
Encoding Detection and Conversion
Bytes carry no label saying how to decode them, so files, scraped pages, and database dumps can arrive in UTF-8, Latin-1, Windows-1252, Shift-JIS, or something else. Detection libraries such as chardet and charset-normalizer guess from byte patterns and report a confidence, but short or mostly ASCII inputs are ambiguous. UTF-8 helps because its multibyte sequences follow strict rules, so random Latin-1 text rarely decodes as valid UTF-8. The classic failure is mojibake: UTF-8 bytes decoded as Latin-1 turn "é" into "é". When the damage is a single wrong decode, it is reversible by encoding back to Latin-1 and decoding as UTF-8.
Encoding detection uses byte-frequency statistics. UTF-8 BOM (byte order mark) is EF BB BF; UTF-16 BOM is FE FF or FF FE. Without a BOM, heuristics examine byte patterns: valid UTF-8 sequences follow strict prefix rules. Libraries like chardet compute a confidence score using a Bayesian model trained on labeled documents. Confidence is typically reliable.
You scrape a web page and get the bytes: b'caf\xc3\xa9'. What encoding is this, and what text does it represent?
Handling Special Characters
Beyond letters, text carries characters that cause silent bugs. Zero-width characters such as U+200B (zero-width space) and the byte order mark U+FEFF are invisible but break string equality and tokenization. Non-breaking spaces (U+00A0) and curly quotes differ from their ASCII lookalikes, and homoglyphs such as Cyrillic "а" can spoof Latin letters. The replacement character U+FFFD signals that an earlier decode failed. Emoji are multi-byte and may combine several code points with joiners. For each category, choose to keep, remove, or map it to a token. Zero-width characters should almost always go, while emoji often carry sentiment and are worth keeping or converting to text.
Special characters like curly quotes ('\u2018', '\u2019'), em-dashes ('\u2014'), and non-breaking spaces ('\u00a0') cause silent bugs in NLP pipelines. A homoglyph attack replaces ASCII 'a' () with Cyrillic 'a' () — visually identical but different code points. Normalizing to ASCII equivalents prevents these issues: map all quote variants to ASCII ', all dashes to -, all spaces to 0x20.
A user's review contains: "Great product! 👍👍👍\u200b\u200b\u200b" (thumbs up emoji followed by zero-width spaces). How do you preprocess this?
Multilingual Text and Encoding Best Practices
Multilingual pipelines fail at the boundaries where bytes become text. A few habits prevent most bugs. Store and exchange text as UTF-8, and pass encoding='utf-8' to open() explicitly, because the platform default differs, notably on older Windows setups. Normalize to NFC right after decoding so equal strings compare equal. Remember that len() counts code points, not bytes or visible characters: a flag emoji is two code points, and accented letters may be one or two depending on normalization. Strip a leading byte order mark, which Windows tools often add to UTF-8 files, by reading with utf-8-sig. Finally, log and inspect decode errors instead of silently replacing them.
The rule is to decode bytes to str at input boundaries (), process Unicode text internally, and encode back to bytes at output (). Since Python 3.3, a str is stored using 1, 2, or 4 bytes per code point depending on the widest character it contains, so its memory layout is independent of the encoding used on disk. Mixing bytes and str raises a TypeError, and decoding bytes with the wrong codec raises UnicodeDecodeError or, with Latin-1, silently produces mojibake.
A CSV file has the BOM character (\ufeff) at the start. Your code reads the first column header as "\ufeffName" instead of "Name". How do you fix this?
Theory Exercise
Problem:
You are building an NLP pipeline that ingests text from 5 sources: a UTF-8 JSON API, a Latin-1 encoded CSV from a European database, a web scrape with unknown encoding, a PDF extraction, and user input from a web form. Design an encoding normalization strategy that handles all 5 sources and prevents mojibake.
Hints:
- Consider using chardet for unknown encodings
Coding Exercise
Problem:
Write a function that detects the encoding of a byte string, converts it to UTF-8, handles BOM removal, and reports the encoding details. Test it with strings in different encodings.
Hints:
- Use chardet.detect() for encoding detection