GenAI Course · Module 2 · Session 7

Count-Based Feature Representation

⏱ 13 min read🎯 Beginner friendly🖼 1 diagram🧪 1 live demo🐍 Python code

Machine learning models need numbers, not words. The first ideas for turning text into numbers simply count words. They are old, but still power search engines, spam filters and quick baselines today, and they explain why embeddings (Session 8) were such a big step.

In this session

  • One-hot encoding, and why every word looks unrelated
  • Bag of Words: counting words per document
  • N-grams: keeping a little word order
  • TF-IDF: giving rare, useful words more weight
  • When count-based features are still the right choice

01One-hot encoding and its limitations

Give every word in the vocabulary a position. A word becomes a vector of zeros with a single 1 at its position:

onehot.py
import numpy as np
vocab = ["upi", "payment", "failed", "refund", "card", "blocked"]
idx = {w: i for i, w in enumerate(vocab)}

def one_hot(word):
    v = np.zeros(len(vocab), dtype=int); v[idx[word]] = 1; return v

for w in ["payment", "refund"]:
    print(f"{w:8s}", one_hot(w))
print("similarity(payment, refund) =", one_hot("payment") @ one_hot("refund"))
payment [0 1 0 0 0 0] refund [0 0 0 1 0 0] similarity(payment, refund) = 0
upipaymentfailedrefundcardblockedupi100000payment010000failed001000refund000100
One-hot vectors: one column per vocabulary word. Every pair of different words has similarity 0.

02Bag of Words (BoW)

Bag of Words represents a whole document by counting how often each vocabulary word appears. Word order is thrown away, like emptying the words into a bag.

bow_tfidf.py
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer

docs = ["UPI payment failed",
        "UPI payment successful",
        "card payment failed again",
        "refund for failed UPI payment"]

bow = CountVectorizer()
X = bow.fit_transform(docs)
print("vocabulary:", list(bow.get_feature_names_out()))
print("BoW matrix:\n", X.toarray())

bi = CountVectorizer(ngram_range=(1, 2))
bi.fit(docs)
print("\nwith bigrams:", len(bi.get_feature_names_out()), "features, e.g.",
      [f for f in bi.get_feature_names_out() if " " in f][:5])

tfidf = TfidfVectorizer()
T = tfidf.fit_transform(docs)
names = tfidf.get_feature_names_out()
print("\nTF-IDF weights for doc 4:", docs[3])
row = T.toarray()[3]
for i in row.argsort()[::-1]:
    if row[i] > 0: print(f"   {names[i]:10s} {row[i]:.2f}")
vocabulary: ['again', 'card', 'failed', 'for', 'payment', 'refund', 'successful', 'upi'] BoW matrix: [[0 0 1 0 1 0 0 1] [0 0 0 0 1 0 1 1] [1 1 1 0 1 0 0 0] [0 0 1 1 1 1 0 1]] with bigrams: 16 features, e.g. ['card payment', 'failed again', 'failed upi', 'for failed', 'payment failed'] TF-IDF weights for doc 4: refund for failed UPI payment refund 0.57 for 0.57 failed 0.36 upi 0.36 payment 0.30

Each row of the BoW matrix is one document; each column is a word from the vocabulary. Row 1 (“UPI payment failed”) has 1s under failed, payment and upi.

🛍️

Like a shopping bill. The bill lists how many of each item you bought, not the order you picked them up. “Dog bites man” and “man bites dog” give exactly the same bag.

03N-grams

An n-gram is a run of n neighbouring words. Adding bigrams (n = 2) keeps a little order: “payment failed” and “failed again” become features of their own.

UPIpaymentfailed

Bigrams of “UPI payment failed”: UPI payment, payment failed.

FeaturesCapturesCost
Unigrams (single words)which words appearsmall
+ Bigramsshort phrases: “not good”, “payment failed”vocabulary grows a lot (8 → 16 features above)
+ Trigramslonger phrasesvery large and sparse
Bigrams fix the classic sentiment problem: “not good” becomes one feature instead of a positive “good”.

04TF-IDF

Counting treats every word equally, but a word that appears in every document (like “payment” here) tells us little. TF-IDF multiplies how often a word appears in this document (term frequency) by how rare it is across all documents (inverse document frequency):

TF-IDF(word, doc) = TF(word, doc) × log( N / DF(word) )
N = number of documents, DF = number of documents containing the word. scikit-learn adds smoothing and normalises each row.

In the output above, payment appears in all four documents, so it gets the lowest weight (0.30), while refund appears only once and gets the highest (0.57). Notice for also scores high only because it is rare in this tiny corpus: that is why stopwords are often removed before TF-IDF.

Demo · Which words matter in each document?

Real weights from the same TF-IDF model as the code above.

Use count features when…Use embeddings when…
you need a fast, explainable baselinemeaning matters more than exact words
exact keywords matter (IDs, product codes)users use different words for the same thing
data is small and CPU onlyyou have GPUs or an embedding API
keyword search (BM25 is a TF-IDF cousin, Session 35)semantic search and RAG (Sessions 31–35)

05Summary

06Quick quiz

1. With one-hot vectors, the similarity between “payment” and “refund” is…

2. What does Bag of Words lose?

3. Why add bigrams?

4. A word that appears in every document gets a TF-IDF weight that is…

5. Which is a modern search method closely related to TF-IDF?

07References

Created by Ajeetkumar