EthicsComputerCourses
Join training +91 90532 10052

Text Classification with TF-IDF

Machine Learning · Lesson 33 of 43 Advanced 8 min read Updated

A large share of business data is text: support tickets, product reviews, chat messages, complaint emails. Routing each support ticket to the right team (delivery, refunds, payments, accounts) by hand is slow and boring work, and a simple text classifier can do most of it in milliseconds.

You saw word counts briefly in Naive Bayes. This lesson builds a proper text classifier the way it is still done in many companies: TF-IDF features, word and character n-grams, a linear model, and a look inside to see which words drive each decision. It works with very little computing power, trains in seconds and is easy to explain, which makes it the right first step before reaching for large language models.

From text to numbers

Models need numbers, so text must be turned into a table. The bag of words approach makes one column per word in the vocabulary and counts how often each word appears in each document. Word order is lost, but the counts carry a lot of meaning.

TF-IDF (term frequency times inverse document frequency) improves on raw counts. A word that appears in almost every ticket, like "order", gets a low weight; a word that appears in few tickets, like "otp", gets a high weight because it says more about the ticket. scikit-learn's TfidfVectorizer also lowercases the text and normalises each row to length 1.

PythonRun it yourself
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer

tickets = ["my order is not delivered",
           "refund for my order not received",
           "otp not coming for login"]

counts = CountVectorizer().fit(tickets)
print("Vocabulary:", list(counts.get_feature_names_out()))
print("Counts for ticket 1:", counts.transform(tickets[:1]).toarray()[0])

tfidf = TfidfVectorizer().fit(tickets)
row = tfidf.transform(tickets[2:]).toarray()[0]
for word, weight in zip(tfidf.get_feature_names_out(), row):
    if weight > 0:
        print(f"{word:<8} {weight:.3f}")
Output
Vocabulary: ['coming', 'delivered', 'for', 'is', 'login', 'my', 'not', 'order', 'otp', 'received', 'refund']
Counts for ticket 1: [0 1 0 1 0 1 1 1 0 0 0]
coming   0.505
for      0.384
login    0.505
not      0.298
otp      0.505

In the third ticket, "coming", "login" and "otp" each get 0.505 because they appear in only one ticket, while "not", which appears in all three, gets only 0.298. TF-IDF automatically emphasises the words that make a ticket different from the others.

The result of transform is a sparse matrix: it stores only the non-zero values, because each ticket uses a tiny part of the vocabulary. Linear models such as LogisticRegression and LinearSVC work directly with sparse input and handle tens of thousands of columns easily.

A ticket router

The examples below use a generator of 1,200 short support tickets for an online shop, in four classes. Some tickets mention two problems, which makes them genuinely ambiguous, as real tickets are. The pipeline joins the vectorizer and the classifier, so fit learns the vocabulary from training text only. ngram_range=(1, 2) adds bigrams (pairs of neighbouring words), so "not delivered" and "not received" become features of their own.

PythonRun it yourself
import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline

PROBLEMS = {
    "delivery": ["order not delivered yet", "parcel is delayed again", "delivery boy did not come",
                 "tracking shows shipped but not received", "where is my package", "kab tak aayega order",
                 "wrong address on delivery", "courier marked delivered but i got nothing"],
    "refund": ["refund not received", "return picked up but money not credited", "paisa wapas nahi aaya",
               "want to return this product", "refund status still pending", "cancelled order but no refund",
               "refund amount is less", "exchange or refund for damaged item"],
    "payment": ["money debited but order failed", "upi payment failed", "charged twice for one order",
                "payment pending for hours", "card declined at checkout", "amount cut from bank but no order",
                "emi option not working", "cash on delivery not available"],
    "account": ["cannot login to my account", "otp not coming", "need to change mobile number",
                "password reset link not working", "account blocked suddenly", "update my email address",
                "delete my account", "profile name is wrong"],
}

def make_tickets(n=1200, seed=42):
    rng = np.random.default_rng(seed)
    openers = ["", "hi", "hello team", "sir", "please help", "urgent"]
    closers = ["", "order id 4521", "please check", "since 3 days", "very bad service", "pls resolve asap"]
    labels = rng.choice(list(PROBLEMS), n)
    rows = []
    for label in labels:
        text = f"{rng.choice(openers)} {rng.choice(PROBLEMS[label])}"
        if rng.random() < 0.15:                     # a second, unrelated problem
            other = rng.choice([k for k in PROBLEMS if k != label])
            text += f" and also {rng.choice(PROBLEMS[other])}"
        rows.append(f"{text} {rng.choice(closers)}".strip())
    return pd.DataFrame({"text": rows, "team": labels})

df = make_tickets()
print(df.sample(3, random_state=1).to_string(index=False))
X_train, X_test, y_train, y_test = train_test_split(
    df["text"], df["team"], test_size=0.25, random_state=42, stratify=df["team"])

router = make_pipeline(TfidfVectorizer(ngram_range=(1, 2)), LogisticRegression(C=10))
router.fit(X_train, y_train)
print(classification_report(y_test, router.predict(X_test), digits=3))

new = ["paisa abhi tak wapas nahi aaya", "otp nahi aa raha login ke liye"]
print(dict(zip(new, router.predict(new))))
Output
                                                                        text     team
                        please help order not delivered yet very bad service delivery
                                      hello team otp not coming since 3 days  account
please help paisa wapas nahi aaya and also kab tak aayega order since 3 days   refund
              precision    recall  f1-score   support

     account      1.000     1.000     1.000        76
    delivery      1.000     0.947     0.973        75
     payment      0.987     1.000     0.994        77
      refund      0.960     1.000     0.980        72

    accuracy                          0.987       300
   macro avg      0.987     0.987     0.986       300
weighted avg      0.987     0.987     0.987       300

{'paisa abhi tak wapas nahi aaya': 'refund', 'otp nahi aa raha login ke liye': 'account'}

The router gets 98.7% of the test tickets right. Generated tickets are cleaner than real ones, so expect lower scores on real data, but the method is exactly the same. It also routes the two new Hinglish tickets correctly, because words such as "paisa", "wapas" and "otp" appeared in training. The few mistakes are tickets with two problems, where even a person might hesitate.

What did the model learn?

A linear text model is easy to inspect. For each class, coef_ holds one weight per vocabulary entry; the largest weights are the words that most strongly push a ticket into that class. Checking them is the fastest way to catch problems, such as an order ID or an agent's name that leaked into the training text.

PythonRun it yourself
import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline

PROBLEMS = {
    "delivery": ["order not delivered yet", "parcel is delayed again", "delivery boy did not come",
                 "tracking shows shipped but not received", "where is my package", "kab tak aayega order",
                 "wrong address on delivery", "courier marked delivered but i got nothing"],
    "refund": ["refund not received", "return picked up but money not credited", "paisa wapas nahi aaya",
               "want to return this product", "refund status still pending", "cancelled order but no refund",
               "refund amount is less", "exchange or refund for damaged item"],
    "payment": ["money debited but order failed", "upi payment failed", "charged twice for one order",
                "payment pending for hours", "card declined at checkout", "amount cut from bank but no order",
                "emi option not working", "cash on delivery not available"],
    "account": ["cannot login to my account", "otp not coming", "need to change mobile number",
                "password reset link not working", "account blocked suddenly", "update my email address",
                "delete my account", "profile name is wrong"],
}

def make_tickets(n=1200, seed=42):
    rng = np.random.default_rng(seed)
    openers = ["", "hi", "hello team", "sir", "please help", "urgent"]
    closers = ["", "order id 4521", "please check", "since 3 days", "very bad service", "pls resolve asap"]
    labels = rng.choice(list(PROBLEMS), n)
    rows = []
    for label in labels:
        text = f"{rng.choice(openers)} {rng.choice(PROBLEMS[label])}"
        if rng.random() < 0.15:
            other = rng.choice([k for k in PROBLEMS if k != label])
            text += f" and also {rng.choice(PROBLEMS[other])}"
        rows.append(f"{text} {rng.choice(closers)}".strip())
    return pd.DataFrame({"text": rows, "team": labels})

df = make_tickets()
router = make_pipeline(TfidfVectorizer(ngram_range=(1, 2)), LogisticRegression(C=10)).fit(df["text"], df["team"])
vec, clf = router[0], router[-1]
words = vec.get_feature_names_out()
print("Vocabulary size:", len(words))
for team, weights in zip(clf.classes_, clf.coef_):
    top = np.argsort(weights)[::-1][:5]
    print(f"{team:<9}", ", ".join(words[top]))
Output
Vocabulary size: 569
account   account, account and, my, otp, not coming
delivery  delivered, address on, wrong address, again and, delivery
payment   failed and, failed, payment, order and, option not
refund    refund, refund not, return, refund and, product and

Most top words make sense: "refund", "return", "otp", "payment", "failed". But look at the bigrams ending in "and", such as "account and" and "failed and". They come from tickets with two problems, where the first problem decides the team. The model has learned how our labels were assigned, not just what the problem is. On real data, a discovery like this starts a useful conversation: should a two-problem ticket go to the first team, or be split in two?

Typos and Hinglish: character n-grams

Real customers type fast on phones: "refnd", "delivry", "pymnt", and they mix Hindi and English in Roman script. A word-level vocabulary treats "refnd" as an unknown word and ignores it. Character n-grams split text into short overlapping pieces such as "ref", "efn" and "fnd", so a misspelt word still shares most of its pieces with the correct one. Use analyzer="char_wb", which builds character n-grams only inside word boundaries.

PythonRun it yourself
import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline, make_union

PROBLEMS = {
    "delivery": ["order not delivered yet", "parcel is delayed again", "delivery boy did not come",
                 "tracking shows shipped but not received", "where is my package", "kab tak aayega order",
                 "wrong address on delivery", "courier marked delivered but i got nothing"],
    "refund": ["refund not received", "return picked up but money not credited", "paisa wapas nahi aaya",
               "want to return this product", "refund status still pending", "cancelled order but no refund",
               "refund amount is less", "exchange or refund for damaged item"],
    "payment": ["money debited but order failed", "upi payment failed", "charged twice for one order",
                "payment pending for hours", "card declined at checkout", "amount cut from bank but no order",
                "emi option not working", "cash on delivery not available"],
    "account": ["cannot login to my account", "otp not coming", "need to change mobile number",
                "password reset link not working", "account blocked suddenly", "update my email address",
                "delete my account", "profile name is wrong"],
}

def make_tickets(n=1200, seed=42):
    rng = np.random.default_rng(seed)
    openers = ["", "hi", "hello team", "sir", "please help", "urgent"]
    closers = ["", "order id 4521", "please check", "since 3 days", "very bad service", "pls resolve asap"]
    labels = rng.choice(list(PROBLEMS), n)
    rows = []
    for label in labels:
        text = f"{rng.choice(openers)} {rng.choice(PROBLEMS[label])}"
        if rng.random() < 0.15:
            other = rng.choice([k for k in PROBLEMS if k != label])
            text += f" and also {rng.choice(PROBLEMS[other])}"
        rows.append(f"{text} {rng.choice(closers)}".strip())
    return pd.DataFrame({"text": rows, "team": labels})

def add_typos(text, rng, rate=0.7):
    """Drop one inner letter from some longer words, like hurried phone typing."""
    words = []
    for w in text.split():
        if len(w) > 4 and rng.random() < rate:
            i = rng.integers(1, len(w) - 1)
            w = w[:i] + w[i + 1:]
        words.append(w)
    return " ".join(words)

df = make_tickets()
X_train, X_test, y_train, y_test = train_test_split(
    df["text"], df["team"], test_size=0.25, random_state=42, stratify=df["team"])
rng = np.random.default_rng(7)
X_test_typos = X_test.apply(lambda t: add_typos(t, rng))
print("Example with typos:", X_test_typos.iloc[0])

models = {
    "word 1-2 grams": TfidfVectorizer(ngram_range=(1, 2)),
    "char_wb 2-4 grams": TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4)),
    "word + char": make_union(TfidfVectorizer(ngram_range=(1, 2)),
                              TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4))),
}
for name, vectorizer in models.items():
    model = make_pipeline(vectorizer, LogisticRegression(C=10)).fit(X_train, y_train)
    print(f"{name:<18} clean test {model.score(X_test, y_test):.3f}  "
          f"test with typos {model.score(X_test_typos, y_test):.3f}")
Output
Example with typos: plese help payment pendig for hurs ordr id 4521
word 1-2 grams     clean test 0.987  test with typos 0.910
char_wb 2-4 grams  clean test 0.937  test with typos 0.927
word + char        clean test 0.983  test with typos 0.947

Heavy typos cut the word model's accuracy from 0.987 to 0.910. The character model barely notices them (0.937 to 0.927), but it is weaker on clean text. Combining both with make_union, which places the two feature sets side by side, keeps almost all of the clean accuracy and is the best of the three on typos (0.947). Word plus character features is a common choice for messy, informal text.

When to go beyond TF-IDF

TF-IDF with a linear model is a strong, cheap baseline, and many production systems still use it. Its main limit is that it only knows the words it has seen: "my parcel never showed up" shares no words with "order not delivered", so the model cannot tell they mean the same thing. Options that understand meaning, in rough order of cost:

  • Sentence embeddings plus logistic regression: a pretrained model turns each ticket into a vector of meaning (see Embeddings & Semantic Search); you train only the small classifier on top.
  • Fine-tuning a transformer model on your labelled tickets (see Deep Learning: CNNs, RNNs & Transformers).
  • Asking an LLM to classify each ticket with a prompt, useful when you have almost no labels, but slower and costlier per ticket.

Whichever you choose, keep the TF-IDF model as the baseline to beat.

Common mistakes

  • Fitting the vectorizer on all the text before splitting. The vocabulary and IDF weights then include test documents; keep the vectorizer inside the pipeline.
  • Passing a list of columns to a vectorizer in a ColumnTransformer. Text vectorizers expect a single column name as a string, such as "text", not ["text"].
  • Removing words like "not" as stop words. In tickets and reviews, "not received" and "received" mean opposite things.
  • Ignoring typos and mixed languages. Try analyzer="char_wb" when users type informally.
  • Never reading the top features. Leaked IDs and names show up there first.

Key points

  • Bag of words turns text into word counts; TF-IDF down-weights words that appear everywhere.
  • Vectorizers produce sparse matrices that linear models handle very efficiently.
  • ngram_range=(1, 2) adds word pairs such as "not delivered", which capture short phrases.
  • Character n-grams (analyzer="char_wb") make models robust to typos and informal spelling.
  • Inspect coef_ with get_feature_names_out() to see which words drive each class.
  • Keep TF-IDF plus a linear model as the baseline before trying embeddings, transformers or LLMs.

Exercise

Tune the ticket router with GridSearchCV (cv=5): try tfidfvectorizer__ngram_range in [(1, 1), (1, 2)], tfidfvectorizer__sublinear_tf in [False, True] and logisticregression__C in [1, 10, 100]. Use the same make_tickets() data and split, then print the best parameters, the best cross-validated accuracy and the test accuracy.

Show solution
Python
import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, train_test_split
from sklearn.pipeline import make_pipeline

PROBLEMS = {
    "delivery": ["order not delivered yet", "parcel is delayed again", "delivery boy did not come",
                 "tracking shows shipped but not received", "where is my package", "kab tak aayega order",
                 "wrong address on delivery", "courier marked delivered but i got nothing"],
    "refund": ["refund not received", "return picked up but money not credited", "paisa wapas nahi aaya",
               "want to return this product", "refund status still pending", "cancelled order but no refund",
               "refund amount is less", "exchange or refund for damaged item"],
    "payment": ["money debited but order failed", "upi payment failed", "charged twice for one order",
                "payment pending for hours", "card declined at checkout", "amount cut from bank but no order",
                "emi option not working", "cash on delivery not available"],
    "account": ["cannot login to my account", "otp not coming", "need to change mobile number",
                "password reset link not working", "account blocked suddenly", "update my email address",
                "delete my account", "profile name is wrong"],
}

def make_tickets(n=1200, seed=42):
    rng = np.random.default_rng(seed)
    openers = ["", "hi", "hello team", "sir", "please help", "urgent"]
    closers = ["", "order id 4521", "please check", "since 3 days", "very bad service", "pls resolve asap"]
    labels = rng.choice(list(PROBLEMS), n)
    rows = []
    for label in labels:
        text = f"{rng.choice(openers)} {rng.choice(PROBLEMS[label])}"
        if rng.random() < 0.15:
            other = rng.choice([k for k in PROBLEMS if k != label])
            text += f" and also {rng.choice(PROBLEMS[other])}"
        rows.append(f"{text} {rng.choice(closers)}".strip())
    return pd.DataFrame({"text": rows, "team": labels})

df = make_tickets()
X_train, X_test, y_train, y_test = train_test_split(
    df["text"], df["team"], test_size=0.25, random_state=42, stratify=df["team"])

pipe = make_pipeline(TfidfVectorizer(), LogisticRegression())
grid = {"tfidfvectorizer__ngram_range": [(1, 1), (1, 2)],
        "tfidfvectorizer__sublinear_tf": [False, True],
        "logisticregression__C": [1, 10, 100]}
search = GridSearchCV(pipe, grid, cv=5).fit(X_train, y_train)
print("Best parameters:", search.best_params_)
print(f"Best CV accuracy: {search.best_score_:.3f}")
print(f"Test accuracy:    {search.score(X_test, y_test):.3f}")
Output
Best parameters: {'logisticregression__C': 100, 'tfidfvectorizer__ngram_range': (1, 2), 'tfidfvectorizer__sublinear_tf': False}
Best CV accuracy: 0.992
Test accuracy:    0.983

The search picks word pairs and a large C (weak regularisation), which suits short, consistent tickets. The best cross-validated accuracy (0.992) and the test accuracy (0.983) are close, a sign that tuning did not overfit. sublinear_tf=True, which uses the logarithm of word counts, made no difference, because tickets rarely repeat a word.

Quick Quiz

1. Why does TF-IDF give the word "order" a low weight in a shop's support tickets?

2. What does ngram_range=(1, 2) add to the features?

3. Customers often type "refnd" and "delivry". Which vectorizer setting helps most?

4. Where should the TfidfVectorizer be fitted?

Learn Machine Learning with a mentor

Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call