Skip to content

Data Splitting Strategies: Random, Time, User, Leave-One-Out — Practical Trade-Offs

How you split MovieLens changes NDCG — 0.15 or 0.25. This lesson covers 5 main split strategies, when each is correct, when each leaks, and a comparison from a production realism standpoint.

Şükrü Yusuf KAYA
24 min read
Intermediate
Veri Bölme Stratejileri: Random, Time, User, Leave-One-Out — Pratik Trade-Off
⚠️ 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#

StratejiAçıklamaRealismRisk
Random SplitTüm rating'leri rastgele 80/20 bölDüşükTemporal leakage
Time SplitTüm rating'leri timestamp'e göre böl (eski → train, yeni → test)YüksekCold item dışlama
User SplitUser'ları böl (some users in train, others in test)OrtaCold start odaklı testlere ideal
Leave-One-Out (LOO)Her user'ın son rating'i test, geri kalanı trainYüksekYapay yüksek metrik
Stratified Time SplitTime split + user/item sıklık dengelemeEn yüksekKarmaşı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 utility
import polars as pl
from 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
 
 
# Test
from data.movielens_loader import load_ml_1m
ratings, _, _ = 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:
SplitNDCG@10Recall@20HR@10
Random Split0.2410.3180.612
Time Split0.1580.2140.434
Leave-One-Out (full eval)0.2950.3850.703
LOO with 99 negatives0.1870.521
Stratified Time Split0.1620.2210.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.

Frequently Asked Questions

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

Related Content