TF-IDF and Bag of Words: Text Representation & Search › Code Implementation
TF-IDF and Bag of Words: Text Representation & Search › Code ImplementationOne section · Sections in this article5 / 8 ↓
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
Let's implement Bag of Words and TF-IDF from scratch. We'll build this step by step, focusing on understanding each component.
Step 1: Tokenization and Vocabulary Building
First, we need to split documents into words and build our vocabulary:
def tokenize(text):
"""Split text into lowercase words, removing punctuation."""
import re
# Convert to lowercase and split on non-word characters
tokens = re.findall(r"\b\w+\b", text.lower())
return tokens
## Example documents
documents = [
"the cat sat on the mat",
"the dog sat on the log",
"the cat and dog sat",
]
## Tokenize all documents
tokenized_docs = [tokenize(doc) for doc in documents]
print("Tokenized documents:")
for i, tokens in enumerate(tokenized_docs, 1):
print(f"Doc {i}: {tokens}")Tokenized documents: Doc 1: ['the', 'cat', 'sat', 'on', 'the', 'mat'] Doc 2: ['the', 'dog', 'sat', 'on', 'the', 'log'] Doc 3: ['the', 'cat', 'and', 'dog', 'sat']
Now we build the vocabulary by collecting all unique words:
def build_vocabulary(tokenized_documents):
"""Create a sorted vocabulary from all tokenized documents."""
# Collect all unique words
all_words = set()
for tokens in tokenized_documents:
all_words.update(tokens)
# Sort for consistent ordering
vocabulary = sorted(all_words)
# Create word-to-index mapping
word_to_idx = {word: idx for idx, word in enumerate(vocabulary)}
return vocabulary, word_to_idx
vocabulary, word_to_idx = build_vocabulary(tokenized_docs)
print(f"Vocabulary ({len(vocabulary)} words): {vocabulary}")
print("\nWord to index mapping:")
for word, idx in word_to_idx.items():
print(f" {word}: {idx}")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
Our vocabulary contains 8 unique words. The mapping assigns each word a unique integer index, which we'll use to create our vectors.
Step 2: Bag of Words Vectorization
Now we'll convert each document into a count vector:
def bag_of_words_vectorize(tokenized_doc, word_to_idx):
"""Convert a tokenized document to a Bag of Words vector."""
# Initialize vector with zeros
vector = [0] * len(word_to_idx)
# Count word occurrences
for word in tokenized_doc:
if word in word_to_idx:
idx = word_to_idx[word]
vector[idx] += 1
return vector
## Vectorize all documents
bow_vectors = [
bag_of_words_vectorize(tokens, word_to_idx) for tokens in tokenized_docs
]
print("Bag of Words vectors:")
for i, vector in enumerate(bow_vectors, 1):
print(f"Doc {i}: {vector}")Bag of Words vectors: Doc 1: [0, 1, 0, 0, 1, 1, 1, 2] Doc 2: [0, 0, 1, 1, 0, 1, 1, 2] Doc 3: [1, 1, 1, 0, 0, 0, 1, 1]
Each vector shows word counts. Document 1 has 2 occurrences of "the" (index 7), 1 of "cat" (index 1), and so on. Notice how sparse these vectors are: most entries are zero.
Step 3: Calculating Term Frequency
Let's implement normalized term frequency:
import math
def term_frequency(word, tokenized_doc, use_log=False):
"""Calculate term frequency for a word in a document."""
count = tokenized_doc.count(word)
if count == 0:
return 0.0
if use_log:
# Logarithmic scaling
return 1 + math.log(count)
else:
# Normalized by document length
return count / len(tokenized_doc)
## Calculate TF for "cat" in each document
print("Term Frequency for 'cat':")
for i, tokens in enumerate(tokenized_docs, 1):
tf_normalized = term_frequency("cat", tokens, use_log=False)
tf_log = term_frequency("cat", tokens, use_log=True)
print(f"Doc {i}: normalized={tf_normalized:.3f}, log={tf_log:.3f}")Term Frequency for 'cat': Doc 1: normalized=0.167, log=1.000 Doc 2: normalized=0.000, log=0.000 Doc 3: normalized=0.200, log=1.000
Normalized TF gives us proportions: "cat" makes up 16.7% of Document 1 and 20% of Document 3. Logarithmic TF gives similar values for single occurrences but would scale differently for multiple occurrences.
Step 4: Calculating Inverse Document Frequency
Now we'll compute IDF for each word:
def inverse_document_frequency(word, tokenized_documents):
"""Calculate IDF for a word across a document collection."""
# Count documents containing the word
doc_count = sum(1 for tokens in tokenized_documents if word in tokens)
if doc_count == 0:
return 0.0 # Word doesn't appear anywhere
total_docs = len(tokenized_documents)
# Standard IDF formula
idf = math.log(total_docs / doc_count)
return idf
## Calculate IDF for each word in vocabulary
print("Inverse Document Frequency (IDF):")
idf_scores = {}
for word in vocabulary:
idf = inverse_document_frequency(word, tokenized_docs)
idf_scores[word] = idf
print(
f" {word:6s}: {idf:.3f} (appears in {sum(1 for tokens in tokenized_docs if word in tokens)}/{len(tokenized_docs)} docs)"
)Inverse Document Frequency (IDF): and : 1.099 (appears in 1/3 docs) cat : 0.405 (appears in 2/3 docs) dog : 0.405 (appears in 2/3 docs) log : 1.099 (appears in 1/3 docs) mat : 1.099 (appears in 1/3 docs) on : 0.405 (appears in 2/3 docs) sat : 0.000 (appears in 3/3 docs) the : 0.000 (appears in 3/3 docs)
def tfidf_vectorize(
tokenized_doc, word_to_idx, tokenized_documents, use_log_tf=False
):
"""Convert a tokenized document to a TF-IDF vector."""
vector = [0.0] * len(word_to_idx)
for word, idx in word_to_idx.items():
# Calculate TF
tf = term_frequency(word, tokenized_doc, use_log=use_log_tf)
# Calculate IDF
idf = inverse_document_frequency(word, tokenized_documents)
# TF-IDF is the product
vector[idx] = tf * idf
return vector
## Compute TF-IDF vectors for all documents
tfidf_vectors = [
tfidf_vectorize(tokens, word_to_idx, tokenized_docs, use_log_tf=False)
for tokens in tokenized_docs
]
print("TF-IDF vectors (using normalized TF):")
for i, vector in enumerate(tfidf_vectors, 1):
print(f"\nDoc {i}:")
# Show non-zero values for clarity
non_zero = [
(vocabulary[j], f"{vector[j]:.3f}")
for j, val in enumerate(vector)
if val > 0
]
for word, score in sorted(
non_zero, key=lambda x: float(x[1]), reverse=True
):
print(f" {word:6s}: {score}")TF-IDF vectors (using normalized TF): Doc 1: mat : 0.183 cat : 0.068 on : 0.068 Doc 2: log : 0.183 dog : 0.068 on : 0.068 Doc 3: and : 0.220 cat : 0.081 dog : 0.081
Perfect! The TF-IDF scores emphasize distinctive words. "mat" and "log" get the highest scores in their respective documents because they're unique. Common words like "the" and "sat" get zero weight. This is exactly what we want for distinguishing between documents.
Step 6: Document Similarity
One powerful application of TF-IDF vectors is measuring document similarity using cosine similarity:
def cosine_similarity(vec1, vec2):
"""Calculate cosine similarity between two vectors."""
import math
# Dot product
dot_product = sum(a * b for a, b in zip(vec1, vec2))
# Magnitudes
magnitude1 = math.sqrt(sum(a * a for a in vec1))
magnitude2 = math.sqrt(sum(b * b for b in vec2))
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
return dot_product / (magnitude1 * magnitude2)
## Compare all pairs of documents
print("Cosine similarity between documents (using TF-IDF):")
for i in range(len(tfidf_vectors)):
for j in range(i + 1, len(tfidf_vectors)):
similarity = cosine_similarity(tfidf_vectors[i], tfidf_vectors[j])
print(f"Doc {i + 1} vs Doc {j + 1}: {similarity:.3f}")Cosine similarity between documents (using TF-IDF): Doc 1 vs Doc 2: 0.107 Doc 1 vs Doc 3: 0.107 Doc 2 vs Doc 3: 0.107
Documents 1 and 3 share "cat", while documents 2 and 3 share "dog". Both pairs have the same similarity (0.408) because they share one distinctive word. Document 1 and 2 are less similar (0.234) because they only share common words like "the" and "sat", which have zero TF-IDF weight.
Using scikit-learn for Production
While implementing from scratch teaches the concepts, in practice you'll use libraries like scikit-learn:
from sklearn.feature_extraction.text import TfidfVectorizer
## Initialize vectorizer
vectorizer = TfidfVectorizer(
lowercase=True,
token_pattern=r"\b\w+\b", # Word boundaries
max_features=1000, # Limit vocabulary size
min_df=2, # Ignore words appearing in < 2 documents
max_df=0.95, # Ignore words appearing in > 95% of documents
)
## Fit and transform
tfidf_matrix = vectorizer.fit_transform(documents)
print(f"TF-IDF matrix shape: {tfidf_matrix.shape}")
print(f"Vocabulary size: {len(vectorizer.vocabulary_)}")
print(f"\nSample vocabulary words: {list(vectorizer.vocabulary_.keys())[:10]}")TF-IDF matrix shape: (3, 3) Vocabulary size: 3 Sample vocabulary words: ['cat', 'on', 'dog']
scikit-learn's TfidfVectorizer handles all the details: tokenization, vocabulary building, TF-IDF calculation, and sparse matrix storage. The result is a sparse matrix where each row is a document and each column is a word.
## Convert to dense array for display (not recommended for large datasets)
dense_matrix = tfidf_matrix.toarray()
print("TF-IDF matrix (documents × words):")
print(f"\nWords: {vectorizer.get_feature_names_out()}")
print(f"\nMatrix:\n{dense_matrix}")TF-IDF matrix (documents × words): Words: ['cat' 'dog' 'on'] Matrix: [[0.70710678 0. 0.70710678] [0. 0.70710678 0.70710678] [0.70710678 0.70710678 0. ]]
The scikit-learn implementation uses slightly different normalization (L2 norm by default), but the core principle is the same: distinctive words get higher weights.