Implicit Feedback'i Etikete Çevirmek: Click, Dwell ve Multi-Signal Aggregation
Bir e-ticaret site log'unun ham hali → modelin eğitilebileceği etiket veri seti. Hu/Koren confidence weighting'in matematiği ve NumPy implementasyonu, multi-signal weighted aggregation, session reconstruction, label leakage'ı önleme.
Şükrü Yusuf KAYA
28 dakikalık okuma
Orta🏷️ Bu dersin amacı
Bir e-ticaret veya media platformunun log'u ham haliyle bir dağ — JSON event'leri, milyonlarca satır. Modelin anlayabileceği etikete çevirmek bir sanat. Bu derste: confidence weighting matematiği (Hu-Koren-Volinsky 2008), multi-signal aggregation, session reconstruction ve label leakage'ı önlemenin pratik kodu.
Ham Log: RetailRocket Örneği#
RetailRocket dataset'i ile çalışacağız — bir gerçek e-ticaret sitesinin 4.5 aylık anonymize log'u.
events.csv Schema#
events.csvtimestamp,visitorid,event,itemid,transactionid 1433221332117,257597,view,355908, 1433224214164,992329,view,248676, 1433221999827,111016,view,318965, 1433221955914,483717,view,253185, 1433221337106,951259,view,367447, 1433222693635,972639,view,22556, 1433223236124,810725,view,443030, 1433223413545,794181,view,439202, 1433221967796,824915,view,428805, ...
Event tipleri ve dağılımı#
import polars as pl events = pl.read_csv("data/retailrocket/events.csv") print(events["event"].value_counts()) # shape: (3, 2) # ┌─────────────┬─────────┐ # │ event │ counts │ # ├─────────────┼─────────┤ # │ view │ 2664312 │ ← %97 # │ addtocart │ 69332 │ ← %2.5 # │ transaction │ 22457 │ ← %0.8 # └─────────────┴─────────┘
Bu üç event tipi — funnel sinyali. View → AddToCart → Transaction. Her aşamada conversion düşüyor.
Naive Yaklaşım — "Sadece View'leri Kullan"#
İlk denenecek yaklaşım: her view'i pozitif sinyal kabul et.
positives = events.filter(pl.col("event") == "view").select( "visitorid", "itemid", "timestamp" )
Bu yaklaşımın 3 problemi:
Problem 1: Tüm View'ler Eşit Değil#
Bir kullanıcı 1 sn'lik view ile 10 dakikalık derin browse'u aynı sayarsan, gürültülü etiket alırsın.
Problem 2: Aşırı Negative#
Tüm "no-view" pair'ları negatif sayarsak: 230K item × 1.4M user = 320 milyar pair. Bunların %99.998'i negatif. Class imbalance!
Problem 3: Funnel Bilgisini Kaybediyorsun#
Transaction = $$$. Ama naive yaklaşımda transaction da view de aynı sinyal. Halbuki transaction 50x daha güçlü pozitif.
Çözüm: Confidence weighting + multi-signal aggregation.
Hu-Koren-Volinsky 2008 — Confidence Weighting Matematiği#
2008'de Yahoo'dan Yifan Hu, Yehuda Koren ve Chris Volinsky "Collaborative Filtering for Implicit Feedback Datasets" paper'ını yayınladı. Bu paper bugün hala recommender literatürünün en çok atıfsından biri.
Anahtar Fikir#
Implicit feedback'i "rating yok" yerine "binary preference + confidence" olarak modelle:
p_ui = 1 if etkileşim var, else 0 c_ui = 1 + α · r_ui (confidence — etkileşim sayısı kadar artar)
Burada etkileşim sayısı (örn. kaç kez view), hyperparameter (paperda α=40).
r_uiαLoss Fonksiyonu#
Standard MF loss:
L = Σ_(u,i) (r_ui - p_u · q_i)²
Hu-Koren-Volinsky loss:
Bu Loss'un Sihri#
- Pozitif örnekler (p_ui=1): — etkileşim sayısı yüksekse loss ağır cezalandırılır.
c_ui = 1 + α · r_ui - Negatif örnekler (p_ui=0): — sabit (düşük) confidence. Tüm "no-interaction" pair'lar negatif sayılır AMA düşük ağırlıkla.
c_ui = 1 - Sonuç: Algoritma "hangi item'ı user etkileşti?" değil, "hangi item kombinasyonu hangi confidence ile pozitif?" sorusuna odaklanır.
Niçin Devrim?#
Önceki yaklaşımlarda "missing data = unknown" sayılırdı (Funk SVD'de olduğu gibi). Hu-Koren-Volinsky "missing = negative with low confidence" diyerek tüm matrix'i kullanma yolunu açtı. Bu sayede:
- Confidence düşük negatifler regularize edici rol oynar.
- Pozitif örneklerin nispi ağırlığı korunur.
- Closed-form ALS solver mümkün.
python
# Hu-Koren-Volinsky 2008 implicit ALS — manuel implementasyonimport numpy as npfrom scipy.sparse import csr_matrix def implicit_als( R: csr_matrix, factors: int = 64, alpha: float = 40.0, reg: float = 0.1, iterations: int = 15, random_state: int = 42,) -> tuple[np.ndarray, np.ndarray]: """ Hu-Koren-Volinsky 2008 — Implicit ALS sıfırdan. R: sparse interaction matrix (entries = etkileşim sayısı) Returns: P: (n_users, factors) — user embedding Q: (n_items, factors) — item embedding """ np.random.seed(random_state) n_users, n_items = R.shape # Confidence matrix: c_ui = 1 + alpha * r_ui # Preference: p_ui = 1 if r > 0 else 0 C = R.copy() * alpha C.data += 1 # 1 + alpha * r # NOT: C sadece pozitif girişleri tutuyor. Negative pair'lar implicit olarak c=1. # Random init P = np.random.normal(0, 0.01, (n_users, factors)).astype(np.float32) Q = np.random.normal(0, 0.01, (n_items, factors)).astype(np.float32) I_k = reg * np.eye(factors, dtype=np.float32) for it in range(iterations): # P'yi güncelle (Q fixed) QtQ = Q.T @ Q # (k, k) — precompute for u in range(n_users): # User u'nun positive items'ı start, end = R.indptr[u], R.indptr[u + 1] pos_items = R.indices[start:end] confidences = 1 + alpha * R.data[start:end] # (Cu - I) Q'Q + Q'Q + reg I = Q'Q + Q'(Cu - I)Q + reg I # Q'(Cu - I)Q = Σ (c_ui - 1) q_i q_i^T (only over positive items) Cu_minus_I_QtQ = ((confidences - 1)[:, None] * Q[pos_items]).T @ Q[pos_items] A = QtQ + Cu_minus_I_QtQ + I_k # Q'Cu p_u = Σ c_ui · 1 · q_i (only over positive items) b = (confidences[:, None] * Q[pos_items]).sum(axis=0) P[u] = np.linalg.solve(A, b) # Q'yu güncelle (P fixed) — simetrik PtP = P.T @ P Rt = R.T.tocsr() # transpoze, item-major for i in range(n_items): start, end = Rt.indptr[i], Rt.indptr[i + 1] pos_users = Rt.indices[start:end] confidences = 1 + alpha * Rt.data[start:end] Cu_minus_I_PtP = ((confidences - 1)[:, None] * P[pos_users]).T @ P[pos_users] A = PtP + Cu_minus_I_PtP + I_k b = (confidences[:, None] * P[pos_users]).sum(axis=0) Q[i] = np.linalg.solve(A, b) if it % 3 == 0: print(f"Iter {it+1}/{iterations}") return P, Q # Smoke test (50 user x 100 item dummy)from scipy.sparse import random as sparse_randomR_test = sparse_random(50, 100, density=0.1, format="csr", dtype=np.float32) * 10R_test.data = np.round(R_test.data).astype(np.float32)P, Q = implicit_als(R_test, factors=16, iterations=5)print(f"P shape: {P.shape}, Q shape: {Q.shape}")print(f"User 0 top item: {(P[0] @ Q.T).argmax()}") Hu-Koren-Volinsky 2008 paper'ının NumPy implementasyonu — Modül 6'da derinleştireceğiz, burası önizleme.
📚 Kütüphane karşılaştırması
Yukarıdaki manuel kod öğretici. Production'da kütüphanesini kullanın: . Cython/CUDA implementasyonu — manuel kod'tan 50-200x daha hızlı (M2 Mac'te). Pedagojik olarak manuel kod yaz, üretimde kütüphane.
implicitfrom implicit.als import AlternatingLeastSquares; model = AlternatingLeastSquares(factors=64, regularization=0.1, alpha=40); model.fit(R)Multi-Signal Aggregation — Birden Çok Event Tipini Birleştirme#
RetailRocket'te 3 event tipi var (view, addtocart, transaction). Bunları tek bir confidence skoruna çevirmenin matematiği:
Yaklaşım 1: Sabit Ağırlık#
WEIGHTS = {"view": 1.0, "addtocart": 5.0, "transaction": 20.0}
Mantığı: transaction = 20 view kadar güçlü sinyal.
Yaklaşım 2: Log-Linear (TikTok yaklaşımı)#
# log(1 + count) ile diminishing returns score = sum(WEIGHTS[e] * np.log1p(count_e) for e, count_e in events_per_type.items())
Mantığı: 100 view 10 view'in 10 katı değil — log scale.
Yaklaşım 3: Funnel-Aware#
# Aynı user-item için: max funnel stage al funnel_stage = max("view", "addtocart", "transaction" sırasıyla) score = STAGE_WEIGHT[funnel_stage]
Mantığı: aynı user 50 kez view + 1 kez transaction = transaction stage. 50 view ekstra bilgi vermez.
Üç Yaklaşımın Karşılaştırması#
Aynı RetailRocket dataset'inde implicit ALS eğitip Recall@20 ölçtüğümde:
| Etiketleme | Recall@20 | Notu |
|---|---|---|
| Sadece view (binary) | 0.082 | Naive baseline |
| Sabit ağırlık | 0.094 | +%14 — basit ama etkili |
| Log-linear | 0.097 | +%18 |
| Funnel-aware | 0.103 | +%26 — açık ara |
Sonuç: Etiketleme stratejisi seçimi modelinden çok daha fazla fark yaratabilir.
python
# data/multi_signal.py — Multi-signal aggregationimport polars as pl # Funnel-aware ağırlıklarSIGNAL_WEIGHTS = { "view": 1.0, "addtocart": 5.0, "transaction": 20.0,}STAGE_ORDER = ["view", "addtocart", "transaction"]STAGE_RANK = {s: i for i, s in enumerate(STAGE_ORDER)} def aggregate_funnel_aware(events: pl.DataFrame) -> pl.DataFrame: """ Funnel-aware aggregation: aynı user-item için max funnel stage. Returns DataFrame with [user_id, item_id, signal_value] """ # Her event'e numeric rank ekle events_ranked = events.with_columns( pl.col("event").replace(STAGE_RANK).cast(pl.Int8).alias("stage_rank") ) # User-item bazında max rank al agg = ( events_ranked.group_by(["visitorid", "itemid"]) .agg([ pl.col("stage_rank").max().alias("max_stage"), pl.col("timestamp").max().alias("last_timestamp"), ]) ) # Rank'i ağırlığa çevir weight_map = {STAGE_RANK[s]: w for s, w in SIGNAL_WEIGHTS.items()} agg = agg.with_columns( pl.col("max_stage").replace(weight_map).cast(pl.Float32).alias("signal_value") ) return agg.select(["visitorid", "itemid", "signal_value", "last_timestamp"]) def aggregate_log_linear(events: pl.DataFrame) -> pl.DataFrame: """ Log-linear aggregation: sum(weight * log1p(count)) per event type. """ import numpy as np counts = ( events.group_by(["visitorid", "itemid", "event"]) .agg(pl.count().alias("n")) ) counts = counts.with_columns( (pl.col("event").replace(SIGNAL_WEIGHTS).cast(pl.Float32) * (pl.col("n").cast(pl.Float32) + 1).log()).alias("contribution") ) return ( counts.group_by(["visitorid", "itemid"]) .agg(pl.col("contribution").sum().alias("signal_value")) ) # Testimport polars as plevents = pl.read_csv("data/retailrocket/events.csv")print(f"Raw events: {events.height:,}") agg = aggregate_funnel_aware(events)print(f"Aggregated (user, item) pairs: {agg.height:,}")print(agg.head(5))print(f"Signal value distribution:")print(agg["signal_value"].value_counts()) Multi-signal aggregation — production-ready, Polars vectorized.
Session Reconstruction — "Davranış Zincirini Kur"#
Sequential recommender (Modül 10) için session bilgisi şarttır. Ama RetailRocket gibi flat log'larda session yok — sadece timestamp var. Bunu nasıl kuruyoruz?
Klasik Tanım#
Bir session — aynı kullanıcının ardışık aktivitelerinden, 30 dakikadan fazla inaktiflik olmayan dizidir.
Google Analytics, Adobe Analytics, hatta endüstri standardı 30 dakika kuralını kullanır. (Bu sayı internetin erken günlerinden — değişiklik nadirdir.)
python
# data/sessions.py — Session reconstructionimport polars as pl SESSION_GAP_MS = 30 * 60 * 1000 # 30 dakika def reconstruct_sessions(events: pl.DataFrame) -> pl.DataFrame: """ Flat log → session-tagged events. Yeni session başlangıcı: user'ın aynı user için ardışık iki event arasında 30 dakikadan fazla gap varsa. Returns DataFrame with [user, item, timestamp, session_id, position] """ return ( events.sort(["visitorid", "timestamp"]) .with_columns([ # Aynı user içinde önceki timestamp'i al pl.col("timestamp").shift(1).over("visitorid").alias("prev_ts"), ]) .with_columns([ # Yeni session başlıyor mu? ( (pl.col("prev_ts").is_null()) | ((pl.col("timestamp") - pl.col("prev_ts")) > SESSION_GAP_MS) ).alias("session_start"), ]) .with_columns([ # Session ID = cumulative sum of session_start, per user pl.col("session_start").cast(pl.Int32).cum_sum().over("visitorid").alias("session_id"), ]) .drop("prev_ts", "session_start") .with_columns([ # Session içinde pozisyon pl.int_range(0, pl.count()).over(["visitorid", "session_id"]).alias("position"), ]) ) # Testimport polars as plevents = pl.read_csv("data/retailrocket/events.csv")sessions = reconstruct_sessions(events)print(sessions.head(10)) # Session istatistikleristats = ( sessions.group_by(["visitorid", "session_id"]) .agg(pl.count().alias("session_len")))print(f"Total sessions: {stats.height:,}")print(f"Avg session length: {stats['session_len'].mean():.1f}")print(f"P50 / P90 / P99: " f"{int(stats['session_len'].quantile(0.5))} / " f"{int(stats['session_len'].quantile(0.9))} / " f"{int(stats['session_len'].quantile(0.99))}") Session reconstruction — 30 dakika gap kuralı, Polars vectorized window operations.
Label Leakage — Sessiz Katil#
Recommender mühendisliğinde en sık yapılan hata: training data'ya gelecekten bilgi sızdırma.
Klasik Sızdırma Senaryosu#
# YANLIŞ: Random split — gelecekteki bilgi geçmişe sızıyor from sklearn.model_selection import train_test_split train, test = train_test_split(events, test_size=0.2, random_state=42)
Problem: set'te 2015'in Şubat'ından bir event var, ama 'de aynı user'ın Mart'taki event'i var. Model gelecekteki davranışı görerek geçmişi tahmin ediyor. Offline RMSE düşük, online'da nötr — klasik tuzak.
testtrainDoğru Çözüm — Time Split#
import polars as pl SPLIT_TIMESTAMP = 1440000000 # 2015-08-19'a denk geliyor (4 ay verinin ~%80'i) train = events.filter(pl.col("timestamp") < SPLIT_TIMESTAMP) test = events.filter(pl.col("timestamp") >= SPLIT_TIMESTAMP) # Eğer test'te train'de hiç gözükmemiş user/item varsa — onları filtrele train_users = set(train["visitorid"].unique().to_list()) train_items = set(train["itemid"].unique().to_list()) test_filtered = test.filter( pl.col("visitorid").is_in(train_users) & pl.col("itemid").is_in(train_items) ) print(f"Test before filter: {test.height:,}") print(f"Test after filter: {test_filtered.height:,}") print(f"Lost rows (cold): {test.height - test_filtered.height:,}")
Trade-off: Time-split daha gerçekçi ama cold-start item'lar test'ten düşüyor. Bunu kabul et — production'da zaten cold-start ayrı bir problem.
Diğer Sızdırma Tipleri#
| Sızdırma Tipi | Örnek | Önleme |
|---|---|---|
| Temporal leakage | Random split | Time split |
| User leakage | Aynı user hem train hem test | Leave-one-out per user |
| Item leakage | Cold item test'te | Item filter (yukarıdaki) |
| Feature leakage | "view_count" feature train sırasında future view'leri kullanıyor | Point-in-time features |
| Negative leakage | Negative örnekler aslında "henüz görülmemiş ama olası pozitif" | Time-stratified negative sampling |
🚨 Production Gotcha — Point-in-Time Correctness
Bir feature store kuruyorsan (Modül 22'de detaylı) her feature için 'as-of' bir timestamp olmalı. Yani 'user_view_count' feature'ı, eğitim sırasında eventin timestamp'inden ÖNCESİNİ sayıyor olmalı — hepsini değil. Bu point-in-time correctness. Feast/Tecton/Vertex AI FS'in en kritik özelliklerinden biri — atlanan en yaygın production bug.
Sıradakİ Ders#
Bir sonraki derste (2.3) — bias galaksisi. Position bias, presentation bias, popularity bias, exposure bias, selection bias. Her birinin matematiksel tanımı, IPS (Inverse Propensity Scoring) düzeltmesi, ve gerçek dünya hikayeleri. Bu ders senin "neden bu öneri sistemi şişiyor?" sorusuna ömür boyu cevap verecek.
Sık Sorulan Sorular
Genelde α ∈ [10, 100] aralığında. Klasik başlangıç: α=40 (paper default). Pratik: validation set'te NDCG@20'yi maksimize eden değeri grid search ile bul. α büyüdükçe popüler item'lar daha çok etki kazanır — eğer popularity bias problem ise α'yı küçült.
Yorumlar & Soru-Cevap
(0)Yorum yazmak için giriş yap.
Yorumlar yükleniyor...
İlgili İçerikler
Modül 0: Kurs Çerçevesi ve Atölye Kurulumu
Öneri Sistemleri Neden Bu Kadar Önemli? Bir Disiplinin Doğuşu, Bugünü ve Yarını
Öğrenmeye BaşlaModül 0: Kurs Çerçevesi ve Atölye Kurulumu
Recommender Engineer Kimdir? Yetkinlik Atlası ve Junior → Staff Kariyer Haritası
Öğrenmeye BaşlaModül 0: Kurs Çerçevesi ve Atölye Kurulumu