Skip to content

Turning Implicit Feedback into Labels: Click, Dwell, and Multi-Signal Aggregation

Raw e-commerce site logs → trainable labeled dataset. Math and NumPy implementation of Hu/Koren confidence weighting, multi-signal weighted aggregation, session reconstruction, preventing label leakage.

Şükrü Yusuf KAYA
28 min read
Intermediate
Implicit Feedback'i Etikete Çevirmek: Click, Dwell ve Multi-Signal Aggregation
🏷️ 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#

timestamp,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
r_ui
etkileşim sayısı (örn. kaç kez view),
α
hyperparameter (paperda α=40).

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):
    c_ui = 1 + α · r_ui
    — etkileşim sayısı yüksekse loss ağır cezalandırılır.
  • Negatif örnekler (p_ui=0):
    c_ui = 1
    — sabit (düşük) confidence. Tüm "no-interaction" pair'lar negatif sayılır AMA düşük ağırlıkla.
  • 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 implementasyon
import numpy as np
from 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_random
R_test = sparse_random(50, 100, density=0.1, format="csr", dtype=np.float32) * 10
R_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
implicit
kütüphanesini kullanın:
from implicit.als import AlternatingLeastSquares; model = AlternatingLeastSquares(factors=64, regularization=0.1, alpha=40); model.fit(R)
. Cython/CUDA implementasyonu — manuel kod'tan 50-200x daha hızlı (M2 Mac'te). Pedagojik olarak manuel kod yaz, üretimde kütüphane.

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:
EtiketlemeRecall@20Notu
Sadece view (binary)0.082Naive baseline
Sabit ağırlık0.094+%14 — basit ama etkili
Log-linear0.097+%18
Funnel-aware0.103+%26 — açık ara
Sonuç: Etiketleme stratejisi seçimi modelinden çok daha fazla fark yaratabilir.
python
# data/multi_signal.py — Multi-signal aggregation
import polars as pl
 
# Funnel-aware ağırlıklar
SIGNAL_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"))
)
 
 
# Test
import polars as pl
events = 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 reconstruction
import 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"),
])
)
 
 
# Test
import polars as pl
events = pl.read_csv("data/retailrocket/events.csv")
sessions = reconstruct_sessions(events)
print(sessions.head(10))
 
# Session istatistikleri
stats = (
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:
test
set'te 2015'in Şubat'ından bir event var, ama
train
'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.

Doğ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 leakageRandom splitTime split
User leakageAynı user hem train hem testLeave-one-out per user
Item leakageCold item test'teItem filter (yukarıdaki)
Feature leakage"view_count" feature train sırasında future view'leri kullanıyorPoint-in-time features
Negative leakageNegative ö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.

Frequently Asked Questions

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...

Related Content