TF-IDF and Bag of Words: Text Representation & Search › Code Implementation

Michael BrenndoerferAugust 30, 20256 min read
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:

In[2]:
Code
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}")
Out[2]:
Console
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:

In[3]:
Code
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}")
Out[3]:
Console
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:

In[4]:
Code
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}")
Out[4]:
Console
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:

In[5]:
Code
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}")
Out[5]:
Console
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:

In[6]:
Code
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)"
    )
Out[6]:
Console
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)

Words that appear in all documents ("the", "sat") get IDF = 0, meaning they provide no discriminative power. Words unique to one document ("and", "log", "mat") get the highest IDF scores.

Step 5: Computing TF-IDF Vectors

Finally, we combine TF and IDF to create TF-IDF vectors:

In[7]:
Code
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}")
Out[7]:
Console
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:

In[8]:
Code
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}")
Out[8]:
Console
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:

In[9]:
Code
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]}")
Out[9]:
Console
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.

In[10]:
Code
## 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}")
Out[10]:
Console
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.