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📐 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 modülün olur.
text_vectorizer.pyTF-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ı:
- Sıklık (TF): O kelimenin doküman içinde kaç kez geçtiği.
- 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)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#
| Term | D1 | D2 | D3 |
|---|---|---|---|
| machine | 1 | 0 | 1 |
| learning | 1 | 1 | 0 |
| deep | 0 | 1 | 1 |
| model | 0 | 0 | 1 |
IDF#
| Term | df | IDF = log(3/df) + 1 |
|---|---|---|
| machine | 2 | log(3/2) + 1 = 1.405 |
| learning | 2 | 1.405 |
| deep | 2 | 1.405 |
| model | 1 | log(3/1) + 1 = 2.099 |
TF-IDF (TF × IDF)#
| Term | D1 | D2 | D3 |
|---|---|---|---|
| machine | 1.405 | 0 | 1.405 |
| learning | 1.405 | 1.405 | 0 |
| deep | 0 | 1.405 | 1.405 |
| model | 0 | 0 | 2.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-IDFimport numpy as npfrom collections import Counterfrom 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 etdocs = [ "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 similarityfrom numpy.linalg import normdef 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 TfidfVectorizerskl = 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:
- TF satürasyonu yok — bir kelime 100 kez geçse 1.000 kez geçenden 10x daha önemli sayılıyor (yanlış).
- 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:
- (genelde 1.2-2.0): TF satürasyonu — yüksek k_1 = daha az satürasyon
k_1 - (genelde 0.75): Doküman uzunluk normalizasyonu — 0 = yok, 1 = tam normalize
b
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 BM25import numpy as npfrom 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ırmadocs = [ "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#
- : hızlı, ana kelime önemli
n=1 - : standart, biraz daha akıllı
n=1,2 - : zengin, vocabulary büyür
n=1,2,3 - (char): kısa metinler için (e-ticaret ürün adı)
n=1-5
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
Module 0: Course Framework & Workshop Setup
Why Do Recommender Systems Matter? Birth, Present, and Future of a Discipline
Start LearningModule 0: Course Framework & Workshop Setup
Who Is a Recommender Engineer? Skill Atlas and Junior → Staff Career Map
Start LearningModule 0: Course Framework & Workshop Setup