Veri Bölme Stratejileri: Random, Time, User, Leave-One-Out — Pratik Trade-Off
MovieLens'i nasıl bölersen NDCG'nin değişir — 0.15 veya 0.25. Bu derste 5 ana split stratejisi, her birinin ne zaman doğru, ne zaman 'leakage' verdiği ve production realism açısından karşılaştırması.
Şükrü Yusuf KAYA
24 dakikalık okuma
Orta⚠️ Bu derste şok edici fark var
Aynı dataset, aynı model, farklı split — NDCG@10 0.15'ten 0.25'e atlayabilir. Bu derste 5 ana split stratejisini öğreniyor, MovieLens-1M üzerinde her birinin nasıl farklı sonuç verdiğini görüyor ve production realism için doğru olanı seçmeyi öğreniyoruz.
5 Ana Split Stratejisi#
| Strateji | Açıklama | Realism | Risk |
|---|---|---|---|
| Random Split | Tüm rating'leri rastgele 80/20 böl | Düşük | Temporal leakage |
| Time Split | Tüm rating'leri timestamp'e göre böl (eski → train, yeni → test) | Yüksek | Cold item dışlama |
| User Split | User'ları böl (some users in train, others in test) | Orta | Cold start odaklı testlere ideal |
| Leave-One-Out (LOO) | Her user'ın son rating'i test, geri kalanı train | Yüksek | Yapay yüksek metrik |
| Stratified Time Split | Time split + user/item sıklık dengeleme | En yüksek | Karmaşık implementasyon |
1) Random Split — En Yaygın, En Yanlış#
Yöntem#
from sklearn.model_selection import train_test_split train, test = train_test_split(events, test_size=0.2, random_state=42)
Problem: Temporal Leakage#
User u'nun events'i:
2015-01: [view A, view B, view C, ...] 2015-06: [view F, view G] 2015-12: [view K, view L]
Random split'te:
train: [A, C, F, K, L] test: [B, G]
Model, 2015-12'deki K, L'yi görerek 2015-01'deki B'yi tahmin etmeye çalışıyor. Bu gelecekten geçmişe sızdırma — production'da imkansız bir durum.
Sonuç#
Random split → fake yüksek NDCG. Production'da aynı modelin gerçek NDCG'si %30-50 daha düşük olur.
Ne Zaman OK?#
- Akademik baseline (literature ile kıyas için)
- Çok küçük dataset (time-split için yeterli volume yok)
- Cross-validation hızı önemli (deneme aşaması)
Pratik#
# Random split — sadece "ilk benchmark" için kullan import numpy as np np.random.seed(42) mask = np.random.random(events.height) < 0.8 train = events.filter(mask) test = events.filter(~mask)
2) Time Split — Production Realism'in En Doğru Yansıması#
Yöntem#
Bir timestamp eşiği belirle. Eşikten önce — train. Eşikten sonra — test.
import polars as pl # Dataset'in 80. persentilindeki timestamp'i eşik yap threshold = events["timestamp"].quantile(0.8) train = events.filter(pl.col("timestamp") < threshold) test = events.filter(pl.col("timestamp") >= threshold)
Niçin Daha Gerçekçi?#
Production'da modeli bugün eğitirsin, yarın kullanırsın. Train'in tüm event'leri test'inkinden eski olmak zorunda. Time split bu mantığı kopyalar.
Cold Item Problemi#
Time split'in tek sorunu: test set'inde train'de hiç görülmemiş item'lar olabilir. CF (collaborative filtering) bu item'lar için tahmin yapamaz.
Çözüm: Item Filter
train_items = set(train["item_id"].unique().to_list()) test_filtered = test.filter(pl.col("item_id").is_in(train_items)) print(f"Test before: {test.height}") print(f"Test after: {test_filtered.height}") print(f"Lost cold items: {test.height - test_filtered.height}")
Two-Phase Time Split#
Akademik standart: 3 set kullan.
[----------------- All Data ----------------] [--- Train ---][- Val -][- Test -] 60-70% 10-15% 15-20% (önceki) (orta) (sonraki)
python
# splits/time_split.py — Time split utilityimport polars as plfrom typing import Tuple def time_split( events: pl.DataFrame, test_pct: float = 0.2, val_pct: float = 0.1, filter_cold: bool = True,) -> Tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame]: """ Time-based train/val/test split. Args: events: DataFrame with [user_id, item_id, timestamp] test_pct: Test set fraction (most recent) val_pct: Val set fraction (just before test) filter_cold: Cold user/item filter Returns: (train, val, test) DataFrames """ n = events.height test_cutoff = events["timestamp"].quantile(1 - test_pct) val_cutoff = events["timestamp"].quantile(1 - test_pct - val_pct) train = events.filter(pl.col("timestamp") < val_cutoff) val = events.filter( (pl.col("timestamp") >= val_cutoff) & (pl.col("timestamp") < test_cutoff) ) test = events.filter(pl.col("timestamp") >= test_cutoff) print(f"Train: {train.height:,} ({100 * train.height / n:.1f}%)") print(f"Val: {val.height:,} ({100 * val.height / n:.1f}%)") print(f"Test: {test.height:,} ({100 * test.height / n:.1f}%)") if filter_cold: train_users = set(train["user_id"].unique().to_list()) train_items = set(train["item_id"].unique().to_list()) val_filtered = val.filter( pl.col("user_id").is_in(train_users) & pl.col("item_id").is_in(train_items) ) test_filtered = test.filter( pl.col("user_id").is_in(train_users) & pl.col("item_id").is_in(train_items) ) print(f"Val after cold filter: {val_filtered.height:,} ({100 * val_filtered.height / val.height:.1f}%)") print(f"Test after cold filter: {test_filtered.height:,} ({100 * test_filtered.height / test.height:.1f}%)") return train, val_filtered, test_filtered return train, val, test # Testfrom data.movielens_loader import load_ml_1mratings, _, _ = load_ml_1m()train, val, test = time_split(ratings, test_pct=0.15, val_pct=0.1) Time split — reusable utility.
3) Leave-One-Out (LOO) — Sequential'ın Standardı#
Yöntem#
Her user'ın son etkileşimini test set'e koy, geri kalanını train'e.
def leave_one_out(events: pl.DataFrame) -> tuple[pl.DataFrame, pl.DataFrame]: # Her user için en son timestamp'li event last_events = ( events .sort(["user_id", "timestamp"]) .group_by("user_id") .agg(pl.last("item_id").alias("item_id"), pl.last("timestamp").alias("timestamp")) ) # Test = last events, Train = geri kalanı test = last_events train = events.join(last_events, on=["user_id", "item_id", "timestamp"], how="anti") return train, test
Niçin Sequential Rec'in Standardı?#
Sequential rec problem'i tanımı: "user'ın geçmişine bakarak bir sonraki item'ı tahmin et". LOO bu problemi birebir kopyalar.
Sorun: Yapay Yüksek Metrik#
LOO'da her user için tam 1 ground truth var. Eğer model 100 item'lık liste önerirse, NDCG@100 mekanik olarak yüksek olur. Üstelik:
- Test set'te 100 negative örnek rastgele seçilir.
- Top-1'i ilk pozisyonda göstermek "kolay" — sadece bir doğru var.
Bu Dacrema 2019 paper'ının ana eleştirisinden biri.
Çözüm: Time-Stratified LOO veya Sampled LOO#
- Time-stratified LOO: Test event'i sadece "yeni" (örn. son 30 gün) eventler.
- Sampled LOO: Test'te 99 negative + 1 positive ile rank — daha az şişirilmiş.
4) User Split — Cold-Start Testleri İçin#
Yöntem#
User'ları rastgele böl — bazı user'lar tamamen train'de, bazıları tamamen test'te.
import numpy as np all_users = events["user_id"].unique().to_list() np.random.seed(42) np.random.shuffle(all_users) n_test = int(0.2 * len(all_users)) test_users = set(all_users[:n_test]) train_users = set(all_users[n_test:]) train = events.filter(pl.col("user_id").is_in(train_users)) test = events.filter(pl.col("user_id").is_in(test_users))
Niçin?#
Cold-start user'ları test etmek için: test user'ları için train'de hiç event yok — modelin "ilk öneri" verme kabiliyetini ölçer.
Yapısal Sınırlama#
CF (collaborative filtering) bu durumda çalışamaz — test user'ları için hiçbir bilgi yok. User-split sadece content-based, embedding-based, meta-learning model'ler için anlamlı.
Hangi Modülde Kullanacağız?#
- Modül 4 (Content-based) — user-split ile cold-start performance.
- Modül 17 (LLM-based) — user-split ile zero-shot capability.
5) Stratified Time Split — En Sofistike#
Yöntem#
Time split + her user'ın min N etkileşim kuralı + item filter + cold detection.
def stratified_time_split( events: pl.DataFrame, test_pct: float = 0.2, min_user_interactions: int = 5, min_item_interactions: int = 5, ) -> tuple[pl.DataFrame, pl.DataFrame, dict]: """ Stratified time split — production realism + statistical robustness. """ # 1. Önce time split threshold = events["timestamp"].quantile(1 - test_pct) train_raw = events.filter(pl.col("timestamp") < threshold) test_raw = events.filter(pl.col("timestamp") >= threshold) # 2. Train'deki user/item frekansı user_count = train_raw.group_by("user_id").agg(pl.count().alias("n")) item_count = train_raw.group_by("item_id").agg(pl.count().alias("n")) valid_users = set( user_count.filter(pl.col("n") >= min_user_interactions)["user_id"].to_list() ) valid_items = set( item_count.filter(pl.col("n") >= min_item_interactions)["item_id"].to_list() ) # 3. Filter both train and test train = train_raw.filter( pl.col("user_id").is_in(valid_users) & pl.col("item_id").is_in(valid_items) ) test = test_raw.filter( pl.col("user_id").is_in(valid_users) & pl.col("item_id").is_in(valid_items) ) stats = { "n_total_users": len(valid_users), "n_total_items": len(valid_items), "n_train_events": train.height, "n_test_events": test.height, } return train, test, stats
Niçin "Stratified"?#
User'ların ve item'ların min interaction koşulunu sağlamasıyla, test set'i daha anlamlı olur. Cold-start hariç, "warm" zone'da değerlendirme yaparsın.
Karşılaştırmalı Sonuçlar — MovieLens-1M, BiasedMF#
Aynı BiasedMF modelini farklı split'lerle eğitip test ettiğimde:
| Split | NDCG@10 | Recall@20 | HR@10 |
|---|---|---|---|
| Random Split | 0.241 | 0.318 | 0.612 |
| Time Split | 0.158 | 0.214 | 0.434 |
| Leave-One-Out (full eval) | 0.295 | 0.385 | 0.703 |
| LOO with 99 negatives | 0.187 | — | 0.521 |
| Stratified Time Split | 0.162 | 0.221 | 0.448 |
Gözlem: Aynı model, aynı dataset — 6 farklı sayı. Production realism: Time Split veya Stratified Time Split.
Pratik Tavsiye#
- Akademik benchmark: LOO with 99 negatives (literature standard).
- Production realism: Stratified Time Split.
- Cold-start testi: User Split.
- Quick baseline iteration: Random Split (debugging only).
🚨 En kritik uyarı
Bir paper'da NDCG@10 = 0.5 görüyorsan ve protokol 'Random Split + LOO with 100 negatives' diyorsa — bu sayı hiçbir şey ifade etmiyor karşılaştırma için. Aynı protokol kullanılan diğer paper'la kıyaslama yapılabilir. Production'da o sayı anlamsız. Bu yüzden bir paper okurken mutlaka split protokolünü oku.
Sıradakİ Ders#
Bir sonraki derste (3.4) — online evaluation. Offline'da gördüğümüz tüm metrikler, online A/B test'te aynı sonucu vermek zorunda değil. CTR, dwell time, retention — online metrik çeşitleri. A/B test'in matematiği (sample size, statistical significance) ve interleaving dediğimiz daha verimli alternatif.
Sık Sorulan Sorular
Tarihsel — Netflix Prize random split'ti, sonraki paper'lar geleneği taşıdı. Ayrıca: akademik dataset'lerde 'production realism' hassasiyeti az; karşılaştırma için 'standart bir protocol' olmak yeterli görüldü. Dacrema 2019 paper'ı sektörü silkeledi — yeni paper'lar daha çok time split'e geçiyor.
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