Skip to content

Item Profiling: TF-IDF, BM25, n-grams, and Categorical Feature Encoding — Math + NumPy

Foundation of content-based recommenders: converting item to numerical vector. Full TF-IDF formula derivation + from-scratch NumPy implementation, BM25 vs TF-IDF difference, n-grams on movie titles, categorical encoding (one-hot, target, frequency).

Şükrü Yusuf KAYA
30 min read
Advanced
Item Profilleme: TF-IDF, BM25, n-gram ve Kategorik Feature Encoding — Matematik + NumPy
📐 Bu dersin amacı
Bir item'ın 'içerik vektörü' bir recommender'ın kalite tavanını belirler. Bu derste TF-IDF formülünü adım adım türetiyor, NumPy ile sıfırdan yazıyor, sklearn'ün versiyonuyla karşılaştırıyor, sonra BM25 (Okapi BM25) ve n-gram detaylarına iniyoruz. Sonunda elinde reusable bir
text_vectorizer.py
modülün olur.

TF-IDF — Bir Disiplinin Kemiği#

TF-IDF (Term Frequency × Inverse Document Frequency) 1972'de Karen Spärck Jones tarafından önerildi. Bilgi-getirme (information retrieval) literatürünün en sık atıfsanan formülüdür. Hala 50+ yıl sonra çoğu içerik-tabanlı sistem TF-IDF ile başlar.

Temel Fikir#

Bir kelimenin bir doküman için önemi iki şeye bağlı:
  1. Sıklık (TF): O kelimenin doküman içinde kaç kez geçtiği.
  2. Nadirlik (IDF): O kelimenin diğer dokümanlarda ne kadar nadir geçtiği.
Yüksek TF + yüksek IDF = bu kelime bu doküman için karakteristik.

Matematik — Adım Adım#

Term Frequency (TF)

tf(t, d)
= kelime t'nin doküman d'deki sıklığı. Üç varyant:

Inverse Document Frequency (IDF)

N = toplam doküman sayısı df(t) = t kelimesini içeren doküman sayısı
+1: tüm dokümanlarda geçen kelimelerin IDF'i sıfır olmasın diye (sklearn convention).

Birleşim

TF-IDF(t, d) = TF(t, d) · IDF(t)

Vektörleştirme + L2 Normalization

Doküman d için TF-IDF vektörü = her terim için TF-IDF skor. Sonra L2 normalize (cosine için).

Sayısal Örnek — 3 Doküman, 5 Kelime#

D1: "machine learning" D2: "deep learning" D3: "machine deep model"

TF Tablosu#

TermD1D2D3
machine101
learning110
deep011
model001

IDF#

TermdfIDF = log(3/df) + 1
machine2log(3/2) + 1 = 1.405
learning21.405
deep21.405
model1log(3/1) + 1 = 2.099

TF-IDF (TF × IDF)#

TermD1D2D3
machine1.40501.405
learning1.4051.4050
deep01.4051.405
model002.099

L2 Normalize#

D1 norm = √(1.405² + 1.405²) = 1.986 D1 normalized = [0.707, 0.707, 0, 0]
D3 norm = √(1.405² + 1.405² + 2.099²) = 2.910 D3 normalized = [0.483, 0, 0.483, 0.721]

Cosine Similarity#

D1 vs D3 = 0.707·0.483 + 0.707·0 + 0·0.483 + 0·0.721 = 0.341
"machine learning" ve "machine deep model" — orta benzerlik (sadece "machine" ortak).
python
# vectorizers/tfidf.py — Sıfırdan NumPy TF-IDF
import numpy as np
from collections import Counter
from typing import Iterable
 
class NumpyTfIdfVectorizer:
"""
Sıfırdan NumPy TF-IDF. Eğitsel amaçlı — production'da sklearn kullanın.
 
Args:
min_df: Bir term en az kaç dokümanda geçmeli (vocabulary filter)
max_df: Bir term en fazla yüzde kaç dokümanda geçmeli
smooth_idf: IDF'te +1 yapısı
sublinear_tf: log(1 + tf) kullan (smooth TF)
"""
 
def __init__(
self,
min_df: int = 1,
max_df: float = 1.0,
smooth_idf: bool = True,
sublinear_tf: bool = False,
):
self.min_df = min_df
self.max_df = max_df
self.smooth_idf = smooth_idf
self.sublinear_tf = sublinear_tf
self.vocabulary_ = {} # term -> index
self.idf_ = None # numpy array
 
def _tokenize(self, doc: str) -> list[str]:
"""Simple whitespace + lowercase tokenizer."""
return doc.lower().split()
 
def fit(self, documents: list[str]) -> "NumpyTfIdfVectorizer":
# 1) Vocabulary + document frequency
df_counter = Counter()
for doc in documents:
unique_terms = set(self._tokenize(doc))
df_counter.update(unique_terms)
 
N = len(documents)
 
# 2) Filter by min_df and max_df
filtered = {}
for term, df in df_counter.items():
if df < self.min_df:
continue
if df / N > self.max_df:
continue
filtered[term] = df
 
# 3) Build vocabulary (sorted for determinism)
terms_sorted = sorted(filtered.keys())
self.vocabulary_ = {t: i for i, t in enumerate(terms_sorted)}
 
# 4) Compute IDF
idf = np.zeros(len(terms_sorted), dtype=np.float64)
for term, idx in self.vocabulary_.items():
df = filtered[term]
if self.smooth_idf:
idf[idx] = np.log((N + 1) / (df + 1)) + 1
else:
idf[idx] = np.log(N / df) + 1
self.idf_ = idf
 
return self
 
def transform(self, documents: list[str]) -> np.ndarray:
"""
Returns dense (n_docs, vocab_size) TF-IDF matrix.
Production'da sparse matrix kullan!
"""
n_docs = len(documents)
vocab_size = len(self.vocabulary_)
X = np.zeros((n_docs, vocab_size), dtype=np.float64)
 
for doc_idx, doc in enumerate(documents):
tokens = self._tokenize(doc)
counts = Counter(tokens)
 
for term, count in counts.items():
if term not in self.vocabulary_:
continue
term_idx = self.vocabulary_[term]
 
# TF
if self.sublinear_tf:
tf = np.log(1 + count)
else:
tf = float(count)
 
# TF * IDF
X[doc_idx, term_idx] = tf * self.idf_[term_idx]
 
# L2 normalize
norm = np.linalg.norm(X[doc_idx])
if norm > 0:
X[doc_idx] /= norm
 
return X
 
def fit_transform(self, documents: list[str]) -> np.ndarray:
return self.fit(documents).transform(documents)
 
 
# Test — yukarıdaki örneği reproduce et
docs = [
"machine learning",
"deep learning",
"machine deep model",
]
vec = NumpyTfIdfVectorizer(smooth_idf=False)
X = vec.fit_transform(docs)
print(f"Vocabulary: {vec.vocabulary_}")
print(f"IDF: {vec.idf_}")
print(f"TF-IDF:\n{X}")
 
# Cosine similarity
from numpy.linalg import norm
def cosine(a, b):
return float(np.dot(a, b) / (norm(a) * norm(b)))
print(f"D1 vs D3: {cosine(X[0], X[2]):.3f}")
# D1 vs D3: 0.341 ← yukarıdaki manuel hesapla aynı
 
# sklearn karşılaştırması
from sklearn.feature_extraction.text import TfidfVectorizer
skl = TfidfVectorizer(token_pattern=r"\S+", smooth_idf=False)
X_skl = skl.fit_transform(docs).toarray()
print(f"sklearn TF-IDF:\n{X_skl}")
# Yaklaşık aynı (sklearn'ün default tokenizer'ı farklı olduğu için tam aynı değil)
 
Sıfırdan NumPy TF-IDF — sklearn ile karşılaştırma.

BM25 (Okapi BM25) — TF-IDF'in Evrildiği Hali#

TF-IDF'in iki sorunu var:
  1. TF satürasyonu yok — bir kelime 100 kez geçse 1.000 kez geçenden 10x daha önemli sayılıyor (yanlış).
  2. Document length kontrolü yok — uzun dokümanlar avantajlı (daha çok terim → daha çok skor).
BM25 bunları düzeltir. 1994'te Stephen Robertson tarafından önerildi, Lucene/Elasticsearch'in default scoring algorithm'ı.
Hyperparameter'lar:
  • k_1
    (genelde 1.2-2.0): TF satürasyonu — yüksek k_1 = daha az satürasyon
  • b
    (genelde 0.75): Doküman uzunluk normalizasyonu — 0 = yok, 1 = tam normalize
IDF formülü BM25'te biraz farklı:
IDF_BM25(t) = log((N - df + 0.5) / (df + 0.5))

TF-IDF vs BM25 Karşılaştırması#

TF=1: TF-IDF gives 1, BM25 gives ~0.92 TF=5: TF-IDF gives 5, BM25 gives ~1.96 (satürasyon) TF=20: TF-IDF gives 20, BM25 gives ~2.30 (neredeyse düz)
BM25 bir noktadan sonra doyuma ulaşır — daha gerçekçi.
python
# vectorizers/bm25.py — Sıfırdan BM25
import numpy as np
from collections import Counter
 
class NumpyBM25Vectorizer:
"""Okapi BM25 — Robertson 1994."""
 
def __init__(self, k1: float = 1.5, b: float = 0.75):
self.k1 = k1
self.b = b
self.vocabulary_ = {}
self.idf_ = None
self.avg_doc_len_ = None
self.doc_lens_ = None # her doc'un uzunluğu (fit sonrası set)
 
def _tokenize(self, doc: str) -> list[str]:
return doc.lower().split()
 
def fit(self, documents: list[str]):
df_counter = Counter()
doc_lens = []
for doc in documents:
tokens = self._tokenize(doc)
doc_lens.append(len(tokens))
df_counter.update(set(tokens))
 
N = len(documents)
terms_sorted = sorted(df_counter.keys())
self.vocabulary_ = {t: i for i, t in enumerate(terms_sorted)}
 
# BM25 IDF
idf = np.zeros(len(terms_sorted), dtype=np.float64)
for term, idx in self.vocabulary_.items():
df = df_counter[term]
idf[idx] = np.log((N - df + 0.5) / (df + 0.5))
self.idf_ = idf
 
self.doc_lens_ = np.array(doc_lens)
self.avg_doc_len_ = self.doc_lens_.mean()
 
return self
 
def transform(self, documents: list[str]) -> np.ndarray:
n_docs = len(documents)
vocab_size = len(self.vocabulary_)
X = np.zeros((n_docs, vocab_size), dtype=np.float64)
 
for doc_idx, doc in enumerate(documents):
tokens = self._tokenize(doc)
counts = Counter(tokens)
doc_len = len(tokens)
 
for term, count in counts.items():
if term not in self.vocabulary_:
continue
term_idx = self.vocabulary_[term]
idf = self.idf_[term_idx]
 
# BM25 score
numerator = count * (self.k1 + 1)
denominator = count + self.k1 * (
1 - self.b + self.b * doc_len / self.avg_doc_len_
)
X[doc_idx, term_idx] = idf * numerator / denominator
 
return X
 
def fit_transform(self, documents: list[str]):
return self.fit(documents).transform(documents)
 
 
# Karşılaştırma
docs = [
"machine learning algorithm",
"deep learning neural network",
"machine learning machine learning machine learning", # TF spam
]
 
tfidf = NumpyTfIdfVectorizer()
X_tfidf = tfidf.fit_transform(docs)
 
bm25 = NumpyBM25Vectorizer()
X_bm25 = bm25.fit_transform(docs)
 
# D2'nin (deep neural) D3 (machine spam)'ye benzerliği TF-IDF'te yüksek görünebilir
# çünkü D3 çok "machine learning" var
# BM25'te satürasyon var, dengeli sonuç verir
 
Okapi BM25 — TF-IDF'in saturated versiyonu.

N-Gram — Cümle Yapısını Yakalamak#

TF-IDF tek kelime (unigram) bazlı. Ama bazı bilgiler kelime sırasında gizli:
"machine learning" ≠ "learning machine" "hot dog" ≠ "dog hot" "the lord of the rings" ≠ "rings lord"
N-gram: Ardışık N kelime/karakter gruplarını feature olarak kullan.

Çeşitleri#

Word-level N-grams

"machine learning algorithm" 1-gram: ["machine", "learning", "algorithm"] 2-gram: ["machine learning", "learning algorithm"] 3-gram: ["machine learning algorithm"]

Character-level N-grams

"hello" 3-char-gram: ["hel", "ell", "llo"]
Character n-gram özellikle typo'lara dirençli ve morphologically rich dillerde (Türkçe!) çok kullanışlı.

Pratik N Seçimi#

  • n=1
    : hızlı, ana kelime önemli
  • n=1,2
    : standart, biraz daha akıllı
  • n=1,2,3
    : zengin, vocabulary büyür
  • n=1-5
    (char): kısa metinler için (e-ticaret ürün adı)

sklearn ile#

from sklearn.feature_extraction.text import TfidfVectorizer vec = TfidfVectorizer(ngram_range=(1, 2)) # unigram + bigram

Kategorik Feature Encoding#

Film genre, ürün kategori, kullanıcı meslek — kategorik feature'lar. Modele numerik vermek için encoding gerek.

1. One-Hot Encoding#

Her kategori için ayrı binary column.
Genre: [Action, Drama, Comedy] "Action film": [1, 0, 0] "Drama film": [0, 1, 0] "Comedy film": [0, 0, 1]
Avantaj: Net, anlaşılır, recommender'da iyi çalışır. Dezavantaj: Yüksek-kardinalite (1000 kategori → 1000 column). Sparse.

2. Multi-Hot Encoding#

Bir item birden çok kategoriye ait olabilir.
"Action and Drama film": [1, 1, 0]
MovieLens'te film genre'ları multi-hot — 19 binary column.

3. Target Encoding (Mean Encoding)#

Kategoriyi o kategorideki ortalama target değeri ile değiştir.
# Her brand için ortalama purchase rate brand_target = { "Apple": 0.45, "Samsung": 0.38, "Xiaomi": 0.28, }
Avantaj: Kardinalite problemi yok, signal-rich. Dezavantaj: Overfitting riski. K-fold target encoding gerekir.

4. Frequency Encoding#

Kategori → o kategorinin global sıklığı.
brand_freq = { "Apple": 0.15, # %15 of all sales "Samsung": 0.12, "Xiaomi": 0.08, }
Avantaj: Basit, "popularity proxy". Dezavantaj: Bilgi yoğunluğu düşük.

5. Embedding (Modern Yaklaşım)#

Yüksek-kardinalite kategoriler için: her kategori için dense embedding vector öğren.
User occupation embedding: 50d dense vector per occupation Item brand embedding: 32d dense vector per brand
Modern recommender'ların (Wide&Deep, DLRM) tercihi. Modül 9'da detaylı işliyoruz.
🎯 Production tavsiyesi
İlk sürümde one-hot + multi-hot + TF-IDF. Yeterince güçlü baseline. Performans yetersizse: yüksek-kardinaliteli feature'lara embedding ekle. Önce basit ile yüksek baseline kur, sonra optimize et. Sıfırdan deep model ile başlama tuzağına düşme.

Sıradakİ Ders#

Bir sonraki derste (4.3) — bu modülün omurgası: MovieLens-100K üstünde sıfırdan content-based recommender kuracağız. ~150 satır NumPy ile gerçek bir öneri sistemi. Sonra performansı sklearn implementasyonu ve baselines ile karşılaştırıp benchmark tablomuza ilk satırı ekleyeceğiz. 🚀

Frequently Asked Questions

Türkçe agglutinative (eklemeli) dil — 'gitmek', 'gitti', 'gidiyor', 'gideceğim' aynı kökten ama TF-IDF için 4 farklı kelime. Çözümler: (1) **Snowball Turkish stemmer** veya **Zemberek** ile köke indir. (2) **Character n-gram** (n=3-5) — root yakalama olmadan bile kelime varyasyonlarını birleştirir. (3) **Modern alternatif:** Türkçe BERT (BERTurk) embedding'leri — daha güçlü ama yavaş.

Yorumlar & Soru-Cevap

(0)
Yorum yazmak için giriş yap.
Yorumlar yükleniyor...

Related Content