Getting Started
NLTK & spaCy Setup
NLTK is great for education and research; spaCy is production-ready and fast. spaCy models return Doc objects with tokenized text, POS tags, dependencies, and more. Install language models with 'python -m spacy download <model>' — en_core_web_sm is the small English model.
# install NLTK and spaCy
pip install nltk spacy
python -m spacy download en_core_web_sm
import nltk
import spacy
# download NLTK data
nltk.download('punkt')
nltk.download('stopwords')
# load a spaCy model
nlp = spacy.load("en_core_web_sm")
doc = nlp("Apple is looking at buying a U.K. startup.")
for token in doc:
print(token.text, token.pos_, token.dep_)spaCy Pipeline Basics
spaCy's pipeline is modular: the tokenizer always runs first, then optional components like tagger, parser, ner. Disabling unused components gives big speedups (e.g. for embeddings you only need tok2vec). Use nlp.pipe() for batches — far faster than calling nlp() in a loop.
import spacy
# blank pipeline (no pretrained components)
nlp_blank = spacy.blank("en")
# full pipeline: tokenizer -> tagger -> parser -> ner -> ...
nlp = spacy.load("en_core_web_sm")
print(nlp.pipe_names) # ['tok2vec', 'tagger', 'parser', 'ner', ...]
# disable components you don't need (faster)
nlp_fast = spacy.load("en_core_web_sm", disable=["parser", "ner"])
# process many texts efficiently
docs = list(nlp.pipe(["Hello world.", "SpaCy is fast."]))
# stream from file with nlp.pipe(texts, batch_size=1000)NLTK Corpora & Data
NLTK ships wrappers for many classic corpora — Gutenberg (books), Brown (categorized text), Reuters (news), WordNet (lexicon). Downloads are one-time and cached. Useful for tutorials and benchmarks, but for production prefer modern datasets from Hugging Face 'datasets'.
import nltk
# one-time downloads (cached in ~/nltk_data)
nltk.download('gutenberg') # Project Gutenberg books
nltk.download('reuters') # Reuters news corpus
nltk.download('brown') # Brown corpus (categorized)
nltk.download('wordnet') # lexical database
nltk.download('averaged_perceptron_tagger')
from nltk.corpus import gutenberg, brown
# first sentences of Moby Dick
print(gutenberg.sents('melville-moby_dick.txt')[0][:8])
# Brown corpus categories
print(brown.categories()) # ['news', 'editorial', 'reviews', ...]
news_words = brown.words(categories='news')First NLP Script (Text Stats)
A quick way to profile text: token count, sentence count, unique lemmas, and content-word frequency (after removing stopwords). spaCy Doc is iterable and exposes sentences via doc.sents. Counter from collections is the simplest frequency tool.
import spacy
nlp = spacy.load("en_core_web_sm")
text = "The quick brown fox jumps over the lazy dog. The dog barks."
doc = nlp(text)
# basic stats
print("Tokens:", len(doc))
print("Sentences:", len(list(doc.sents)))
print("Unique words:", len({t.lemma_.lower() for t in doc if t.is_alpha}))
# word frequency
from collections import Counter
words = [t.text.lower() for t in doc if not t.is_stop and t.is_alpha]
print(Counter(words).most_common(5))Reading & Cleaning Text Files
Real-world text is messy: inconsistent encoding, HTML, smart quotes, dashes. Always read with explicit utf-8. Normalize whitespace and strip HTML before tokenizing. Lowercasing loses information (US vs us) — only do it for bag-of-words style tasks, not for NER or POS tagging.
import re
from pathlib import Path
# read with proper encoding
raw = Path("doc.txt").read_text(encoding="utf-8")
# normalize whitespace
text = re.sub(r"\s+", " ", raw).strip()
# remove HTML tags
text = re.sub(r"<[^>]+>", " ", text)
# fix curly quotes / dashes to ASCII
text = text.replace("\u2019", "'").replace("\u201c", '"')
text = text.replace("\u2013", "-").replace("\u2014", "-")
# lowercase only if case-insensitive task
clean = text.lower()Tokenization
NLTK Word & Sentence Tokenization
NLTK's punkt tokenizer splits sentences using a trained model that handles abbreviations like 'Mr.'. word_tokenize uses the Treebank convention which splits contractions ('don't' -> 'do' + "n't"). Choose a tokenizer that matches how your downstream model was trained — mismatched tokenization silently hurts performance.
from nltk.tokenize import word_tokenize, sent_tokenize
text = "Hello there! How are you? I'm fine, thanks."
# sentence split (handles abbreviations)
sents = sent_tokenize(text)
# ['Hello there!', 'How are you?', "I'm fine, thanks."]
# word split (handles contractions, punctuation)
tokens = word_tokenize(text)
# ['Hello', 'there', '!', 'How', 'are', 'you', '?',
# 'I', "'m", 'fine', ',', 'thanks', '.']
# treebank tokenizer: 'don't' -> ['do', "n't"]
from nltk.tokenize import TreebankWordTokenizer
print(TreebankWordTokenizer().tokenize("don't stop"))spaCy Tokenization
spaCy's tokenizer is non-destructive: it never loses alignment between tokens and the original text (tok.idx gives the character offset). This matters for NER and highlighting. Custom infix/exception rules let you tune splitting without rewriting the whole pipeline.
import spacy
nlp = spacy.load("en_core_web_sm")
doc = nlp("Let's go to N.Y.C. on 03/14/2023 for $5.50!")
# tokens keep their position in the original text
for tok in doc:
print(f"{tok.text!r:12} start={tok.idx} end={tok.idx + len(tok.text)}")
# spans: contiguous slices of the Doc
span = doc[2:5] # 'go to N.Y.C.'
print(span.text)
# spaCy never loses alignment to the original string
print(doc.text == "".join(t.text_with_ws for t in doc)) # TrueRegex Tokenizer
Regex tokenizers are fast and deterministic — good when you control the text format. \w+ drops punctuation and numbers-as-words; pair it carefully with your task. WhitespaceTokenizer is the cheapest split but leaves punctuation glued to words, which usually needs a second cleanup pass.
from nltk.tokenize import RegexpTokenizer
# words only (drop punctuation)
tok = RegexpTokenizer(r"\w+")
print(tok.tokenize("Hello, world! 123")) # ['Hello', 'world', '123']
# keep contractions: \w+|'\w+
tok2 = RegexpTokenizer(r"\w+|'\w+")
print(tok2.tokenize("don't stop")) # ['do', "n't", 'stop']
# whitespace tokenizer (keeps punctuation attached)
from nltk.tokenize import WhitespaceTokenizer
print(WhitespaceTokenizer().tokenize("Hi! Bye.")) # ['Hi!', 'Bye.']Subword Tokenization (BPE / WordPiece)
Subword tokenizers (BPE, WordPiece, Unigram, SentencePiece) split rare words into common pieces, balancing vocabulary size and coverage. They are the standard for modern transformers — BERT uses WordPiece, GPT-2 uses BPE, T5 uses SentencePiece. Train on your own corpus to match the target domain.
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
# train a Byte-Pair-Encoding tokenizer
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()
trainer = BpeTrainer(
special_tokens=["[UNK]", "[PAD]", "[CLS]", "[SEP]"],
vocab_size=8000,
)
tokenizer.train(files=["corpus.txt"], trainer=trainer)
enc = tokenizer.encode("Tokenization is fun.")
print(enc.tokens) # ['Tok', 'eni', 'zer', ...]
print(enc.ids) # [34, 912, ...]Custom spaCy Tokenizer Rules
Use add_special_case for domain terms (URLs, hashtags, drug names) that the default tokenizer mishandles. Modifying infix/prefix/suffix patterns changes global splitting behavior — test on a sample set. Prefer special cases over full rewrites; they compose cleanly with the rest of the pipeline.
import spacy
from spacy.tokenizer import Tokenizer
from spacy.util import compile_infix_regex, compile_prefix_regex
nlp = spacy.load("en_core_web_sm")
# add a special-case rule
specials = {"gimme": [{ORTH: "gim"}, {ORTH: "me"}]}
for text, pattern in specials.items():
nlp.tokenizer.add_special_case(text, pattern)
print([t.text for t in nlp("gimme that")]) # ['gim', 'me', 'that']
# customize infixes (e.g. split on hyphens)
infixes = compile_infix_regex([r"-"])
nlp.tokenizer.infix_finditer = infixes.finditer
print([t.text for t in nlp("state-of-the-art")])Transformer Tokenizers (Hugging Face)
Always pair a model with its exact tokenizer — mismatched vocabularies silently break everything. AutoTokenizer.from_pretrained auto-selects the right class (BertTokenizer, GPT2Tokenizer, etc.). bert-base-uncased lowercases and adds [CLS]/[SEP]; cased variants preserve case. Set truncation/max_length to avoid errors on long inputs.
from transformers import AutoTokenizer
# load the tokenizer matching a pretrained model
tok = AutoTokenizer.from_pretrained("bert-base-uncased")
enc = tok("Tokenization with BERT.", return_tensors="pt")
print(enc.keys()) # dict_keys(['input_ids', 'token_type_ids', 'attention_mask'])
print(enc["input_ids"]) # tensor([[101, 19204, 3989, 102]])
print(tok.convert_ids_to_tokens(enc["input_ids"][0]))
# ['[CLS]', 'tokenization', 'with', 'bert', '.', '[SEP]']
# handle long inputs with sliding window
print(tok("Very long text", truncation=True, max_length=512))POS Tagging
NLTK POS Tagging
The default perceptron tagger uses the Penn Treebank tagset (NN=noun, VB=verb, JJ=adjective, PRP=pronoun...). It's context-sensitive — 'permit' as a verb (VB) vs noun (NN) is disambiguated by surrounding words. POS tags are a cheap feature that improves NER, chunking, and rule-based extraction.
import nltk
from nltk.tokenize import word_tokenize
nltk.download('averaged_perceptron_tagger_eng')
tokens = word_tokenize("They refuse to permit us to obtain the permit.")
tags = nltk.pos_tag(tokens)
# [('They', 'PRP'), ('refuse', 'VBP'), ('to', 'TO'),
# ('permit', 'VB'), ..., ('permit', 'NN'), ('.', '.')]
# note: the two 'permit' tokens get different tags (VB verb vs NN noun)
for word, tag in tags:
print(f"{word:10} {tag}")spaCy POS & Morphology
spaCy gives both coarse Universal POS (pos_, language-agnostic) and fine-grained Treebank tags (tag_). The morph object carries features like tense, number, person. Universal tags are best for cross-lingual work; Treebank tags carry more detail for English-specific rules.
import spacy
nlp = spacy.load("en_core_web_sm")
doc = nlp("The children were playing happily.")
for tok in doc:
print(f"{tok.text:12} POS={tok.pos_:6} TAG={tok.tag_:5} "
f"lemma={tok.lemma_:10} morph={tok.morph}")
# tok.pos_ -> coarse Universal POS (NOUN, VERB, ADJ, ...)
# tok.tag_ -> fine-grained Treebank tag (NN, VBD, JJ, ...)
# tok.morph -> morphological features (Tense=Past, Number=Plur, ...)Universal POS Tags
Universal POS tags (from Universal Dependencies) use the same 17 labels across all languages — invaluable for multilingual pipelines. PROPN (proper nouns) vs NOUN matters for NER. Counting POS proportions (lexical density = NOUN+VERB+ADJ / total) is a simple text-style feature.
# Universal Dependencies tagset (17 tags, same across languages)
# ADJ - adjective | ADP - adposition (prep/post)
# ADV - adverb | AUX - auxiliary (is, has)
# CCONJ - coordinating conj | DET - determiner (the, a)
# INTJ - interjection | NOUN - noun
# NUM - numeral | PART - particle ('s, not)
# PRON - pronoun | PROPN - proper noun
# PUNCT - punctuation | SCONJ - subordinating conj
# SYM - symbol | VERB - verb
# X - other
import spacy
nlp = spacy.load("en_core_web_sm")
doc = nlp("She quickly ran to the store.")
nouns = [t.text for t in doc if t.pos_ in ("NOUN", "PROPN")]
verbs = [t.text for t in doc if t.pos_ == "VERB"]Reading Tagged Corpora
Tagged corpora are gold for training or evaluating taggers and for linguistic analysis. Brown uses a rich tagset (with suffixes like -TL for titles, -NC for citations). Comparing tag distributions across Brown categories (news vs fiction) reveals stylistic differences in part-of-speech usage.
import nltk
from nltk.corpus import brown, treebank
# Brown: each word pre-tagged
sent = brown.tagged_sents(categories='news')[0]
print(sent) # [('The', 'AT'), ('Fulton', 'NP-TL'), ...]
# Penn Treebank ( Wall Street Journal)
for s in treebank.tagged_sents()[:1]:
print(s)
# count tag distribution
from collections import Counter
tags = [tag for w, tag in brown.tagged_words(categories='news')]
print(Counter(tags).most_common(5))Rule-Based & Lookup Tagging
For small or domain-specific data, simple taggers with backoff chains work surprisingly well: try the most-specific tagger first, fall back to a broader one for unknown words. UnigramTagger learns the most common tag per word; DefaultTagger ('NN') handles unseen words. RegexTagger catches numbers, dates, and capitalization patterns.
from nltk.tag import UnigramTagger, DefaultTagger
from nltk.corpus import brown
# fallback: tag everything as NN
default = DefaultTagger('NN')
# unigram: most-likely tag per word (from training data)
train = brown.tagged_sents(categories='news')[:3000]
uni = UnigramTagger(train, backoff=default)
# backoff chain: unigram -> default
test = brown.tagged_sents(categories='news')[3000:3100]
print(f"Accuracy: {uni.evaluate(test):.3f}")
# regex tagger for patterns
from nltk.tag import RegexpTagger
regex = RegexpTagger([(r'^-?[0-9]+(\.[0-9]+)?$', 'CD')], backoff=default)Named Entity Recognition
spaCy NER
spaCy's pretrained NER recognizes ORG, PERSON, GPE (geo-political), DATE, MONEY, etc. doc.ents are spans aligned to the tokenizer. displacy.render/serve gives a nice HTML visualization. Accuracy on general news text is good; for specialized domains (medical, legal) you'll need to fine-tune.
import spacy
nlp = spacy.load("en_core_web_sm")
doc = nlp("Apple is buying a U.K. startup for $1 billion in New York.")
for ent in doc.ents:
print(f"{ent.text:20} {ent.label_:10} {spacy.explain(ent.label_)}")
# Apple ORG Companies, agencies, institutions
# U.K. GPE Countries, cities, states
# $1 billion MONEY Monetary values
# New York GPE Countries, cities, states
# visualize
from spacy import displacy
displacy.serve(doc, style="ent") # opens a browserNLTK NE Chunking
NLTK's ne_chunk combines POS tagging with a grammar-based chunker. Output is a tree where named entities are subtrees labeled PERSON, ORGANIZATION, GPE, etc. It's slower and less accurate than spaCy but useful for learning the chunking concept and for rule-based extensions.
import nltk
from nltk.tokenize import word_tokenize, sent_tokenize
text = "Barack Obama was born in Hawaii and led the USA."
sents = sent_tokenize(text)
for s in sents:
tokens = word_tokenize(s)
tags = nltk.pos_tag(tokens)
# ne_chunk returns a nested tree of named entities
tree = nltk.ne_chunk(tags)
print(tree)
# (S
# (PERSON Barack/NNP Obama/NNP)
# was/VBD born/VBN in/IN
# (GPE Hawaii/NNP) ...)
# extract just the named entities
for node in tree:
if hasattr(node, 'label'):
name = " ".join(w for w, t in node)
print(node.label(), name)Entity Types & Labels
spaCy's English NER uses the OntoNotes label set — broad coverage of person, organization, location, date, money, quantity. Use spacy.explain(label) to get human-readable descriptions. For finer-grained types (e.g. distinguishing CEO from COMPANY) train a custom model or use an external knowledge base.
import spacy
nlp = spacy.load("en_core_web_sm")
# list all labels the model knows
for label in nlp.get_pipe("ner").labels:
print(f"{label:12} {spacy.explain(label)}")
# PERSON People, including fictional
# NORP Nationalities or religious/political groups
# FAC Buildings, airports, highways
# ORG Companies, agencies, institutions
# GPE Countries, cities, states
# LOC Non-GPE locations: mountain ranges, bodies of water
# PRODUCT Vehicles, food, clothing (not services)
# EVENT Battles, wars, sports events
# DATE Absolute or relative dates
# TIME Times smaller than a day
# MONEY Monetary values
# QUANTITY Measurements (weight, distance)
# ORDINAL First, second, ...
# CARDINAL Numerals that are not dates or amountsTraining a Custom NER Model
Fine-tune NER when the pretrained labels don't fit your domain. Provide training data as (text, {entities: [(start, end, label)]}) using character offsets — spaCy needs exact spans. Start from en_core_web_sm instead of blank to leverage existing features. 30-50 examples per entity type is a reasonable minimum; use_eval for early stopping.