Skip to content
learn
  1. Learn
  2. Machine Learning
  3. Sequences and Natural Language Processing

Machine Learning ML-10.02

Bag of words and TF-IDF

Represent documents as word counts, reweight them with TF-IDF so distinctive words stand out, and train fast linear text classifiers on top.

Beginner 10 min Last reviewed

The big idea

You have 50,000 support tickets and want to route each one to billing, shipping or tech support. The simplest useful representation of a ticket ignores grammar and word order completely and just records which words appear and how often. That is the bag of wordsA document representation that counts how often each vocabulary word appears, ignoring word order.: tip the words of a document into a bag, shake it, and count what is inside.

It sounds too crude to work, yet a ticket containing "refund", "charged" and "invoice" is about billing whatever the order of those words. The catch is that raw counts are dominated by words like "the" and "please", which appear everywhere and say nothing. TF-IDF fixes that by boosting words that are frequent in this document but rare in the collection, the way a good editor highlights the words that make a document different from all the others.

How it works

From text to a TF-IDF matrix A pipeline: documents are tokenized, counted into a document-term matrix with one row per document and one column per vocabulary term, multiplied by inverse document frequency and normalized, then fed to a linear model or used for similarity search. documents raw text tokenize ML-10.01 count matrix docs x terms x idf, normalize TF-IDF linear model or similarity search Figure 1. Bag of words turns each document into a row of counts; TF-IDF reweights the columns; a linear model or a cosine similarity search takes it from there.

Step 1: The document-term matrix

Build a vocabulary of every term in the training documents ( Text preprocessing and tokenization basics ). Each document becomes a row vector with one entry per term: the number of times that term occurs. Stack the rows and you have a document-term matrixA matrix with one row per document and one column per vocabulary term; each entry counts, or weights, that term in that document..

Take three documents: "the cat sat on the mat", "the dog sat on the log" and "the cat chased the dog". The vocabulary has 8 terms (cat, chased, dog, log, mat, on, sat, the), so each document is a vector of length 8. The first one is [1, 0, 0, 0, 1, 1, 1, 2]: "the" twice, five other words once, three words zero times.

Real vocabularies have tens or hundreds of thousands of terms and each document uses only a few hundred, so almost every entry is zero. Libraries store the matrix as a sparse matrixA matrix format that stores only the non-zero entries and their positions, saving memory when almost all entries are zero. that keeps only the non-zero entries.

Step 2: Term frequency and document frequency

The count of a term in one document is its term frequencyHow often a term occurs in one document; the TF in TF-IDF. (tf). On its own it rewards common words: "the" has the highest count in all three example documents. What we want to know is how much a word tells us about this document, compared with the others. The document frequencyThe number of documents in the collection that contain a term at least once. (df) of a term is the number of documents that contain it. "the" has df = 3 out of 3; "mat" has df = 1.

Step 3: Inverse document frequency

IDFInverse document frequency: a weight that is high for terms that occur in few documents and low for terms that occur in most of them, typically log(N / df). turns df into a weight that is large for rare terms and small for common ones. The classic form is

where N is the number of documents. With N = 3: "the" gets ln(3 / 3) = 0, so it vanishes; "cat", "sat" and "on" (df = 2) get ln 1.5 = 0.41; "mat" (df = 1) gets ln 3 = 1.10. In document 1, "mat" now has the highest weight: it is the word that sets this document apart.

Inverse document frequency against document frequency A curve of ln(1000 / df) for df from 1 to 1000. It starts at 6.91 for a term in one document, falls steeply to 2.30 at 100 documents, 0.69 at 500 and reaches 0 for a term in every document. 020040060080010000246document frequency df (out of N = 1,000)idf = ln(N / df) in 1 doc: 6.91 in 100 docs: 2.30 in half: 0.69 in every doc: 0 Figure 2. The log makes idf fall fast: a term in 1 of 1,000 documents gets 6.91, in 100 gets 2.30, and a term in every document gets 0.

Why a logarithm? Without it, a term in 1 document would weigh 1,000 times more than a term in all of them, and a single rare typo would swamp everything. The log compresses that range.

Step 4: Variants you will meet

TF-IDF is a family, not one formula. scikit-learn's TfidfVectorizer by default uses a smoothed idf, ln((1 + N) / (1 + df)) + 1, which acts as if one extra document contained every term, so no idf is ever 0 and terms in every document keep a weight of 1. It then scales each document vector to length 1 (L2 normalization), so long and short documents become comparable. Other common options are sublinear tf (1 + ln tf), so the tenth "refund" counts less than the first, and n-grams: with ngram_range=(1, 2) pairs of adjacent words such as "not good" become features too, which brings back a little word order.

Try it

This interactive needs JavaScript. The diagrams and text above cover the same idea.

Try this:

  1. Switch to raw counts: "the" is the biggest number in every row. Switch back to TF-IDF: its idf is 0 and the gold outline moves to "mat", "log" and "chased".
  2. Choose scikit-learn default. "the" is back with weight above 0 because the smoothed idf never reaches 0. In document 1 it even has the largest weight, because it occurs twice. What does that tell you about removing stop words or using sublinear tf?
  3. Edit document 3 to "the mat" and watch "mat" lose weight in document 1: its df went from 1 to 2.

Going deeper

Code: TF-IDF by hand and a tiny classifier

tfidf.pyruns with python3 + numpy + scikit-learn
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.linear_model import LogisticRegression

docs = ["the cat sat on the mat", "the dog sat on the log", "the cat chased the dog"]
cv = CountVectorizer(); X = cv.fit_transform(docs)          # sparse document-term matrix
print("terms:", list(cv.get_feature_names_out()))
print("counts:\n", X.toarray())

# TF-IDF by hand (scikit-learn's default) and with the library
n, df = len(docs), (X.toarray() > 0).sum(0)
idf = np.log((1 + n) / (1 + df)) + 1                        # smooth idf
W = X.toarray() * idf; W /= np.linalg.norm(W, axis=1, keepdims=True)
tv = TfidfVectorizer(); W_sk = tv.fit_transform(docs).toarray()
print("idf:", np.round(idf, 3), " matches library:", np.allclose(W, W_sk))
print("doc 1 weights:", {t: float(v) for t, v in zip(cv.get_feature_names_out(), np.round(W[0], 2))})

# A linear classifier on TF-IDF: 12 tiny reviews, 1 = positive
reviews = ["great food and friendly staff", "loved the pasta, great service", "friendly and fast, will return",
           "the best pizza in town", "great value, lovely staff", "fast service and tasty food",
           "cold food and rude staff", "slow service, never again", "the pizza was not good",
           "rude waiter and cold pasta", "not worth the price", "terrible, slow and expensive"]
y = np.array([1] * 6 + [0] * 6)
vec = TfidfVectorizer(); Xr = vec.fit_transform(reviews)
clf = LogisticRegression(C=10).fit(Xr, y)
w, names = clf.coef_[0], vec.get_feature_names_out()
print(f"\n{len(names)} features; with bigrams there would be",
      len(TfidfVectorizer(ngram_range=(1, 2)).fit(reviews).get_feature_names_out()))
print("most positive words:", list(names[np.argsort(w)[-4:][::-1]]))
print("most negative words:", list(names[np.argsort(w)[:4]]))
test = ["friendly staff and great pasta", "the food was not good", "not bad at all, great"]
for t, p in zip(test, clf.predict_proba(vec.transform(test))[:, 1]):
    print(f"  P(positive) {p:.2f}  {t}")

Output

terms: ['cat', 'chased', 'dog', 'log', 'mat', 'on', 'sat', 'the']
counts:
 [[1 0 0 0 1 1 1 2]
 [0 0 1 1 0 1 1 2]
 [1 1 1 0 0 0 0 2]]
idf: [1.288 1.693 1.288 1.693 1.693 1.288 1.288 1.   ]  matches library: True
doc 1 weights: {'cat': 0.37, 'chased': 0.0, 'dog': 0.0, 'log': 0.0, 'mat': 0.49, 'on': 0.37, 'sat': 0.37, 'the': 0.58}

32 features; with bigrams there would be 75
most positive words: ['great', 'fast', 'friendly', 'town']
most negative words: ['cold', 'rude', 'slow', 'not']
  P(positive) 0.84  friendly staff and great pasta
  P(positive) 0.19  the food was not good
  P(positive) 0.51  not bad at all, great

The hand computation matches scikit-learn's TfidfVectorizer exactly, including the smoothed idf of 1.0 for "the" and the unit-length rows. The second half trains logistic regression ( Logistic regression ) on TF-IDF features of 12 short restaurant reviews. Its largest weights are readable: "great", "fast" and "friendly" push towards positive, "cold", "rude", "slow" and "not" towards negative. It scores "the food was not good" at 0.19 because "not" is a strong negative word. But it gives "not bad at all, great" only 0.51: it sees "not" and "great" and cannot know that "not bad" is praise. That is the bag-of-words blind spot, and with 12 examples bigrams are too sparse to fix it.

Why linear models work so well here

A TF-IDF vector has thousands of dimensions, and in such high-dimensional sparse spaces classes are often close to linearly separable. Logistic regression or a linear SVM ( Support Vector Machines and kernels ) trains in seconds on hundreds of thousands of documents, needs no GPU, and every weight maps to a word you can read and audit. That makes TF-IDF plus a linear model the baseline to beat before reaching for neural networks ( Learning curves and baselines ). The same vectors power search: rank documents by the cosine similarity between their TF-IDF vector and the query's. BM25, the scoring function behind classic search engines, is a refined TF-IDF.

Limits

Bag of words ignores order ("dog bites man" equals "man bites dog") and treats every word as unrelated to every other: "great" and "excellent" are as different as "great" and "terrible". Word embeddings ( Word embeddings ) attack the second problem, and sequence models attack the first.

Trade-offs

OptionWins whenLoses when
Raw counts Simplest; fine for Naive Bayes Common words dominate; long documents look bigger
TF-IDF Distinctive words stand out; strong linear baselines and search Still ignores order and synonyms
Adding bigrams Captures short phrases like 'not good' Vocabulary grows fast; features get sparse
Neural embeddings Synonyms and context; best accuracy with enough data Slower, less interpretable, needs more compute

Common mistakes

  • Fitting the vectorizer on all data, test set included. The vocabulary and idf leak test information; fit on training data only ( Data leakage ).
  • Building the vocabulary again at prediction time, so column 57 means a different word in production than in training. Save and reuse the fitted vectorizer.
  • Converting the sparse matrix to a dense array on a large corpus and running out of memory.
  • Comparing documents of very different lengths without normalizing the vectors.
  • Expecting bag of words to understand negation or word order.

In the wild

TF-IDF and its relative BM25 have long powered keyword search engines, and they still sit inside modern retrieval systems as the "sparse" half of hybrid search next to dense embeddings ( Hybrid search and reranking ). Spam filters, support-ticket routers, document deduplication and keyword extraction tools often run on TF-IDF vectors with a linear model, because they are fast, cheap and easy to explain.

Check yourself

  1. A collection has 10,000 documents. 'invoice' appears in 100 of them. What is its idf with ln(N / df)?

    Show answer

    A. ln(100) = 4.61 N / df = 10,000 / 100 = 100, and ln(100) = 4.61. A term in fewer documents would get a higher idf.

  2. Why does TF-IDF give 'the' a weight near or exactly 0?

    Show answer

    B. It appears in almost every document, so its idf is near 0 IDF measures how rare a term is across documents. A term present in every document gets ln(N / N) = 0 in the classic form.

  3. Your TF-IDF plus logistic regression model rates 'not bad at all' as negative. What is the most direct cheap fix to try?

    Show answer

    B. Add bigram features with ngram_range=(1, 2) Bigrams make 'not bad' a feature of its own, so the model can learn it is positive. Removing stop words would delete 'not' and make it worse.

  4. You fit TfidfVectorizer on train and test together before splitting. What is wrong?

    Show answer

    B. The vocabulary and idf values use test documents, leaking information into training Document frequencies would count test documents. Fit the vectorizer on training data, then only transform validation and test data.

Recap

  • Bag of words: one row per document, one column per term, entries are counts; order is ignored.
  • TF-IDF multiplies term frequency by idf = ln(N / df), so words common everywhere fade and distinctive words stand out.
  • Libraries add smoothing, unit-length rows, sublinear tf and n-grams; know which variant you use.
  • TF-IDF plus a linear model is fast, interpretable and a strong baseline for text classification and search.
  • Its blind spots are word order and synonyms, which embeddings and sequence models address.

Get new lessons by email

Get new posts on backend and AI internals. No spam, unsubscribe anytime.

Your email is only used to send new posts. Privacy