Bag of Words: Document-Term Matrices
Explains how the Bag of Words model transforms text into numerical vectors through word counting, vocabulary construction, and sparse matrix storage.
Language AI HandbookAll chapters Bag of WordsFull chapter · Sections in this chapter1 / 14 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Bag of Words
How do you teach a computer to understand text? Computers are fundamentally numeric machines. Their calculations operate on binary data and floating-point numbers. Text, on the other hand, organizes symbols into words that form sentences and larger passages. Before any learning can happen, we need to bridge this gap. We need a way to translate the fuzzy, contextual world of human language into the crisp, numerical world of mathematics.
The first step is deceptively simple: count words. The Bag of Words (BoW) model converts documents into numerical vectors by tallying how often each word appears. This representation ignores grammar, word order, and context entirely. It treats a document as nothing more than a collection of words tossed into a bag, hence the name. Think of it like taking a book, ripping out every page, cutting out every word, throwing all those scraps into a literal bag, and then counting how many times each unique word appears. You lose the story, but you preserve the vocabulary, and that turns out to be surprisingly useful.
Despite its simplicity, Bag of Words powered text classification, spam detection, and information retrieval for decades before more sophisticated methods emerged. It remains a surprisingly effective baseline for many NLP tasks, and in some domains it still outperforms more complex models when labeled data is scarce. Understanding BoW matters for historical reasons and because it introduces the core concepts that persist throughout all of modern NLP: vocabulary construction, document-term matrices, and sparse vector representations. Every technique covered in this book, from TF-IDF to word embeddings to transformer attention mechanisms, builds on ideas that first appear in their clearest form in the Bag of Words model.
This chapter walks you through building a Bag of Words representation from scratch. You'll learn how to construct vocabularies, create document-term matrices, handle the explosion of dimensionality with sparse matrices, and understand when this simple approach works and when it fails. Along the way, we'll examine the mathematical foundations of document similarity, the engineering practicalities of vocabulary pruning, and the philosophical limitations that motivated everything that came after BoW.
The term "bag of words" traces back to a 1954 paper by Zellig Harris on distributional structure, where he observed that words occurring in similar contexts tend to have related meanings. The term was popularized in Zelig Harris's work and later by Gerard Salton in his development of the Vector Space Model in the 1970s. Salton's SMART information retrieval system, developed at Cornell, was one of the first to represent documents as vectors of word counts, laying the groundwork for modern search engines. The bag-of-words assumption, though crude, proved computationally tractable at a time when computers had kilobytes of memory, and it produced results good enough to justify its widespread adoption. For nearly three decades, from the 1970s through the early 2000s, it dominated text processing.
The Core Idea
The Bag of Words model rests on a single, powerful observation: documents that discuss the same topic tend to use the same words. A sports article will mention "goal", "player", and "score". A cooking article will mention "ingredient", "recipe", and "bake". Even if the sentences are completely different, the vocabulary overlaps strongly within a topic. This means that word counts alone carry a surprising amount of information about what a document is about.
The key insight is that we can represent any document as a point in a high-dimensional vector space, where each dimension corresponds to a word in the vocabulary, and the coordinate along that dimension tells us how many times that word appears. Two documents that are about the same topic will be nearby points in this space, because they use similar words with similar frequencies. Two documents about completely different topics will be far apart. This geometric intuition makes it possible to apply distance metrics, classification algorithms, and clustering techniques directly to text, using the same mathematical toolkit we use for any other numerical data.
Consider three short documents:
- "The cat sat on the mat"
- "The dog sat on the log"
- "The cat and the dog"
To represent these numerically, we first build a vocabulary: a list of all unique words across all documents. Then we count how many times each vocabulary word appears in each document. Notice that Documents 1 and 2 share a similar structure ("The [animal] sat on the [object]"), while Document 3 has a different structure. We would expect Documents 1 and 2 to be more similar to each other than either is to Document 3, and that is exactly what the vector representation will capture.
The Bag of Words model represents text as an unordered collection of words, disregarding grammar and word order but keeping track of word frequency. Each document becomes a vector where each dimension corresponds to a vocabulary word, and the value indicates how often that word appears.
Let's implement this step by step:
PythonIn [3]Expand
# Our corpus of documents
documents = [
"The cat sat on the mat",
"The dog sat on the log",
"The cat and the dog",
]
# Step 1: Tokenize and lowercase
tokenized_docs = [doc.lower().split() for doc in documents]
# Step 2: Build vocabulary from all documents
all_words = []
for doc in tokenized_docs:
all_words.extend(doc)
vocabulary = sorted(set(all_words))
word_to_idx = {word: idx for idx, word in enumerate(vocabulary)}ConsoleOut [4]Expand
Tokenized documents: Doc 1: ['the', 'cat', 'sat', 'on', 'the', 'mat'] Doc 2: ['the', 'dog', 'sat', 'on', 'the', 'log'] Doc 3: ['the', 'cat', 'and', 'the', 'dog'] Vocabulary (8 words): ['and', 'cat', 'dog', 'log', 'mat', 'on', 'sat', 'the'] Word to index mapping: 'and' → 0 'cat' → 1 'dog' → 2 'log' → 3 'mat' → 4 'on' → 5 'sat' → 6 'the' → 7
The vocabulary contains all unique words found across the documents. Each word maps to a unique integer index that will become a dimension in our vector representation. Notice that "the" appears multiple times in our documents but only once in the vocabulary, because the vocabulary stores unique words only. This compression, from raw tokens to unique vocabulary terms, is one of the defining features of the BoW representation.
PythonIn [5]Expand
import numpy as np
# Step 3: Create document vectors by counting word occurrences
def document_to_vector(doc_tokens, word_to_idx):
"""Convert a tokenized document to a count vector."""
vector = np.zeros(len(word_to_idx))
for token in doc_tokens:
if token in word_to_idx:
vector[word_to_idx[token]] += 1
return vector
# Create the document-term matrix
doc_term_matrix = np.array(
[document_to_vector(doc, word_to_idx) for doc in tokenized_docs]
)ConsoleOut [6]Expand
Document-Term Matrix: ------------------------------------------------------------ Doc and cat dog log mat on sat the ------------------------------------------------------------ 1 0 1 0 0 1 1 1 2 2 0 0 1 1 0 1 1 2 3 1 1 1 0 0 0 0 2
Each row represents a document, and each column represents a word from our vocabulary. Formally, we can express the document-term matrix as where element represents the count of vocabulary word in document . Here, is the number of documents and is the vocabulary size.
VisualizationOut [7]Expand

Look at the matrix structure. Document 1 has two occurrences of "the" (the cat... the mat), reflected in the count of 2. Document 3 shares vocabulary with both other documents, which we can see from the overlapping non-zero entries. The zeros tell us which words are absent. In practice, this means the matrix will be dominated by zeros, because any given document uses only a small fraction of the total vocabulary. We will return to this sparsity problem shortly.
Document Similarity from Word Counts
Once documents become vectors, we can measure their similarity using cosine similarity. This metric computes the cosine of the angle between two vectors, effectively measuring how similar their directions are in high-dimensional space, regardless of their magnitudes. Think of it as measuring not how long the vectors are, but which direction they point. Two documents that use the same words in similar proportions will point in roughly the same direction, even if one is much longer than the other.
The motivation for cosine similarity over simpler measures like Euclidean distance is important to understand. Euclidean distance is sensitive to document length: a long document that repeats many of the same words as a short document would appear far away in Euclidean space, simply because its word counts are larger. Cosine similarity normalizes by vector magnitude, so a 500-word article and a 50-word summary on the same topic will score close to 1.0 similarity even though their raw count vectors look very different in scale.
Given two document vectors and , cosine similarity computes the normalized dot product between them. The dot product captures how many words the two documents share (and in what quantities), while the normalization by vector magnitudes accounts for document length differences:
where:
- : the dot product of vectors and , computed as
- : the Euclidean norm (magnitude) of vector , computed as
- : the vocabulary size (number of dimensions in each vector)
- : the word counts at position in vectors and respectively
The result ranges from 0 (completely different, no shared vocabulary) to 1 (identical word distributions). By normalizing by vector magnitudes, cosine similarity ensures that documents with similar word proportions are considered similar even if one document is much longer than the other.
PythonIn [8]Expand
from sklearn.metrics.pairwise import cosine_similarity
# Compute pairwise cosine similarity
similarity_matrix = cosine_similarity(doc_term_matrix)VisualizationOut [9]Expand

Documents 1 and 2 are most similar because they share the phrase structure "The [animal] sat on the [object]". Document 3, with its different structure, shows lower similarity to both. This demonstrates how BoW captures topical similarity through shared vocabulary, even though it ignores word order. Notice that the similarity matrix is symmetric: the similarity from Document 1 to Document 2 equals the similarity from Document 2 to Document 1, which makes intuitive sense since cosine similarity is commutative.
Vocabulary Construction
Building a vocabulary seems straightforward at first glance, but real-world text introduces significant complications. Raw text varies in capitalization and punctuation and may contain numbers or contractions. These variations make vocabulary construction a surprisingly subtle problem. How do you handle punctuation? Should "cat" and "Cat" be the same word? What about "run", "running", and "ran"? What about rare words that appear only once across millions of documents? What about extremely common words like "the" that appear everywhere but say nothing distinctive about a document's content?
These are not academic questions. The answers dramatically affect the size of your vocabulary, the density of your vectors, and ultimately the quality of the models you build on top. A naive vocabulary that includes every unique character sequence as a separate word quickly grows to tens of millions of entries, most of which appear only once and contribute only noise. A carefully pruned vocabulary that retains only the most informative terms produces tighter, more discriminative representations that generalize better to new documents.
The vocabulary construction process involves several sequential decisions. First, you tokenize the text, splitting it into individual word units. Then you normalize those tokens, typically by lowercasing and stripping punctuation. Finally, you filter the resulting vocabulary using frequency thresholds to remove both extremely rare and extremely common terms. Each of these steps involves tradeoffs that depend on your specific task and domain.
From Corpus to Vocabulary
A corpus is a collection of documents. The vocabulary is the set of unique terms extracted from this corpus. Let's work with a slightly more realistic example:
PythonIn [10]Expand
corpus = [
"Machine learning is a subset of artificial intelligence.",
"Deep learning is a subset of machine learning.",
"Natural language processing uses machine learning techniques.",
"AI and machine learning are transforming industries.",
"Neural networks power deep learning systems.",
]
def tokenize(text):
"""Simple tokenization: lowercase and split on whitespace/punctuation."""
import re
# Convert to lowercase and extract word tokens
tokens = re.findall(r"\b[a-z]+\b", text.lower())
return tokens
# Tokenize all documents
tokenized_corpus = [tokenize(doc) for doc in corpus]
# Build vocabulary
all_tokens = []
for doc in tokenized_corpus:
all_tokens.extend(doc)
vocabulary = sorted(set(all_tokens))
vocab_size = len(vocabulary)ConsoleOut [11]Expand
Corpus size: 5 documents Total tokens: 36 Vocabulary size: 23 unique words Vocabulary: a, ai, and, are, artificial deep, industries, intelligence, is, language learning, machine, natural, networks, neural of, power, processing, subset, systems techniques, transforming, uses
The vocabulary is significantly smaller than the total token count because many words repeat across documents. This compression, from raw tokens to unique vocabulary terms, is a key characteristic of text data. In practice, this ratio becomes even more pronounced as the corpus grows: adding more documents adds fewer and fewer new vocabulary words because the most common words appear in the very first documents.
Word Frequency Analysis
Before finalizing the vocabulary, examining word frequencies helps identify potential issues. The distribution of word frequencies in natural language follows Zipf's Law, which states that the frequency of a word is roughly inversely proportional to its rank in the frequency table. The most common word appears about twice as often as the second most common word, three times as often as the third, and so on. This mathematical regularity directly affects vocabulary construction.
Zipf's Law means that any natural language corpus contains a small number of very high-frequency words (function words like "the", "is", "a") and a very large number of low-frequency words (specific nouns, technical terms, proper names). The high-frequency words consume disproportionate space in your vectors but carry little discriminative information, since they appear in nearly every document regardless of topic. The low-frequency words are often highly informative when they do appear, but appear so rarely that they may be noise rather than signal.
PythonIn [12]Expand
from collections import Counter
# Count word frequencies across the corpus
word_counts = Counter(all_tokens)
# Sort by frequency
sorted_counts = word_counts.most_common()ConsoleOut [13]Expand
Word Frequencies (descending): ----------------------------------- learning 6 ██████ machine 4 ████ is 2 ██ a 2 ██ subset 2 ██ of 2 ██ deep 2 ██ artificial 1 █ intelligence 1 █ natural 1 █ language 1 █ processing 1 █ uses 1 █ techniques 1 █ ai 1 █ and 1 █ are 1 █ transforming 1 █ industries 1 █ neural 1 █ networks 1 █ power 1 █ systems 1 █
VisualizationOut [14]Expand

The word "learning" appears 5 times, "machine" appears 4 times, but many words appear only once. This pattern, a few high-frequency words and many rare words, follows Zipf's Law and is characteristic of natural language. In practice, this means that the decision of where to draw the frequency cutoff for your vocabulary has a large impact on both vocabulary size and model quality.
Vocabulary Pruning
Raw vocabularies from large corpora can contain millions of unique words. Many of these are noise: typos, rare technical terms, proper nouns that appear in only one document, or words so common that they appear everywhere. Vocabulary pruning removes uninformative terms to reduce dimensionality and improve model performance.
The case for pruning is both computational and statistical. On the computational side, every word in your vocabulary adds a dimension to every document vector, and operations over high-dimensional sparse vectors are expensive. On the statistical side, rare words that appear in only one or two documents provide almost no information for a classifier: the model cannot learn reliable patterns from a single or double observation. And common words that appear in nearly every document also provide no discriminative power, because a word that is equally likely in all classes tells you nothing about which class a document belongs to.
In practice, this means that the most informative words for text classification tend to be those in the middle of the frequency distribution: not so rare that they appear in only a handful of documents, and not so common that they appear everywhere. Vocabulary pruning attempts to focus the representation on exactly this informative middle zone.
Minimum Document Frequency
The document frequency of a word , denoted , counts how many distinct documents in the corpus contain that word. Notice that document frequency is different from term frequency: if a word appears 10 times in a single document, its term frequency is 10 but its document frequency is still just 1. Document frequency tells us how widespread a word is across the corpus, which is a better measure of a word's general informativeness than raw frequency.
Formally, document frequency is the size of the set of documents containing the word:
where:
- : the corpus (collection of all documents)
- : an individual document in the corpus
- : indicates that word appears in document
- : the count of elements in the set
Words that appear in very few documents provide little discriminative power and may represent noise. The min_df parameter sets a threshold: words must appear in at least this many documents (or this fraction of documents) to be included.
PythonIn [15]Expand
def compute_document_frequency(tokenized_corpus):
"""Count how many documents each word appears in."""
doc_freq = Counter()
for doc in tokenized_corpus:
# Count each word once per document
unique_words = set(doc)
doc_freq.update(unique_words)
return doc_freq
doc_freq = compute_document_frequency(tokenized_corpus)
# Apply min_df threshold
min_df = 2 # Word must appear in at least 2 documents
filtered_vocab_min = {word for word, freq in doc_freq.items() if freq >= min_df}ConsoleOut [16]Expand
Document Frequency Analysis: --------------------------------------------- Word Doc Freq Keep (min_df=2) --------------------------------------------- a 2 ✓ ai 1 ✗ and 1 ✗ are 1 ✗ artificial 1 ✗ deep 2 ✓ industries 1 ✗ intelligence 1 ✗ is 2 ✓ language 1 ✗ learning 5 ✓ machine 4 ✓ natural 1 ✗ networks 1 ✗ neural 1 ✗ of 2 ✓ power 1 ✗ processing 1 ✗ subset 2 ✓ systems 1 ✗ techniques 1 ✗ transforming 1 ✗ uses 1 ✗ --------------------------------------------- Original vocabulary: 23 words After min_df=2: 7 words
Words like "artificial", "industries", and "networks" appear in only one document. Removing them reduces our vocabulary while keeping words that appear across multiple documents. Notice that this pruning is conservative: we are only removing words that appear in a single document, which is the lowest possible threshold. In practice with large corpora, you might set min_df=5 or min_df=10 to be more aggressive.
Maximum Document Frequency
At the other extreme, words that appear in almost every document provide no discriminative power. The word "the" might appear in 95% of documents, making it useless for distinguishing between them. If "the" appears in sports articles, cooking articles, and science articles in roughly equal proportions, a classifier that learns to look for "the" gains nothing. The max_df parameter sets an upper threshold to filter out these ubiquitous words.
This is closely related to the concept of stop words, a predefined list of common words (like "the", "is", "and", "of") that are routinely excluded from text representations. The max_df approach is more principled than fixed stop word lists because it adapts to the corpus. In a specialized medical corpus, a word like "patient" might appear in 80% of documents and should be filtered out, even though it would not appear on any general-purpose stop word list.
PythonIn [17]Expand
# Apply max_df threshold (as a fraction of documents)
max_df = 0.8 # Word must appear in at most 80% of documents
num_docs = len(tokenized_corpus)
max_doc_count = int(max_df * num_docs)
filtered_vocab_max = {
word for word, freq in doc_freq.items() if freq <= max_doc_count
}
# Combined filtering
filtered_vocab = {
word
for word, freq in doc_freq.items()
if freq >= min_df and freq <= max_doc_count
}ConsoleOut [18]Expand
Total documents: 5 max_df = 0.8 → max document count = 4 Words appearing in >80% of documents: 'learning' appears in 5/5 documents Vocabulary after min_df=2, max_df=0.8: 6 words
In our small corpus, "learning" appears in all 5 documents (100%), exceeding our 80% threshold. In real applications, you might filter out words appearing in more than 90% of documents to remove uninformative terms like "the", "is", and "a". The combined effect of min_df and max_df pruning tends to keep precisely the words that carry the most discriminative information about document content.
Vocabulary Reduction with min_df
How aggressively should you prune? The answer depends on your corpus size and your task. For very large corpora with millions of documents, even a min_df of 5 or 10 removes a substantial fraction of the vocabulary while keeping all words with meaningful statistical support. For small corpora like our example, even min_df=2 has a significant effect.
Let's visualize how vocabulary size changes as we increase the min_df threshold:
PythonIn [19]Expand
# Calculate vocabulary size at different min_df thresholds
min_df_values = range(1, num_docs + 1)
vocab_sizes = []
for threshold in min_df_values:
remaining = sum(1 for freq in doc_freq.values() if freq >= threshold)
vocab_sizes.append(remaining)VisualizationOut [20]Expand

The steep drop from min_df=1 to min_df=2 is typical. In real corpora, a large fraction of words appear only once (called hapax legomena, from the Greek for "said only once"). Removing these rare words often improves model performance by reducing noise without losing much signal. This is because hapax legomena are disproportionately likely to be misspellings, unusual proper names, or highly specialized jargon that appears in only one domain-specific document. Keeping them inflates the vocabulary and creates dimensions that are always zero except for that one document.
Count vs. Binary Representations
So far, we've counted word occurrences. But sometimes presence matters more than frequency. In a binary representation, each cell contains 1 if the word appears in the document and 0 otherwise, regardless of how many times it appears.
The choice between count and binary representation depends on what the frequency signal means for your task. Counts carry information about emphasis: a document that uses the word "excellent" ten times is making a stronger statement than one that uses it once. But counts can also mislead. A very long document will have higher counts for almost every word simply because it's longer, not because those words are more important. This length bias is one reason why TF-IDF weighting (covered in the next chapter) was developed as an improvement over raw counts.
Binary representations have a natural interpretation: they answer the question "is this topic present in the document?" rather than "how intensely is this topic discussed?" For some classification tasks, the distinction matters. Classifying whether a document is about sports or cooking depends mostly on presence of domain vocabulary, and binary features work just as well as counts. For other tasks, like distinguishing a rave review from a mild positive review, the frequency of strong positive words matters, and counts carry more signal.
PythonIn [21]Expand
# Create word-to-index mapping for our vocabulary
word_to_idx = {word: idx for idx, word in enumerate(sorted(vocabulary))}
# Count representation
def to_count_vector(tokens, word_to_idx):
vector = np.zeros(len(word_to_idx))
for token in tokens:
if token in word_to_idx:
vector[word_to_idx[token]] += 1
return vector
# Binary representation
def to_binary_vector(tokens, word_to_idx):
vector = np.zeros(len(word_to_idx))
for token in tokens:
if token in word_to_idx:
vector[word_to_idx[token]] = 1 # Set to 1, don't increment
return vector
# Compare representations for a sample document
sample_doc = tokenized_corpus[
1
] # "deep learning is a subset of machine learning"
count_vec = to_count_vector(sample_doc, word_to_idx)
binary_vec = to_binary_vector(sample_doc, word_to_idx)ConsoleOut [22]Expand
Sample document: 'Deep learning is a subset of machine learning.' Tokens: ['deep', 'learning', 'is', 'a', 'subset', 'of', 'machine', 'learning'] Comparison of representations: -------------------------------------------------- Word Count Binary -------------------------------------------------- a 1 1 deep 1 1 is 1 1 learning 2 1 ← differs machine 1 1 of 1 1 subset 1 1
Notice that "learning" appears twice in this document. The count representation records 2, while the binary representation records 1. Which is better? It depends on the task. For document classification, binary representations often work as well as counts. For tasks where word frequency carries meaning (like authorship attribution), counts are more informative. In practice, the best approach is to treat this as a hyperparameter and evaluate both on held-out data.
Sparse Matrix Representation
Real-world vocabularies contain tens of thousands of words, yet most documents use only a small fraction. A news article with 500 words might touch only 200 unique vocabulary terms out of 50,000. If you store this as a dense array with one float per vocabulary term, you need 50,000 floating-point numbers to represent each document, of which 49,800 are exactly zero. That's 49,800 zeros sitting in memory, contributing nothing to any computation.
This is the sparsity problem, and it gets dramatically worse as vocabulary size grows. The mathematics are unforgiving: if your vocabulary has words and each document uses an average of unique words, then the sparsity is approximately . For a vocabulary of 100,000 words and documents that use 300 unique words, that's 99.7% zeros. Storing and operating on dense matrices of this kind is not just inefficient, it's often completely impractical.
A sparse matrix is a matrix where most elements are zero. Sparse matrix formats store only the non-zero values and their positions, dramatically reducing memory usage for high-dimensional, mostly-empty data like document-term matrices.
The Sparsity Problem
Sparsity measures the proportion of zero elements in a matrix. For a document-term matrix with documents (rows) and vocabulary words (columns), the total number of elements in a dense representation is . The sparsity tells us what fraction of those elements are zero:
where:
- : the number of documents (rows in the matrix)
- : the vocabulary size (columns in the matrix)
- : the number of non-zero elements in the matrix
A sparsity of 0.99 means 99% of the matrix elements are zeros. In NLP, high sparsity is the norm because each document uses only a tiny fraction of the total vocabulary.
Let's quantify the sparsity in a typical document-term matrix:
PythonIn [23]Expand
# Create full document-term matrix for our corpus
doc_term_matrix = np.array(
[to_count_vector(doc, word_to_idx) for doc in tokenized_corpus]
)
# Calculate sparsity
total_elements = doc_term_matrix.size
non_zero_elements = np.count_nonzero(doc_term_matrix)
zero_elements = total_elements - non_zero_elements
sparsity = zero_elements / total_elementsConsoleOut [24]Expand
Document-Term Matrix Statistics: Shape: (5, 23) (documents × vocabulary) Total elements: 115 Non-zero elements: 35 Zero elements: 80 Sparsity: 69.6% Memory comparison: Dense matrix: 920 bytes
Even in this small example, most of the matrix consists of zeros. Each document only uses a fraction of the vocabulary. In real applications with vocabularies of 100,000+ words and millions of documents, sparsity typically exceeds 99%. Storing a dense matrix would require terabytes of memory for mostly zeros.
CSR Format
The Compressed Sparse Row (CSR) format stores only non-zero values along with their column indices and row boundaries. This is the standard format for document-term matrices because NLP operations typically process one document (row) at a time.
Think of CSR as storing three parallel arrays. The data array holds the actual non-zero values. The indices array holds the column position (vocabulary index) of each non-zero value. The indptr array holds boundary markers that tell you where each row's values begin and end in the other two arrays. To reconstruct any row of the original matrix, you look up the row's boundary in indptr, then read the corresponding slice of data and indices. This design makes row access very fast, which is exactly what you need when processing documents one at a time.
PythonIn [25]Expand
from scipy import sparse
# Convert to CSR format
sparse_matrix = sparse.csr_matrix(doc_term_matrix)
# Examine the internal structure
data = sparse_matrix.data # Non-zero values
indices = sparse_matrix.indices # Column indices of non-zero values
indptr = sparse_matrix.indptr # Row boundariesConsoleOut [26]Expand
CSR Format Internals:
--------------------------------------------------
data (non-zero values): [1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 2. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
indices (column positions): [ 0 4 7 8 10 11 15 18 0 5 8 10 11 15 18 9 10 11 12 17 20 22 1 2
3 6 10 11 21 5 10 13 14 16 19]
indptr (row boundaries): [ 0 8 15 22 29 35]
How to read this:
Row 0: values at columns indices[0:3] = indices[0:3]
→ columns [ 0 4 7 8 10 11 15 18], values [1. 1. 1. 1. 1. 1. 1. 1.]
Row 1: columns [ 0 5 8 10 11 15 18], values [1. 1. 1. 2. 1. 1. 1.]
Row 2: columns [ 9 10 11 12 17 20 22], values [1. 1. 1. 1. 1. 1. 1.]VisualizationsOut [27]Expand


The indptr array is the key to CSR. To find the non-zero values in row , look at positions indptr[i] through indptr[i+1] in both data and indices. This makes row slicing extremely efficient. In a dense matrix, accessing a row requires touching all columns even if 99% are zero. In CSR format, you touch only the non-zero elements, making operations on sparse matrices scale with the actual content density rather than the theoretical maximum.
Memory Savings
The memory advantage of sparse matrices grows dramatically with vocabulary size:
PythonIn [28]Expand
# Simulate larger matrices to show memory scaling
def estimate_memory(num_docs, vocab_size, avg_words_per_doc):
"""Estimate memory usage for dense vs sparse representations."""
# Dense: store every element (8 bytes for float64)
dense_bytes = num_docs * vocab_size * 8
# Sparse CSR: store only non-zero elements
# data array: avg_words_per_doc * num_docs * 8 bytes
# indices array: avg_words_per_doc * num_docs * 4 bytes (int32)
# indptr array: (num_docs + 1) * 4 bytes
nnz = avg_words_per_doc * num_docs
sparse_bytes = nnz * 8 + nnz * 4 + (num_docs + 1) * 4
return dense_bytes, sparse_bytes
# Test different scales
scales = [
(100, 1000, 50), # Small: 100 docs, 1K vocab
(10000, 50000, 200), # Medium: 10K docs, 50K vocab
(1000000, 100000, 300), # Large: 1M docs, 100K vocab
]ConsoleOut [29]Expand
Memory Usage: Dense vs Sparse ====================================================================== Scale Dense Sparse Savings ---------------------------------------------------------------------- 100 × 1,000 800.0 KB 60.4 KB 92.4% 10,000 × 50,000 4.0 GB 24.0 MB 99.4% 1,000,000 × 100,000 800.0 GB 3.6 GB 99.5%
For a realistic corpus of 1 million documents with a 100,000-word vocabulary, sparse representation uses less than 1% of the memory required by dense storage. This is the difference between fitting in RAM and requiring distributed storage. The practical implication is that sparse matrix libraries like SciPy make BoW-based NLP computationally feasible at scales where the naive dense approach would simply run out of memory.
VisualizationOut [30]Expand

Using scikit-learn's CountVectorizer
While understanding the internals is valuable, in practice you'll use scikit-learn's CountVectorizer. It handles tokenization, vocabulary building, and sparse matrix creation in a single, optimized package. Under the hood, CountVectorizer implements all the same steps we've built from scratch: tokenizing text, building a vocabulary, counting occurrences, and storing the result as a CSR sparse matrix. The difference is that it does all of this efficiently, with built-in support for vocabulary pruning parameters and other preprocessing options.
One important practical detail is the fit/transform distinction. You fit the vectorizer on your training data to build the vocabulary. You then transform new documents using that same fixed vocabulary. This separation preserves the vector dimensions used during training: when you deploy a model in production, you cannot modify the vocabulary based on new documents you see at inference time. Any word in a new document that wasn't in the training vocabulary is simply ignored.
PythonIn [31]Expand
from sklearn.feature_extraction.text import CountVectorizer
# Create vectorizer with common settings
vectorizer = CountVectorizer(
lowercase=True, # Convert to lowercase
min_df=1, # Minimum document frequency
max_df=1.0, # Maximum document frequency (fraction)
binary=False, # Use counts, not binary
ngram_range=(1, 1), # Unigrams only (single words)
)
# Fit and transform the corpus
bow_matrix = vectorizer.fit_transform(corpus)
# Get the vocabulary
feature_names = vectorizer.get_feature_names_out()ConsoleOut [32]Expand
CountVectorizer Results: Matrix shape: (5, 22) Matrix type: <class 'scipy.sparse._csr.csr_matrix'> Vocabulary size: 22 Vocabulary (feature names): ['ai', 'and', 'are', 'artificial', 'deep', 'industries', 'intelligence', 'is', 'language', 'learning', 'machine', 'natural', 'networks', 'neural', 'of', 'power', 'processing', 'subset', 'systems', 'techniques', 'transforming', 'uses'] Sparse matrix info: Non-zero elements: 33 Sparsity: 70.0%
CountVectorizer automatically handles tokenization, lowercasing, and vocabulary construction. The result is a sparse CSR matrix ready for machine learning. No manual preprocessing is required.
PythonIn [33]Expand
# Convert to dense for visualization (only for small matrices!)
dense_matrix = bow_matrix.toarray()VisualizationOut [34]Expand

Key Parameters
CountVectorizer offers extensive customization through its constructor parameters. Understanding what each parameter does allows you to tune the vocabulary construction process for your specific domain and dataset. The right parameter choices can dramatically improve downstream model performance by keeping informative words and discarding noise.
PythonIn [35]Expand
# Demonstrate key parameters
vectorizers = {
"default": CountVectorizer(),
"binary": CountVectorizer(binary=True),
"min_df=2": CountVectorizer(min_df=2),
"max_df=0.8": CountVectorizer(max_df=0.8),
"bigrams": CountVectorizer(ngram_range=(1, 2)),
}
results = {}
for name, vec in vectorizers.items():
matrix = vec.fit_transform(corpus)
results[name] = {
"vocab_size": len(vec.get_feature_names_out()),
"nnz": matrix.nnz,
"sample_features": list(vec.get_feature_names_out()[:5]),
}ConsoleOut [36]Expand
CountVectorizer Parameter Comparison: ====================================================================== default: Vocabulary size: 22 Non-zero elements: 33 Sample features: ['ai', 'and', 'are', 'artificial', 'deep'] binary: Vocabulary size: 22 Non-zero elements: 33 Sample features: ['ai', 'and', 'are', 'artificial', 'deep'] min_df=2: Vocabulary size: 6 Non-zero elements: 17 Sample features: ['deep', 'is', 'learning', 'machine', 'of'] max_df=0.8: Vocabulary size: 21 Non-zero elements: 28 Sample features: ['ai', 'and', 'are', 'artificial', 'deep'] bigrams: Vocabulary size: 44 Non-zero elements: 62 Sample features: ['ai', 'ai and', 'and', 'and machine', 'are']
Each parameter setting produces different results. The binary setting keeps the same vocabulary but changes counts to presence indicators. Setting min_df=2 dramatically reduces vocabulary by removing rare words. The ngram_range=(1, 2) setting includes both unigrams and bigrams, capturing two-word phrases like "machine learning" and "deep learning". This dramatically increases vocabulary size but can capture meaningful phrases.
The key parameters for CountVectorizer are:
lowercase(default:True): Convert all text to lowercase before tokenizing. Set toFalseif case carries meaning (for example, proper nouns or acronyms).min_df: Minimum document frequency threshold. If an integer, the word must appear in at least this many documents. If a float between 0.0 and 1.0, represents a proportion of documents. Usemin_df=2or higher to remove rare words and typos.max_df: Maximum document frequency threshold. Words appearing in more than this fraction of documents are excluded. Usemax_df=0.9to remove extremely common words that provide no discriminative power.binary(default:False): IfTrue, all non-zero counts are set to 1. Use binary representation when word presence matters more than frequency.ngram_range(default:(1, 1)): Tuple specifying the range of n-gram sizes to include.(1, 2)includes unigrams and bigrams, capturing phrases like "machine learning". Higher values dramatically increase vocabulary size.stop_words: Either'english'for built-in stop word list, or a custom list of words to exclude. Removes common words like "the", "is", "and" that typically add noise.max_features: Limit vocabulary to the top N most frequent terms. Useful for controlling dimensionality in very large corpora.
The Loss of Word Order
The most striking limitation of Bag of Words is its complete discard of word sequence information. "The cat chased the dog" and "The dog chased the cat" produce identical vectors, despite having opposite meanings about who is doing the chasing. This is not a minor technical footnote; it is a fundamental consequence of the bag metaphor. Once you pour the words into the bag and shake it, all positional information is irretrievably lost.
In practice, this means that BoW cannot represent a large class of linguistic phenomena that depend on word order. Negation is perhaps the most important: "I love this movie" and "I don't love this movie" produce nearly identical vectors, differing only by the addition of "don't". A BoW classifier trained to recognize positive sentiment might confidently classify the second sentence as positive, because all the positive words are still there. Similarly, comparative statements like "cats are better than dogs" and "dogs are better than cats" become indistinguishable, as do passive and active constructions, questions and statements, and most conditional relationships.
PythonIn [37]Expand
# Demonstrate word order loss
sentences = [
"The cat chased the dog",
"The dog chased the cat",
"Dog the cat the chased", # Nonsense with same words
]
vec = CountVectorizer()
vectors = vec.fit_transform(sentences)ConsoleOut [38]Expand
Word Order Demonstration: -------------------------------------------------- Vocabulary: ['cat', 'chased', 'dog', 'the'] 'The cat chased the dog' Vector: [1 1 1 2] 'The dog chased the cat' Vector: [1 1 1 2] 'Dog the cat the chased' Vector: [1 1 1 2] Sentence 1 == Sentence 2: True Sentence 1 == Sentence 3: True All three sentences produce IDENTICAL vectors!
This is the fundamental limitation of Bag of Words. It cannot distinguish between:
- Active and passive voice: "John hit Mary" vs "Mary was hit by John"
- Negation scope: "I love this movie" vs "I don't love this movie" have nearly identical vectors
- Questions and statements: "Is this good?" vs "This is good"
- Any semantic difference that depends on word order
VisualizationOut [39]Expand

A Worked Example: Spam Detection
To make the BoW model concrete, let's trace through a complete spam detection pipeline from raw text to a classification decision. Email spam detection was one of the first real-world applications where BoW excelled, and the reason is instructive: spam emails use a distinctive vocabulary (words like "free", "winner", "urgent", "prize") that differs reliably from legitimate email. You don't need to understand the grammatical structure of "CONGRATULATIONS! You have won a FREE prize!" to know it's likely spam. The presence of those words is sufficient.
Imagine you are building a spam filter for a small email service. You have 100 labeled emails: 50 spam and 50 legitimate. The process unfolds in three stages. In the first stage, preprocessing, you tokenize each email, lowercase all words, and strip punctuation. You build a vocabulary from all the words that appear in the training set. In the second stage, vectorization, you transform each email into a count vector using that vocabulary. In the third stage, classification, you train a model (such as Naive Bayes) on the count vectors and their labels.
PythonIn [40]Expand
# Quick demonstration: sentiment classification with BoW
from sklearn.naive_bayes import MultinomialNB
# Simple sentiment dataset
reviews = [
"This movie was fantastic and amazing",
"Absolutely loved this film, brilliant",
"Great movie, highly recommend",
"Terrible waste of time, boring",
"Awful movie, complete disaster",
"Worst film I have ever seen",
]
labels = [1, 1, 1, 0, 0, 0] # 1 = positive, 0 = negative
# Vectorize and train
vec = CountVectorizer()
X = vec.fit_transform(reviews)
clf = MultinomialNB()
clf.fit(X, labels)
# Test on new reviews
test_reviews = [
"This was a brilliant and fantastic experience",
"Complete waste of time, terrible",
]
test_X = vec.transform(test_reviews)
predictions = clf.predict(test_X)
probabilities = clf.predict_proba(test_X)ConsoleOut [41]Expand
Sentiment Classification with Bag of Words: -------------------------------------------------- Review: 'This was a brilliant and fantastic experience' Prediction: Positive (confidence: 97.7%) Review: 'Complete waste of time, terrible' Prediction: Negative (confidence: 97.3%)
Even this trivial example shows BoW capturing sentiment through word presence. Words like "brilliant", "fantastic", and "terrible" carry strong sentiment signals regardless of context. Notice that the model correctly classifies both test reviews despite having very small training data. The key insight is that sentiment-bearing words cluster reliably: positive reviews use a different vocabulary than negative ones, and that vocabulary-level signal is sufficient for classification on simple examples.
In practice, this pattern extends to many classification tasks. Topic classification (sports vs. politics vs. technology) works well with BoW because different topics use distinctly different vocabularies. Language detection (is this English or French?) works extremely well because the vocabularies have almost no overlap. Even author identification has been done with BoW: different authors have characteristic word preferences that persist across their writings.
When Bag of Words Works
Despite its limitations, Bag of Words remains useful for many tasks. Understanding when it works helps you make the right architectural choice for your problem.
The tasks where BoW excels share a common property: the relevant signal is captured by vocabulary rather than syntax. When you need to classify a document by topic, the presence or absence of domain-specific words is highly predictive. A document containing "electrode", "voltage", and "circuit" is almost certainly about electrical engineering regardless of how those words are arranged. BoW can exploit that signal without any knowledge of grammar or syntax.
For document classification, categorizing news articles, spam detection, or sentiment analysis on long texts, word presence often matters more than order. A movie review containing "terrible", "boring", and "waste" is likely negative regardless of how those words are arranged. The signal becomes more stable as the document contains more words, which is why BoW tends to work better on longer documents than on short phrases or sentences.
Information retrieval is another natural home for BoW. Search engines match query terms against document terms. The classic TF-IDF weighting scheme (covered in the next chapter) builds directly on the Bag of Words foundation, using word counts as the raw material for computing how important each word is to each document. Topic modeling algorithms like Latent Dirichlet Allocation (LDA) assume documents are mixtures of topics, each characterized by word distributions. The bag-of-words assumption is baked directly into the generative model.
Perhaps most importantly, BoW provides an essential baseline for any text classification task. Before deploying complex neural networks, a BoW model provides a sanity check. If a simple model achieves 90% accuracy, you know the task is learnable from word frequencies alone, and you should ask whether the additional complexity of a neural model is justified. If BoW achieves only 60%, you know that word order or semantic relationships matter for this task, which guides you toward more sophisticated approaches.
Limitations and Impact
Bag of Words has fundamental limitations that motivated the development of more sophisticated representations. Understanding these limitations precisely is important: they reveal what BoW cannot do and why, and that "why" directly motivates every subsequent advancement in NLP.
The first limitation is the complete loss of word order, which we have already examined in detail. The second is the absence of semantic knowledge: in the BoW framework, "good" and "excellent" are treated as completely unrelated words, even though they are near-synonyms. The vocabulary is treated as a set of arbitrary symbols with no relationship to one another. This means that a model trained on documents containing "excellent" has no idea how to handle "outstanding" if that word wasn't in the training vocabulary. Semantic relationships between words, the fact that some words mean similar things, that some words are opposites, that some words appear in related contexts, are entirely invisible to BoW.
The third limitation is vocabulary explosion when you try to extend BoW to capture some phrase structure. Adding n-grams helps capture some sequential patterns, but the vocabulary grows rapidly. Moving from unigrams to bigrams can multiply vocabulary size by a factor of 10 to 100, and most of those bigrams will be extremely rare. Moving to trigrams is even worse. The curse of dimensionality sets in: your vectors grow enormous while your data stays the same size, making it increasingly difficult for models to learn reliable patterns.
The fourth limitation concerns sparsity in the context of neural networks. High-dimensional sparse vectors are inefficient for neural networks, which prefer dense, lower-dimensional inputs. A neural network that receives a sparse input vector of 50,000 dimensions, of which 49,700 are zero, is wasting most of its computation processing those zeros. The dense, lower-dimensional word embeddings that replaced BoW representations for deep learning were motivated directly by this inefficiency.
Finally, out-of-vocabulary words present a fundamental challenge. Words not seen during training have no representation. A model trained on formal news text may fail entirely on social media text full of slang or abbreviations, including unconventional spellings. The vocabulary is a closed system: everything outside it is invisible.
These limitations drove the development of better representations. TF-IDF improved on raw counts by weighting words by their rarity. Word embeddings (Word2Vec, GloVe) created dense, semantic representations that capture word relationships. Contextual embeddings (ELMo, BERT) gave words different representations depending on their context, finally solving the word order problem. Yet Bag of Words remains the conceptual starting point for all of these. Understanding document-term matrices, vocabulary construction, and sparse representations supports understanding why each subsequent technique was invented and what problem it solved.
Experienced NLP engineers still reach for BoW-based models in several scenarios. When labeled data is very scarce (fewer than a few hundred examples), simple models with low variance often outperform complex ones that overfit. When interpretability matters (which words drive the classification decision?), BoW with a linear classifier gives direct coefficient-level explanations. When latency is critical (sub-millisecond inference), a sparse dot product with a weight vector is orders of magnitude faster than a neural network forward pass. And when building a quick prototype to validate that a task is learnable at all, BoW with Naive Bayes can be implemented and evaluated in minutes rather than hours. The tool is not obsolete; it is appropriate for certain conditions.
Key Functions and Parameters
When working with Bag of Words representations, CountVectorizer from scikit-learn is the primary tool. Knowing its parameters and their effects allows you to adapt the vocabulary construction process to your specific domain and scale.
The most important consideration is the min_df and max_df balance. Setting min_df too low keeps noisy rare words. Setting min_df too high removes informative but moderately rare terms. In practice, start with min_df=2 on small corpora and min_df=5 on larger ones, then tune based on downstream performance. Similarly, max_df=0.95 is a reasonable default that removes very common terms while preserving domain-specific high-frequency words.
The ngram_range parameter deserves careful consideration. Bigrams can capture important phrases like "machine learning", "not good", and "highly recommend" that unigrams miss entirely. However, they also introduce a huge number of uninformative combinations. A good practice is to start with unigrams, measure performance, and then try ngram_range=(1, 2) to see if bigrams help. If vocabulary size becomes unmanageable, combine bigrams with a stricter min_df or a max_features cap.
Summary
Bag of Words converts text into numerical vectors by counting word occurrences, ignoring grammar and word order entirely. Despite this simplification, it powers effective text classification, information retrieval, and topic modeling systems, and it established the conceptual vocabulary that all subsequent NLP methods build upon.
Key takeaways:
- Vocabulary construction extracts unique words from a corpus, mapping each to a vector dimension. The decisions made here, what to tokenize, how to normalize, and which words to keep, directly affect model quality.
- Document-term matrices represent documents as rows and vocabulary words as columns, with counts (or binary indicators) as values. The matrix entry is the count of vocabulary word in document .
- Cosine similarity measures document similarity by computing the normalized dot product between vectors, making it insensitive to differences in document length.
- Vocabulary pruning with
min_dfandmax_dfremoves uninformative rare and common words. The steep drop in vocabulary size frommin_df=1tomin_df=2reflects the Zipfian nature of language, where a large fraction of words appear only once. - Sparse matrices (CSR format) efficiently store the mostly-zero document-term matrices, reducing memory by 99%+ for realistic corpora by storing only non-zero values and their positions.
- scikit-learn's CountVectorizer handles tokenization, vocabulary building, and sparse matrix creation in one optimized package, with a clear fit/transform API that enforces vocabulary consistency between training and inference.
- Word order loss is the fundamental limitation: "The cat chased the dog" and "The dog chased the cat" produce identical vectors.
In the next chapters, we'll extend these ideas with n-grams to capture some word sequences, and with TF-IDF weighting to emphasize discriminative terms over common ones.
Quiz
Ready to test your understanding? Take this quick quiz to reinforce what you've learned about Bag of Words representations.
Bag of Words Quiz
Join the discussionAsk a question or share what you learned.
Keep exploring
Reference
Citation details
Cite or share this article.


Comments
Add to the conversation
A question, a correction, or an example from your own work.
No comments yet. Be the first to share your thoughts!