Natural Language Processing Basics

Bag-of-Words & TF-IDF Representations


You have 50,000 product reviews and a logistic regression model that refuses to accept anything but numbers. So you need to turn text into numbers. The first idea almost everyone has is the obvious one: give every word an integer.

Python
vocab = {"terrible": 1, "bad": 2, "okay": 3, "good": 4, "excellent": 5}review = "good"     # -> [4]review = "terrible" # -> [1]

Feed that to a linear model and watch what it assumes. The model computes w⋅xw \cdot x. If excellent is 5 and terrible is 1, the model believes excellent is five times terrible, and that okay (3) sits exactly halfway between them. Sometimes, by pure luck, that ordering is meaningful. Now try it with a real vocabulary:

Python
vocab = {"aardvark": 1, "abacus": 2, ..., "cat": 4821, "dog": 4822, ...}

The model now believes dog is one more than cat, and that cat is roughly 2,400 times abacus. These numbers were assigned alphabetically. They encode nothing except spelling, and the model will happily fit patterns to that noise.

This is the ordinal fallacy: using integers as identifiers while a model reads them as quantities. Word identity is categorical, not numeric, and the representation has to reflect that.

Counts across three reviews, and the word that says nothing311014002121001thesuperbexcellentawfulfoodreview 1review 2review 3"the" has the largest counts and appears in every document, so its inverse document frequency is zero.
TF asks how often a word appears here; IDF asks how rare it is everywhere — only the product is informative.

One-hot vectors, and why they lead straight to counting

The standard fix for categorical data is a one-hot vector: a vector as long as your vocabulary, with a 1 in the position for that word and 0 everywhere else.

Text
vocabulary = [amazing, bad, film, the, was]"film"     ->  [0, 0, 1, 0, 0]"bad"      ->  [0, 1, 0, 0, 0]

No word is now larger than any other. All of them are equidistant. But a document is many words, and a model needs one fixed-size vector per document, not a variable number of them. The simplest way to combine them is to add them up.

Text
"the film was bad"  the  -> [0, 0, 0, 1, 0]  film -> [0, 0, 1, 0, 0]  was  -> [0, 0, 0, 0, 1]  bad  -> [0, 1, 0, 0, 0]  sum  =  [0, 1, 1, 1, 1]

Sum the one-hot vectors of every word in a document and you get a vector of counts. That is the bag-of-words model, arrived at from first principles.

Bag-of-words, worked end to end

Take three tiny documents:

Text
D1: "the film was good"D2: "the film was bad bad"D3: "the acting was good"

Step one is to collect the vocabulary — every distinct word, in a fixed order:

Text
[acting, bad, film, good, the, was]

Step two is to count occurrences of each vocabulary word in each document:

actingbadfilmgoodthewas
D1001111
D2021011
D3100111

That is the document-term matrix. Three documents, six features, all numeric. Any model that takes a feature vector can now consume text.

The name is precise and worth taking literally. It really is a bag — you tipped all the words in and shook it. The order is gone.

Text
"dog bites man"  ->  {bites: 1, dog: 1, man: 1}"man bites dog"  ->  {bites: 1, dog: 1, man: 1}   identical

Bag-of-words discards word order completely. For topic classification that is nearly free — a document about football contains football words regardless of arrangement. For anything where order carries meaning, it is a hard ceiling you cannot train your way past.

The frequency problem

Look at the matrix above again and ask which column is useful. the is 1 in every document. was is 1 in every document. They cost you two of six features and separate nothing.

Scale that to real text and it gets worse. In a corpus of film reviews, the most frequent words are roughly:

WordTotal countDocuments containing itUseful for classification?
the336,00024,900 / 25,000No
movie44,00018,200 / 25,000Barely — it is a film corpus
good21,0009,800 / 25,000Yes
tedious310295 / 25,000Yes, strongly

Raw counts give the a value of 336,000 across the corpus and tedious a value of 310. Any model sensitive to feature magnitude — and most are — will be dominated by words that carry no information. You could delete stopwords by hand, but that only handles the extreme cases and requires you to know in advance which words are uninformative in your corpus. In a corpus of medical papers, patient is a stopword. In a general corpus it is not.

You want a principled, automatic way to say: frequent within this document is good evidence; frequent across all documents is not. That is exactly what TF-IDF computes.

TF-IDF from the two ideas it is made of

The score is a product of two terms.

Term frequency

How often the word appears in this document. The simplest form is the raw count, but dividing by document length stops long documents from dominating:

tf(t,d)=count of t in dtotal terms in d\text{tf}(t, d) = \frac{\text{count of } t \text{ in } d}{\text{total terms in } d}

A common refinement is sublinear scaling, 1+log⁡(count)1 + \log(\text{count}), on the reasoning that a word appearing 20 times is not 20 times more relevant than one appearing once.

Inverse document frequency

How rare the word is across the whole corpus. With NN documents and df(t)\text{df}(t) documents containing tt:

idf(t)=log⁡ ⁣(Ndf(t))\text{idf}(t) = \log\!\left(\frac{N}{\text{df}(t)}\right)

Ask why there is a logarithm. Without it, with N=25,000N = 25{,}000, a word in one document scores 25,000 and a word in 100 documents scores 250 — a hundredfold gap that overwhelms everything else. The log compresses that to 10.1 versus 5.5, a difference that is meaningful without being catastrophic. Rarity should be rewarded on a sliding scale, not explosively.

In practice both scikit-learn and most implementations add smoothing so that a term appearing in every document gets a small positive weight rather than exactly zero, and so an unseen term does not divide by zero:

idf(t)=log⁡ ⁣(1+N1+df(t))+1\text{idf}(t) = \log\!\left(\frac{1 + N}{1 + \text{df}(t)}\right) + 1

The product

tfidf(t,d)=tf(t,d)×idf(t)\text{tfidf}(t, d) = \text{tf}(t, d) \times \text{idf}(t)

Read the four cases and the design becomes obvious:

SituationtfidftfidfInterpretation
Common in this doc, common everywhere (the)high≈0lowNot distinctive
Common in this doc, rare elsewhere (tedious)highhighhighStrong signal for this doc
Rare in this doc, rare elsewherelowhighmediumWeak but noteworthy
Absent from this doc0—0No contribution

A calculation with real numbers

Use the three documents from earlier, N=3N = 3, with the smoothed formula.

Document frequencies: the 3, was 3, good 2, film 2, bad 1, acting 1.

Termdfidf = log((1+3)/(1+df)) + 1
the3log(4/4) + 1 = 0 + 1 = 1.000
was31.000
film2log(4/3) + 1 = 0.288 + 1 = 1.288
good21.288
bad1log(4/2) + 1 = 0.693 + 1 = 1.693
acting11.693

Now take D2, "the film was bad bad", using raw counts for tf:

Termcountidftf × idfAfter L2 normalisation
bad21.6933.3860.871
film11.2881.2880.331
the11.0001.0000.257
was11.0001.0000.257

The L2 norm is 3.3862+1.2882+12+12=11.47+1.66+1+1=3.889\sqrt{3.386^2 + 1.288^2 + 1^2 + 1^2} = \sqrt{11.47 + 1.66 + 1 + 1} = 3.889, and each value is divided by it so the document vector has length 1. That normalisation matters: without it, a 2,000-word review would have vastly larger values than a 50-word one purely because it is longer, and a distance-based model would cluster documents by length rather than by content.

Read the result. In a document of five words, bad now has a weight of 0.87 and the only 0.26. That is the ranking you wanted, produced automatically, with no hand-written stopword list.

TF-IDF is not a model and it learns nothing. It is a weighting scheme that encodes one assumption — a word is informative about a document in proportion to how concentrated it is in that document relative to the corpus. That assumption is simple, cheap, and remarkably hard to beat on topical tasks.

Doing it in scikit-learn

Python
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizerdocs = ["the film was good",        "the film was bad bad",        "the acting was good"]cv = CountVectorizer()X = cv.fit_transform(docs)print(cv.get_feature_names_out())# ['acting' 'bad' 'film' 'good' 'the' 'was']print(X.toarray())# [[0 0 1 1 1 1]#  [0 2 1 0 1 1]#  [1 0 0 1 1 1]]tv = TfidfVectorizer()T = tv.fit_transform(docs)print(T.toarray().round(3))# [[0.    0.    0.558 0.558 0.434 0.434]#  [0.    0.871 0.331 0.    0.257 0.257]#  [0.663 0.    0.    0.504 0.391 0.391]]

The middle row matches the hand calculation. The parameters that actually matter:

ParameterWhat it doesSensible starting value
min_dfIgnore terms in fewer than this many documents2 or 5 — kills typos and one-off tokens
max_dfIgnore terms in more than this fraction of documents0.9 — an automatic, corpus-specific stopword list
max_featuresKeep only the top-N by frequency20000–50000
ngram_rangeInclude multi-word features(1, 2)
sublinear_tfUse 1+log⁡(tf)1 + \log(\text{tf}) instead of raw tfTrue for long documents
normVector normalisation'l2' — leave it alone

Implementing it by hand

Worth doing once, because it removes all mystery about what the library is doing:

Python
import mathfrom collections import Counterdef tfidf(docs):    tokenised = [d.lower().split() for d in docs]    vocab = sorted({w for d in tokenised for w in d})    N = len(tokenised)    df = Counter()    for d in tokenised:        df.update(set(d))    idf = {t: math.log((1 + N) / (1 + df[t])) + 1 for t in vocab}    matrix = []    for d in tokenised:        counts = Counter(d)        row = [counts[t] * idf[t] for t in vocab]        norm = math.sqrt(sum(v * v for v in row)) or 1.0        matrix.append([v / norm for v in row])    return vocab, matrixvocab, M = tfidf(["the film was good",                  "the film was bad bad",                  "the acting was good"])print(vocab)print([round(v, 3) for v in M[1]])# [0.0, 0.871, 0.331, 0.0, 0.257, 0.257]

N-grams: buying back a little word order

Bag-of-words cannot tell not good from good. An n-gram treats each contiguous run of nn tokens as its own feature, which recovers local order.

Text
"the film was not good"unigrams: the, film, was, not, goodbigrams:  the film, film was, was not, not good

Now not good is a single feature, and a classifier can learn a negative weight for it while keeping a positive weight for good. This is the cheapest available fix for negation in a bag-of-words pipeline, and it works well.

The cost is vocabulary size. With a unigram vocabulary of VV words, the theoretical bigram space is V2V^2:

ngram_rangeFeatures (25k IMDb reviews, min_df=2)Typical accuracy gainFit time
(1, 1)~45,000baseline1×
(1, 2)~440,000+2 to +3 points~3×
(1, 3)~900,000+0 to +0.5 more~6×

Bigrams almost always pay for themselves. Trigrams almost never do — most trigrams appear once, get pruned by min_df, and the survivors add little. (1, 2) with min_df=2 is the default worth reaching for.

Sparsity, and why it is fine

A document-term matrix for 25,000 reviews with a 440,000-feature bigram vocabulary has 11 billion cells. Stored densely as 64-bit floats that would be 88 gigabytes, but an average review touches only about 300 of those features. Over 99.9% of the matrix is zero.

Scikit-learn returns a scipy.sparse matrix that stores only the non-zeros. Two consequences follow:

  • Never call .toarray() on a real corpus. It will try to materialise every zero and exhaust your memory.
  • Prefer models that accept sparse input directly — LogisticRegression, LinearSVC, MultinomialNB, SGDClassifier. Tree ensembles and neural networks generally want dense input, which is a large part of why linear models remain the standard partner for TF-IDF.

The mistakes that cost real accuracy

Fitting the vectoriser on all your data

This is the single most common bug, and it inflates your reported score without ever failing loudly.

Python
# WRONG - the vectoriser has seen the test setX = TfidfVectorizer().fit_transform(all_docs)X_train, X_test = train_test_split(X, ...)# RIGHT - fit on train only, transform test with what was learnedX_train_txt, X_test_txt, y_train, y_test = train_test_split(docs, labels)vec = TfidfVectorizer(min_df=2, ngram_range=(1, 2))X_train = vec.fit_transform(X_train_txt)X_test  = vec.transform(X_test_txt)

In the wrong version, the idf values are computed using document frequencies that include the test set. Your test documents influenced their own feature weights. The measured accuracy is optimistic and the model degrades when it meets genuinely new data. Use a Pipeline and cross-validation and this becomes impossible to get wrong:

Python
from sklearn.pipeline import make_pipelinefrom sklearn.linear_model import LogisticRegressionfrom sklearn.model_selection import cross_val_scorepipe = make_pipeline(    TfidfVectorizer(min_df=2, ngram_range=(1, 2), sublinear_tf=True),    LogisticRegression(max_iter=1000, C=1.0),)print(cross_val_score(pipe, docs, labels, cv=5, scoring="accuracy").mean())

Assuming unseen words do something

Any word not in the fitted vocabulary is silently dropped at transform time. If your training corpus is from 2018 and your production traffic is from today, a growing fraction of every incoming document contributes nothing at all. Monitor the proportion of out-of-vocabulary tokens in live traffic; a rising number is your early warning that the vectoriser needs refitting.

Reading feature weights as causes

A linear model over TF-IDF features is pleasantly inspectable:

Python
import numpy as npnames = vec.get_feature_names_out()coefs = clf.coef_[0]top = np.argsort(coefs)print("most negative:", names[top[:10]])print("most positive:", names[top[-10:]])

That is genuinely useful for debugging — if the or a stray HTML tag shows up in the top ten, your preprocessing is broken. But a high weight means the feature correlates with the label in your training data, not that it causes anything. If every positive review in your scrape came from one site that uses a particular template word, that word will top the list.

When this is still the right tool

It is tempting to treat TF-IDF as a historical curiosity now that pretrained transformers exist. That is a mistake, and the reason is economic rather than sentimental.

TF-IDF + linear modelFine-tuned transformer
Training time (25k docs)Seconds, on a laptopTens of minutes, on a GPU
Inference latencyUnder a millisecond, CPU10–100 ms, GPU preferred
Model sizeA few MB400 MB+
Labelled data neededWorks from a few thousandWorks from hundreds, better with more
Topical classification accuracyStrongSlightly stronger
Tasks needing word order or nuanceWeakMuch stronger
Explains its own decisionsDirectly, per featureOnly with extra tooling

Two practical habits follow. First, build the TF-IDF baseline before anything else, always. It takes ten minutes and it tells you what the task's floor looks like. A transformer that beats it by half a point is not worth the operational cost; a transformer that beats it by fifteen points has told you the task genuinely needs semantics and order.

Second, when your data has clear topical vocabulary — spam filtering, routing support tickets to departments, tagging news by section, deduplicating documents, retrieval over a fixed corpus — TF-IDF is frequently not just adequate but the correct final answer. It is fast, it runs anywhere, it has no dependency on a GPU, and when it makes a mistake you can point at the feature that caused it.