Skip to content

Text Classification with TF-IDF

What you’ll learn

  • how a bag of words turns 6,000 short tickets into a 2,447 × 4,200 matrix that is 99.17% zeros
  • what idf actually computes, and why the loudest weights go to the least useful tokens
  • why sparse linear models win on text: 0.8733 for Naive Bayes against 0.8100 for boosting on 120 SVD components
  • that min_df=50min_df=50 deletes 92% of the vocabulary and improves accuracy to 0.8711
  • how a template artefact becomes a 5.46 coefficient, and costs 15 accuracy points when the template changes
  • when character n-grams beat word n-grams: 0.8611 against 0.8289 on typo’d text

The corpus

Real support tickets cannot be published, so this page uses a synthetic corpus generated from a known process: 6,000 tickets, three queues, about 22 words each. Filler words, a handful of topical words, and a long tail of case identifiers.

the_corpus.py
TOPICS = {
    "billing":   "invoice charged refund payment card ... late lost error".split(),
    "shipping":  "parcel delivery courier tracking ... late lost refund".split(),
    "technical": "login password reset error crash ... charge missing".split(),
}
the_corpus.py
TOPICS = {
    "billing":   "invoice charged refund payment card ... late lost error".split(),
    "shipping":  "parcel delivery courier tracking ... late lost refund".split(),
    "technical": "login password reset error crash ... charge missing".split(),
}

Three properties are deliberate, and each one shows up in a measurement later:

  • The topic vocabularies overlap. refundrefund belongs to billing and shipping; errorerror to billing and technical; latelate and lostlost to billing and shipping. This is the irreducible part.
  • 6% of the labels are wrong — tickets filed in the wrong queue, as happens.
  • 22% of the tokens are case identifiers drawn from a Zipf distribution over 4,000 possible values, which is what makes the matrix wide.

One caveat to state up front: the generator shuffles the words in each document, so word order carries no information by construction. That makes this corpus a clean test of bag-of-words methods and a useless test of anything that depends on sequence — a limitation the n-gram section returns to.

The matrix

figure6,000 tickets of about 22 words produce a matrix that is 99.17% zerosmatplotlib
Left: a 60 by 120 corner of the document-term matrix, almost entirely dark with scattered blue cells. Right: a table of numbers — 4,200 documents, 2,447 vocabulary, 2,281 identifiers, 166 real words, 10,277,400 cells, 85,388 non-zero, 99.17% sparsity, 1,052 tokens in exactly one document.Left: a 60 by 120 corner of the document-term matrix, almost entirely dark with scattered blue cells. Right: a table of numbers — 4,200 documents, 2,447 vocabulary, 2,281 identifiers, 166 real words, 10,277,400 cells, 85,388 non-zero, 99.17% sparsity, 1,052 tokens in exactly one document.
A document of 22 words can touch at most 22 of 2,447 columns, so the density is bounded by 0.9% before you even look at the data. This is why text features are stored sparse, why linear models are the default, and why 43% of the vocabulary — the tokens that appear in exactly one document — cannot possibly generalise.
QuantityValue
training documents4,200
vocabulary2,447
of which case identifiers2,281
actual words166
non-zero cells85,388 of 10,277,400
sparsity99.17%
tokens appearing in exactly one document1,052 (43.0%)

What idf does

Term frequency alone gives every word its raw count. Inverse document frequency down-weights words that appear everywhere. scikit-learn’s smoothed version is

idf(t)=log ⁣1+n1+df(t)+1,tfidf(t,d)=tf(t,d)idf(t)\mathrm{idf}(t) = \log\!\frac{1 + n}{1 + \mathrm{df}(t)} + 1, \qquad \mathrm{tfidf}(t, d) = \mathrm{tf}(t, d) \cdot \mathrm{idf}(t)

after which each row is L2-normalised, so document length stops mattering.

figure43% of the vocabulary appears in exactly one document, and idf hands those tokens the largest weightsmatplotlib
Left: log-log plot of document frequency against token rank, falling from 2,116 for case00000 through 1,088 for 'my' and 245 for 'refund' down to 1. Right: idf bars — case00000 1.6853, my 2.3501, the 2.9263, ref_bill 3.3100, charge 3.8256, refund 3.8377, error 3.8920.Left: log-log plot of document frequency against token rank, falling from 2,116 for case00000 through 1,088 for 'my' and 245 for 'refund' down to 1. Right: idf bars — case00000 1.6853, my 2.3501, the 2.9263, ref_bill 3.3100, charge 3.8256, refund 3.8377, error 3.8920.
Document frequency follows a power law, so idf spans a narrow range for the common half of the vocabulary and then rises steeply. Note what tops the idf ranking here: not the informative words but whichever ones happen to be rarest. idf is a heuristic about redundancy, not a measure of relevance.
TokenDocument frequencyidf
case00000case000002,1161.6853
mymy1,0882.3501
thethe6112.9263
ref_billref_bill4163.3100
chargecharge2483.8256
refundrefund2453.8377
errorerror2323.8920

Two things are worth noticing.

The most frequent token is an identifier. case00000case00000 is the head of the Zipf distribution over case numbers and appears in half the documents, so idf treats it as a stopword — correctly, by accident.

idf does not know what matters. errorerror gets the highest weight of the seven above, and it is one of the overlapping words that cannot separate billing from technical. idf measures how unusual a token is, not how diagnostic. Supervised weighting is what the classifier’s coefficients do.

See it move

The idf formula is four operations, and the intuition arrives faster by moving it than by reading it. Toggle which of the eight documents contain the word.

sketch What idf does to a word p5.js
Eight documents; clicking toggles whether each contains the word. The document frequency, the smoothed idf and the resulting tf-idf weight for a document containing the word twice all update from the formula.

Two boundary cases are worth clicking to: a word in all eight documents has idf exactly 1.0000, so tf-idf reduces to term frequency; a word in none of them has no column at all, which is what happens to every token that appears for the first time in production.

Models

figureNaive Bayes on tf-idf beats a 120-component boosting pipeline by 0.06matplotlib
Horizontal accuracy bars: SVD-120 plus boosting 0.8100, logistic on raw counts 0.8383, linear SVC 0.8472, logistic on tf-idf 0.8656, SVD-120 plus logistic 0.8700, complement NB 0.8711, multinomial NB 0.8733.Horizontal accuracy bars: SVD-120 plus boosting 0.8100, logistic on raw counts 0.8383, linear SVC 0.8472, logistic on tf-idf 0.8656, SVD-120 plus logistic 0.8700, complement NB 0.8711, multinomial NB 0.8733.
Every sparse linear method lands between 0.847 and 0.873. Reducing to 120 dense components and applying gradient boosting loses 0.06 — a large amount for this task — because the signal is spread thinly across many weakly informative columns, which is the situation trees handle worst and additive linear models handle best.
ModelTest accuracy
multinomial Naive Bayes0.8733
complement Naive Bayes0.8711
SVD-120 + logistic0.8700
logistic on tf-idf0.8656
linear SVC0.8472
logistic on raw counts0.8383
SVD-120 + gradient boosting0.8100

Four things this ordering tells you:

tf-idf is worth 0.0273 over raw counts for the same classifier — a real but modest gain, entirely from down-weighting the identifier tokens that dominate the counts.

Naive Bayes is not a toy. Its independence assumption is false, and it still wins here, in milliseconds, with no hyperparameters. On short documents with sparse features it is the correct baseline and frequently the answer.

Trees are the wrong shape for text. Each split tests one token; discriminating three classes on 1.98 topical words per document needs many weak pieces of evidence added together, which is what a linear model does by construction and what a tree does only with enormous depth.

Dimensionality reduction did not help. SVD-120 + logistic (0.8700) is statistically level with sparse logistic (0.8656) while discarding 2,327 columns and adding a fitted transform to maintain. Reach for it when you need dense vectors for something else, not for accuracy.

The confusion matrix shows where the remaining 13% goes:

true ↓ / predicted →billingshippingtechnical
billing5234840
shipping3652042
technical3244515

The errors are almost perfectly symmetric — 48 and 36 between billing and shipping, 40 and 32 between billing and technical. Symmetric confusion is the signature of genuinely ambiguous documents (a refundrefund for a damaged parceldamaged parcel belongs to both queues) rather than a model that has learned one class badly.

Most of the vocabulary is dead weight

figure2,447 features and 207 features score within 0.0056matplotlib
Accuracy against vocabulary size on a log axis, from 207 features at 0.8711 through 356 at 0.8694 and 1,395 at 0.8672 to 2,447 features at 0.8656; the curve is nearly flat and slightly higher at the small end.Accuracy against vocabulary size on a log axis, from 207 features at 0.8711 through 356 at 0.8694 and 1,395 at 0.8672 to 2,447 features at 0.8656; the curve is nearly flat and slightly higher at the small end.
Raising min_df from 1 to 50 deletes 92% of the columns and the accuracy goes up by 0.0055. Everything discarded was a case identifier appearing in a handful of documents — pure memorisation capacity, no signal. The pruned model is also 12 times smaller and correspondingly faster.
min_dfmin_dfVocabularyTest accuracy
12,4470.8656
21,3950.8672
55720.8667
103560.8694
502070.8711

min_dfmin_df is the cheapest regularisation available on text, and it is a statement about generalisation rather than about memory: a token that appears in three training documents cannot support a reliable coefficient, and keeping it invites the model to memorise those three.

The companion knob is max_dfmax_df, which drops tokens appearing in too many documents. On this corpus it does little, because idf already handles them — but on corpora with boilerplate (legal disclaimers, email footers) max_df=0.5max_df=0.5 removes a lot of noise in one line.

Two failures that clean cross-validation cannot show you

figureTwo failure modes that no cross-validation on clean, same-template data can show youmatplotlib
Left: accuracy on the 193 tickets carrying the ref_bill token, 0.9482 with the template present and 0.7927 with it removed. Right: word tf-idf 0.8656 on clean text and 0.8289 on typo'd text, against char 3-5 grams at 0.8700 and 0.8611.Left: accuracy on the 193 tickets carrying the ref_bill token, 0.9482 with the template present and 0.7927 with it removed. Right: word tf-idf 0.8656 on clean text and 0.8289 on typo'd text, against char 3-5 grams at 0.8700 and 0.8611.
Left: ref_bill is a template artefact carried by 30% of billing tickets. It earns the largest coefficient in the model, +5.459 against +3.173 for the best real word, and when a new ticket system stops emitting it, accuracy on exactly those tickets falls from 0.9482 to 0.7927. Right: deleting one letter from a quarter of the long words costs word-level tf-idf 0.0367 and character n-grams only 0.0089.

The template artefact

30% of billing tickets carry the token ref_billref_bill — a reference-number prefix from the ticketing template, with no meaning whatsoever. The model finds it immediately:

ClassLargest coefficients
billingref_billref_bill +5.459, chargedcharged +3.173, discountdiscount +3.117
shippingtrackingtracking +3.520, addressaddress +3.412, warehousewarehouse +3.412
technicalbugbug +3.215, installinstall +3.179, appapp +3.179

ref_billref_bill outweighs the best genuine billing word by 1.7×, and on the 193 test tickets that carry it the model is right 94.82% of the time against 85.56% elsewhere. Everything looks excellent.

Now migrate the ticketing system so the prefix disappears, and score the same model on the same tickets with that one token removed: 0.7927. A 15.6-point drop, concentrated entirely in the segment that used to be the model’s best.

This is not leakage in the train/test sense — the token is genuinely present at prediction time, and every split reproduces it. It is a dependency on an artefact, and the only defence is reading the coefficients and asking whether each top token is something a human would use to make the same decision. Which is exactly the audit from Phase 09.

Typos, and character n-grams

Word-level features are exact-match: deliverydelivery and delivrydelivry are unrelated columns. Delete one letter from 25% of the words longer than four characters — a mild simulation of real user text — and word tf-idf falls from 0.8656 to 0.8289. Character n-grams, which see delivdeliv in both spellings, fall only from 0.8700 to 0.8611.

char_ngrams.py
TfidfVectorizer(analyzer="char_wb", ngram_range=(3, 5), min_df=2)
# 7,637 features on this corpus; robust to typos, agglutination and casing
char_ngrams.py
TfidfVectorizer(analyzer="char_wb", ngram_range=(3, 5), min_df=2)
# 7,637 features on this corpus; robust to typos, agglutination and casing

The cost is a wider matrix (7,637 features against 2,447) and features nobody can read. The usual production answer is a FeatureUnionFeatureUnion of both: word n-grams for interpretability, character n-grams for robustness.

On n-grams

FeaturesVocabularyAccuracy
unigrams (min_df=2min_df=2)1,3950.8672
unigrams + bigrams17,6340.8667
unigrams + bigrams + trigrams18,5430.8683

Bigrams add 16,239 columns and 0.0000 of accuracy here — and that result is a property of this corpus, not of text in general. The generator shuffles words, so there are no phrases to find. On real text, bigrams typically buy one to three points on sentiment (“not good”) and almost nothing on topic classification. The lesson is procedural: measure the gain on your corpus before paying for a tenfold wider matrix.

The pipeline

diagram Diagram mermaid

The last box is the most common production bug on this page’s subject: a TfidfVectorizerTfidfVectorizer is a fitted object holding the vocabulary and the idf vector. Save the whole PipelinePipeline, never the classifier alone — the same failure, with the same silence, as dropping a scaler.

Pitfalls

PitfallWhy it bitesWhat to do
fit_transformfit_transform on the whole corpus before splittingThe idf vector and vocabulary see the test setfitfit on train only, inside a PipelinePipeline
Saving the classifier without the vectorizerWord IDs mean nothing without the fitted vocabularyPickle the whole pipeline
min_df=1min_df=1 by default43% of columns appear in one documentmin_df=2min_df=2 minimum; sweep it, 50 was best here
Trusting top coefficients as meaningref_billref_bill scored +5.459 and means nothingAudit them against domain expectation
Adding bigrams reflexively16,239 extra columns for 0.0000 hereMeasure on your corpus first
Exact-match features on user-written text0.8289 against 0.8611 under mild typosAdd character n-grams
Reaching for boosting on sparse text0.8100 against 0.8733 for Naive BayesLinear first, always
Reading accuracy without the confusion matrixSymmetric and asymmetric errors need different fixesPrint it every time

Recap

  • 6,000 tickets, 22 words each, produced a 2,447-column matrix that is 99.17% zeros, with 43.0% of tokens appearing in exactly one document.
  • idf is log((1+n)/(1+df))+1\log((1+n)/(1+\mathrm{df})) + 1: case00000case00000 got 1.6853 and errorerror 3.8920 — it measures rarity, not relevance.
  • Multinomial Naive Bayes 0.8733, logistic on tf-idf 0.8656, logistic on raw counts 0.8383, SVD-120 + boosting 0.8100.
  • min_df=50min_df=50 cut the vocabulary by 92% and raised accuracy to 0.8711.
  • A meaningless template token earned the model’s largest coefficient (+5.459) and cost 15.6 points on the affected tickets when it disappeared.
  • Under mild typos, word features lost 0.0367 and character 3–5 grams 0.0089.
  • Bigrams added 16,239 columns and nothing — on this corpus, whose words are shuffled by construction.
quizCheck yourself
  1. Your document-term matrix is 4,200 x 2,447 and 99.17% zeros. What does that imply about model choice?

    Show answer

    B — Prefer sparse linear models or Naive Bayes: the evidence is spread thinly across many weak columns, which linear models add up and trees cannot — Each tree split tests a single token, and with about two topical words per document no single token is decisive. Measured here: Naive Bayes 0.8733, sparse logistic 0.8656, boosting on 120 SVD components 0.8100.

  2. The token with the highest idf in your corpus is a case number. What does that tell you?

    Show answer

    B — Nothing about usefulness — idf measures rarity, so the rarest tokens get the loudest weights regardless of whether they discriminate anything — idf is an unsupervised heuristic about redundancy. On this corpus 'error' outranked every informative word while being one of the overlapping tokens that cannot separate billing from technical. Supervised weighting is the classifier's job.

  3. Raising min_df from 1 to 50 deletes 92% of your vocabulary and accuracy rises from 0.8656 to 0.8711. Why?

    Show answer

    B — The deleted tokens appeared in a handful of documents each — pure memorisation capacity with no generalisable signal, so removing them acts as regularisation — 43% of columns appeared in exactly one training document. A coefficient fitted on one occurrence cannot generalise; deleting those columns loses nothing and constrains the model. The pruned pipeline is also twelve times smaller.

  4. The largest coefficient in your billing class is a reference-number prefix from the ticket template. Is that a problem?

    Show answer

    B — Yes — it works only while the template exists; measured here, accuracy on the affected tickets fell from 0.9482 to 0.7927 when the token was removed — This is not train/test leakage: the token is genuinely present at prediction time. It is a dependency on an artefact, and it fails silently at the next system migration. Reading the top coefficients and asking whether a human would use them is the only defence.

  5. Your model scores 0.87 on clean text and 0.83 on real user submissions full of typos. What is the cheapest fix?

    Show answer

    B — Add character n-grams (analyzer='char_wb', ngram_range=(3, 5)) — they share substrings between 'delivery' and 'delivry', and lost only 0.0089 under the same corruption — Word features are exact-match, so a single deleted letter creates an unseen token and destroys the evidence. Character n-grams degrade gracefully. The usual production shape is a FeatureUnion of word and character features.

🧪 Try It Yourself

Exercise 1 – Generate the corpus

Exercise 2 – Vectorize, and look at the matrix

Exercise 3 – Fit three models and read the confusion matrix

Exercise 4 – Delete 92% of the vocabulary

Exercise 5 – Break the template

Next

Recommender Systems from Scratch — from a sparse document-term matrix to a sparser user-item matrix, where 99.17% zeros would count as dense and the missing entries are the thing you are trying to predict.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did