Bag-of-Words vs. TF-IDF Vectorization: A Comprehensive Guide with Python Code Examples
When working with textual data for machine learning tasks like document classification, sentiment analysis, or information retrieval, one of the first steps is to convert the raw text into a numerical representation that ML algorithms can understand. This process is known as text vectorization or feature extraction. Two popular techniques for vectorizing text are bag-of-words and TF-IDF. In this hands-on tutorial, we‘ll take an in-depth look at both methods, understand how they work under the hood, and implement them in Python using the scikit-learn library. By the end, you‘ll have a solid grasp of when and how to use these fundamental NLP techniques in your own projects.
Why Do We Need to Vectorize Text?
Machine learning models and algorithms require numerical feature vectors as input, not raw text. So before we can apply ML to textual data, we need a way to meaningfully convert words and documents into numbers. The goal is to represent each document or piece of text as a fixed-length vector that captures the essence of what it‘s about. Text vectorization aims to extract and encode the most salient information and features from the raw text.
There are various approaches to vectorizing text, but most of them follow the same general framework of:
- Tokenization – splitting text into individual words or tokens
- Vocabulary building – creating a numerical index mapping each unique word to an integer
- Encoding – converting each document into a vector using the integer word encodings
- Normalization – transforming the vector in some way, like scaling by document length
Two of the most widely used text vectorization techniques are bag-of-words and TF-IDF. Let‘s dive into each one.
The Bag-of-Words Model
The bag-of-words (BoW) model is a simple, intuitive way to represent text as numerical feature vectors. As the name suggests, it‘s like throwing all the words in a document into a bag, disregarding grammar and word order, and then counting how many times each word appears. Here‘s how it works:
- Split each document into individual words or tokens
- Build up a vocabulary of all unique words across the documents, and give each word a unique integer index
- For each document, count the number of occurrences of each word
- Encode each document as a vector of length V (the vocabulary size), where the i-th element is the count of the i-th word
Let‘s illustrate with an example. Suppose we have the following toy corpus of 3 documents:
- D1: "the cat sat on the mat"
- D2: "the dog lay on the rug"
- D3: "the cat lay on the rug and the dog sat on the mat"
After tokenization, the vocab (ignoring case and punctuation) is:
{‘the‘: 0, ‘cat‘: 1, ‘sat‘: 2, ‘on‘: 3, ‘mat‘: 4, ‘dog‘: 5, ‘lay‘: 6, ‘rug‘: 7, ‘and‘: 8}
Using this integer encoding of the vocab, the BoW vectors are:
- D1: [2, 1, 1, 1, 1, 0, 0, 0, 0]
- D2: [2, 0, 0, 1, 0, 1, 1, 1, 0]
- D3: [4, 1, 1, 2, 1, 1, 1, 1, 1]
Where the i-th element is the count of the i-th word in the vocabulary.
Implementing BoW in Python with scikit-learn
We can easily implement bag-of-words using sklearn‘s CountVectorizer:
from sklearn.feature_extraction.text import CountVectorizer
corpus = [
‘the cat sat on the mat‘,
‘the dog lay on the rug‘,
‘the cat lay on the rug and the dog sat on the mat‘
]
vectorizer = CountVectorizer()
X = vectorizer.fit_transform(corpus)
print(vectorizer.get_feature_names())
print(X.toarray())
Output:
[‘and‘, ‘cat‘, ‘dog‘, ‘lay‘, ‘mat‘, ‘on‘, ‘rug‘, ‘sat‘, ‘the‘]
[[0 1 0 0 1 1 0 1 2]
[0 0 1 1 0 1 1 0 2]
[1 1 1 1 1 2 1 1 4]]
The .fit_transform() method does the tokenization, builds the vocab, and encodes each document as a vector. .get_feature_names() returns the vocab mapping words to indices, and .toarray() converts the sparse vectors to a regular numpy array for easy inspection.
Pros and Cons of Bag-of-Words
Bag-of-words is computationally efficient and easy to understand. For many applications, it does a decent job of capturing the topics and content of documents in a simple way. And there‘s no need for tedious hand-crafting of features.
However, BoW vectors tend to be very high-dimensional (same size as the vocab) and sparse (mostly zeros). This can cause problems with storage, computational efficiency, and model performance.
Another major drawback is that BoW completely ignores word order, meaning and context. The two sentences "It was the best of times, it was the worst of times" and "It was the worst of times, it was the best of times" would have the exact same BoW representation, even though they mean opposite things! So while BoW is a good baseline, it often fails to capture more nuanced semantic information.
TF-IDF: A Smarter Way to Vectorize Text
TF-IDF stands for "Term Frequency – Inverse Document Frequency". It‘s a more advanced text vectorization technique that improves on the raw word counts of BoW. The key insight is that not all words are equally important or informative.
Some words like "the", "a", "and", etc. appear very frequently across all documents, and thus aren‘t very useful for distinguishing the content of different documents. Conversely, words that appear frequently in a particular document, but rarely in the rest of the corpus, are more likely to be relevant to the meaning and topic of that document.
TF-IDF captures this intuition by assigning words a weight that:
- Increases proportionally to the number of times the word appears in a document (Term Frequency)
- Decreases with the number of documents in the corpus that contain the word (Inverse Document Frequency)
Specifically, the TF-IDF weight of word i in document j is calculated as:
$w{i,j} = tf{i,j} * idf_i$
where the term frequency $tf_{i,j}$ is just the count of word i in doc j, and the inverse document frequency $idf_i$ is:
$idf_i = log(\frac{N}{df_i})$
where N is the total number of documents and $df_i$ is the number of documents that word i appears in. The log is used to dampen the effect of IDF.
So TF-IDF weights words highly if they appear many times in a given document, but not in many documents in the overall collection. This helps surface words that are particularly characteristic or representative of a document‘s content.
Let‘s walk through a concrete example to make things clearer. Consider the same example corpus as before:
- D1: "the cat sat on the mat"
- D2: "the dog lay on the rug"
- D3: "the cat lay on the rug and the dog sat on the mat"
For the word "cat", the term frequency is 1 in D1 and D3, and 0 in D2.
The document frequency of "cat" is 2, since it appears in 2 out of the 3 documents.
Thus the IDF of "cat" is $log(3/2) = 0.176$.
And the TF-IDF weights of "cat" are:
- D1: 1 * 0.176 = 0.176
- D2: 0 * 0.176 = 0
- D3: 1 * 0.176 = 0.176
Compare this to the word "the", which has TF of 2, 2, and 4, but a DF of 3, so its IDF is $log(3/3)=0$. The TF-IDF weight of "the" is 0 across all documents, which captures the intuition that it‘s not an informative word.
Implementing TF-IDF in Python
We can easily compute TF-IDF vectors in Python using sklearn‘s TfidfVectorizer:
from sklearn.feature_extraction.text import TfidfVectorizer
corpus = [
‘the cat sat on the mat‘,
‘the dog lay on the rug‘,
‘the cat lay on the rug and the dog sat on the mat‘
]
vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(corpus)
print(vectorizer.get_feature_names())
print(X.toarray())
Output:
[‘and‘, ‘cat‘, ‘dog‘, ‘lay‘, ‘mat‘, ‘on‘, ‘rug‘, ‘sat‘, ‘the‘]
[[0. 0.46979139 0. 0. 0.58028582 0.38408524 0. 0.38408524 0.38408524]
[0. 0. 0.52640543 0.52640543 0. 0.43106673 0.52640543 0. 0.28107324]
[0.25881905 0.23489569 0.26320271 0.26320271 0.29014291 0.4825212 0.26320271 0.19204262 0.60727998]]
The interface and usage is very similar to CountVectorizer. The .fit_transform() method learns the vocabulary and IDF weights from the corpus, then transforms each document into a TF-IDF weighted vector. The resulting vectors aren‘t as sparse, and capture the relative importance of words in each document.
Important Parameters to Know
Both CountVectorizer and TfidfVectorizer have a number of parameters that control the tokenization, vocabulary building, and encoding process. Some key ones to know:
max_features: build a vocabulary that only considers the topmax_featuresordered by term frequency across the corpusstop_words: ignore common "stop words" like "the", "and", "a", etc. You can pass a custom list or use the built-in"english"listngram_range: include word n-grams in the vocabulary. E.g.ngram_range=(1,2)means consider both unigrams and bigramsmin_dfandmax_df: ignore terms that appear in less thanmin_dfdocuments or more thanmax_dfproportion of documents. Useful for removing very rare or very common words.
Play around with these parameters to understand their effect. Always visualize your vectors by printing out the get_feature_names() and .toarray() to make sure they align with your intent.
Limitations of Bag-of-Words and TF-IDF
While TF-IDF improves on BoW by considering term specificity, both are still bag-of-words models that ignore word order and disregard grammatical structure.
As with BoW, the sentences "the cat chased the mouse" and "the mouse chased the cat" would have identical TF-IDF vectors, even though they describe very different events! For more complex NLP tasks that depend on capturing the meaning of text, we need more sophisticated techniques.
Additionally, both BoW and TF-IDF can generate very high-dimensional, sparse vectors for large corpora with big vocabularies. This can lead to computational and memory issues, and exacerbate problems like the curse of dimensionality in machine learning.
Beyond BoW and TF-IDF: Word Embeddings
In recent years, newer techniques like Word2Vec, GloVe, and BERT have become increasingly popular for representing text data. These methods learn low-dimensional, dense "word embeddings" that encode semantic similarity between words.
Words that are similar in meaning have vectors that are close together in the embedding space. And the relationship between words can be captured by vector operations (e.g. king – man + woman = queen).
This allows embedding-based models to generalize better and extrapolate to unseen data. They also tend to perform better on downstream tasks like text classification, since they capture more of the underlying meaning and relationships in language. If you‘re working on more complex NLP tasks, it‘s worth exploring these embedding-based approaches.
Tips and Best Practices
To wrap up, here are some tips and best practices to keep in mind when working with text vectorization:
-
Always preprocess your text data by lowercasing, removing punctuation/special characters, and standardizing spelling and abbreviations. Ideally, your documents should be as clean and consistent as possible before vectorizing.
-
Use CountVectorizer and TF-IDF as benchmark models, but explore more advanced techniques like word embeddings if you‘re working on complex language tasks
-
Visualize your vectors by printing out the vocabulary and using
.toarray()to convert to a dense representation. Make sure the vectorized data matches your expectations. -
Tune the vectorizer parameters like
max_features,min_df,max_df, etc. to control the vocabulary size and remove very rare or uninformative words. Strike a balance between capturing relevant info and keeping the dimensionality manageable. -
Consider word n-grams to capture common phrases and multi-word expressions. But be careful about combinatorial explosion.
-
For large datasets, use
HashingVectorizerinstead ofCountVectorizerto avoid storing the vocabulary and save memory. -
Remember that vectorization is often just the first step in an NLP pipeline. You‘ll likely need to normalize or further transform your vectors before feeding them into a machine learning model. StandardScaler, Normalizer, and TruncatedSVD are useful here.
I hope this deep dive into bag-of-words and TF-IDF gives you a solid foundation for working with text data. While these techniques have their limitations, they‘re an important part of any NLP toolkit.
The key is to understand how they work, when to use them, and combine them with other preprocessing techniques as part of a thoughtful approach to text vectorization. Let me know if you have any other questions!