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.
import spacy
from spacy.training import Example
# start from a blank model (or fine-tune a pretrained one)
nlp = spacy.blank("en")
ner = nlp.add_pipe("ner")
ner.add_label("DRUG")
# training data: text + character offsets of entities
TRAIN = [
("Take aspirin daily.", {"entities": [(5, 11, "DRUG")]}),
("Ibuprofen reduces fever.", {"entities": [(0, 9, "DRUG")]}),
]
optimizer = nlp.initialize()
for epoch in range(30):
for text, ann in TRAIN:
doc = nlp.make_doc(text)
nlp.update([Example.from_dict(doc, ann)], sgd=optimizer)
# save and use
nlp.to_disk("custom_ner")
doc = nlp("Give metformin 500mg.")
print([(e.text, e.label_) for e in doc.ents]) # [('metformin', 'DRUG')]Entity Linking & Wikification
NER finds mentions; entity linking disambiguates them to a knowledge-base entry (which 'Apple'? company vs fruit). Full linking needs a KB plus a candidate generator and a disambiguator. spaCy's built-in EntityLinker needs you to build a KB; for production, consider REL, mGENRE, or commercial APIs (Google KG, Bing Entity Search).
# Entity linking maps a mention to a unique ID in a knowledge base
# (e.g. "Apple" -> Apple Inc. the company, not the fruit)
import spacy
nlp = spacy.load("en_core_web_sm")
# spaCy's EntityLinker requires a knowledge base (KB).
# Building one from Wikipedia/Wikidata is involved; in practice use:
# Option 1: spaCy + Wikipedia-based KB (spacy-entity-linker)
# pip install spacy-entity-linker
# from spacy_entity_linker import EntityLinker
# linker = EntityLinker()
# nlp.add_pipe("entityLinker", last=True)
# Option 2: a dedicated library
# import entitylinking # or use a Wikidata API directly
# Example query via Wikidata SPARQL
import requests
url = "https://www.wikidata.org/w/api.php"
params = {"action": "wbsearchentities", "search": "Apple",
"language": "en", "format": "json"}
print(requests.get(url, params=params).json())Stemming & Lemmatization
Porter Stemmer
PorterStemmer applies a fixed sequence of suffix-stripping rules. It's fast and deterministic but produces non-words ('easili', 'studi') and ignores context — 'ran' stays 'ran' because it has no suffix to strip. Fine for search engines and topic models where stem-consistency matters more than correctness.
from nltk.stem import PorterStemmer
stemmer = PorterStemmer()
for w in ["running", "runs", "ran", "easily", "fairness", "studies"]:
print(f"{w:10} -> {stemmer.stem(w)}")
# running -> run
# runs -> run
# ran -> ran (Porter doesn't handle irregulars)
# easily -> easili (sometimes ugly)
# fairness -> fair
# studies -> studi
# fast, rule-based, deterministic
print([stemmer.stem(t) for t in "the cats are running".split()])Snowball Stemmer
SnowballStemmer is the modernized Porter ('English' version) and adds 15+ other languages. It's marginally more accurate and supports non-English text out of the box. If you need multilingual stemming without dictionaries, Snowball is the practical default; switch to a lemmatizer for higher-quality results.
from nltk.stem import SnowballStemmer
# Snowball = improved Porter, supports many languages
print(SnowballStemmer.languages)
# ['arabic', 'danish', 'dutch', 'english', 'finnish', 'french',
# 'german', 'hungarian', 'italian', 'norwegian', 'porter',
# 'portuguese', 'romanian', 'russian', 'spanish', 'swedish']
en = SnowballStemmer("english")
print(en.stem("running"), en.stem("fairly"), en.stem("studies"))
# run fair studi
# compare two languages
fr = SnowballStemmer("french")
print(fr.stem("mangerons")) # mangWordNet Lemmatizer
Lemmatization returns real dictionary words ('better' -> 'good', 'mice' -> 'mouse') by looking up WordNet. It needs the POS to disambiguate ('running' is a noun vs verb). The overhead of POS tagging is worth it when downstream features (BoW, TF-IDF) benefit from canonical forms. Slower than stemming due to dictionary lookups.
import nltk
from nltk.stem import WordNetLemmatizer
nltk.download('wordnet')
lem = WordNetLemmatizer()
# without POS, defaults to NOUN
print(lem.lemmatize("running")) # running (treated as noun)
print(lem.lemmatize("running", pos='v')) # run (verb)
print(lem.lemmatize("better", pos='a')) # good (adjective)
print(lem.lemmatize("mice")) # mouse (irregular plural)
# pipeline: tokenize -> POS tag -> lemmatize with correct POS
from nltk.tokenize import word_tokenize
from nltk import pos_tag
words = word_tokenize("The cats were running quickly.")
treebank_to_wordnet = {'N': 'n', 'V': 'v', 'J': 'a', 'R': 'r'}
for w, t in pos_tag(words):
wn_pos = treebank_to_wordnet.get(t[0], 'n')
print(w, '->', lem.lemmatize(w, pos=wn_pos))spaCy Lemmatization
spaCy lemmatizes within the pipeline — POS-aware and handles irregulars ('ate'->'eat', 'were'->'be') without extra code. Using lemma_ instead of text collapses 'cat'/'cats'/'cats'" into one feature, reducing vocabulary size. For multi-language models, lemmatization quality varies; check the model's accuracy report.
import spacy
nlp = spacy.load("en_core_web_sm")
doc = nlp("The cats were running faster. I ate two apples.")
for tok in doc:
if tok.text.lower() != tok.lemma_:
print(f"{tok.text:10} -> {tok.lemma_}")
# cats -> cat
# were -> be
# running -> run
# faster -> fast
# ate -> eat
# apples -> apple
# context-aware, handles irregulars automatically
# use lemma_ instead of text for bag-of-words features
docs_tokens = [[t.lemma_.lower() for t in nlp(d) if not t.is_stop]
for d in ["Cats run.", "The cat ran."]]Stemming vs Lemmatization
Stemming is faster but produces non-words and conflates too aggressively for some uses. Lemmatization returns valid words and respects POS, at the cost of dictionary lookups. For bag-of-words features both reduce vocabulary; for user-facing output (e.g. keyword lists), always lemmatize. For massive-scale search, stemming is the pragmatic choice.
import spacy
from nltk.stem import PorterStemmer
nlp = spacy.load("en_core_web_sm")
stem = PorterStemmer()
words = ["studies", "studying", "better", "feet", "running", "happily"]
print(f"{'word':12}{'stem':12}{'lemma':12}")
for w in words:
lemma = nlp(w)[0].lemma_
print(f"{w:12}{stem.stem(w):12}{lemma:12}")
# studies studi study
# studying studi study
# better better good <- lemma handles irregular
# feet feet foot
# running run run
# happily happili happily
# rule of thumb:
# search/indexing/topic-models -> stemming (fast)
# interpretation/visualization -> lemmatization (accurate)Stopwords
NLTK Stopwords
NLTK ships compact stopword lists per language (~180 words for English). Use a set for O(1) lookup. Always lowercase before checking, since lists are lowercase. Filtering shrinks vocabulary and removes low-information words — useful for topic models and search indexes, but harmful for sentiment ("not good" loses the negation).
import nltk
from nltk.corpus import stopwords
nltk.download('stopwords')
# 18 languages available
print(stopwords.fileids())
# ['arabic', 'english', 'french', 'german', 'spanish', ...]
en = set(stopwords.words('english'))
print(len(en)) # 179
print("the" in en) # True
print("python" in en) # False
# remove from a token list
from nltk.tokenize import word_tokenize
text = "The quick brown fox is very lazy indeed"
tokens = [w for w in word_tokenize(text.lower()) if w.isalpha()]
filtered = [w for w in tokens if w not in en]
print(filtered) # ['quick', 'brown', 'fox', 'lazy']spaCy Stopwords
spaCy marks is_stop per token, including contractions ('isn't'). The default list is larger than NLTK's (326 words). Use is_stop together with is_punct and is_alpha for clean content-word extraction. The list lives on nlp.Defaults.stop_words and can be edited at runtime per pipeline.
import spacy
nlp = spacy.load("en_core_web_sm")
# access the stopword set
print(len(nlp.Defaults.stop_words)) # 326
print("the" in nlp.Defaults.stop_words) # True
# tokens expose is_stop directly
doc = nlp("The quick fox isn't lazy")
for tok in doc:
print(f"{tok.text:8} is_stop={tok.is_stop}")
# The is_stop=True
# quick is_stop=False
# isn't is_stop=True (contraction included)
# content words only
content = [t.text for t in doc if not t.is_stop and not t.is_punct]Removing Stopwords Safely
Blindly removing stopwords can destroy information: negations ('not good' -> 'good'), multi-word entities ('New York' is fine but 'out of' might matter), and question structure ('What is X?' -> 'X?'). For sentiment, always keep negators. For NER, never remove stopwords before entity extraction.
import spacy
nlp = spacy.load("en_core_web_sm")
def remove_stopwords(text, keep_negations=True):
doc = nlp(text)
out = []
for tok in doc:
if tok.is_stop or tok.is_punct:
# keep negations for sentiment tasks
if keep_negations and tok.lemma_ in ("not", "no", "never"):
out.append(tok.text)
continue
continue
out.append(tok.text)
return " ".join(out)
print(remove_stopwords("I do not like green eggs and ham."))
# 'not like green eggs ham .'
# bigrams preserve phrases that stopwords broke:
# 'New York' should NOT lose its inner stopwords...Custom Stopword Lists
Generic lists miss domain noise ('reuters', 'inc', 'said' in news). Mine your corpus for very-high-frequency, low-information words and add them. Conversely, remove words that your task cares about (negations, modal verbs for stance detection). Save the modified pipeline so the custom list travels with the model.