İçeriğe geç

MovieLens'i Sıfırdan Tanıyalım: Schema, EDA ve Polars ile Verimli Yükleme

MovieLens-100K, 1M ve 25M'in dosya yapısı, satır-satır schema, Polars ile lazy/streaming load (Pandas'tan 10-30x hızlı), sparse matrix'e çevirme, ilk EDA grafikleri ve veri kalite kontrolleri.

Şükrü Yusuf KAYA
32 dakikalık okuma
Orta
MovieLens'i Sıfırdan Tanıyalım: Schema, EDA ve Polars ile Verimli Yükleme
📊 Bu dersin amacı
MovieLens öneri sistemleri dünyasının 'Hello World'üdür ama çoğu insan onu yüzeysel kullanır. Bu derste: dosya formatından field tip'lerine, Polars lazy execution'dan scipy.sparse'a kadar bir mühendisin bilmesi gereken her şeyi ele alıyoruz. Sonunda elinde reusable bir
data/movielens_loader.py
modülün olacak.

Niye Veri Yüklemeye Bir Ders Adıyoruz?#

Üç sebep:
1. Yanlış yükleme = yanlış model. Eğer rating'leri
int8
yerine
float32
yüklersen 4x daha çok RAM kullanırsın — bir M2 16GB Mac'te MovieLens-25M'i yükleyemezsin. Eğer timestamp'leri parse etmezsen time-split yapamazsın. Eğer user_id'leri 0-indexli yeniden numaralandırmazsan embedding tablolarını yanlış boyutta kurarsın.
2. Pandas 10M satırın üstünde yavaşlar. Bu kursun büyük dataset'lerinde (Amazon Reviews 570M, H&M 31M) Pandas
.apply()
ile çalışırsan dakikalar değil saatler yersin. Polars/DuckDB/Spark refleksi bir recommender mühendisinin temel becerisidir.
3. Sparse matrix dönüşümü ML için kritik. Bir 100M user x 1M item rating matrisini dense numpy array olarak tutamazsın (100TB!). Sparse'a çevirmek "nice-to-know" değil, "must-have"dir.
Bu üçü olmadan recommender mühendisliğine başlayamazsın.

MovieLens'in Üç Sürümü — Karşılaştırma#

BoyutUserItemRatingTagDiskRAM (load edildikten sonra)Train süresi (Funk SVD, CPU)
ML-100K9431,682100,000yok5 MB~50 MB~5 sn
ML-1M6,0403,7061,000,209yok24 MB~200 MB~30 sn
ML-10M71,56710,68110M95K250 MB~2 GB~5 dk
ML-25M162,54162,42325M1.1M250 MB~3 GB~15 dk
ML-32M (2024)200,94887,58532M2M312 MB~4 GB~22 dk
Her boyut farklı pedagojik amaca hizmet eder. 100K hızlı debug, 1M klasik benchmark, 25M+ modern deep model'ler için.

ML-100K — En Küçük, En Pedagojik#

100K satırın detaylı schema'sı (dosyalar tab-separated, header yok):

u.data
— Ana rating dosyası#

user_id<TAB>item_id<TAB>rating<TAB>timestamp 196 242 3 881250949 186 302 3 891717742 22 377 1 878887116 ...
  • user_id
    : 1-943 arası
  • item_id
    : 1-1682 arası
  • rating
    : 1-5 integer (explicit)
  • timestamp
    : Unix epoch (saniye)

u.item
— Item metadata#

1|Toy Story (1995)|01-Jan-1995||http://us.imdb.com/M/title-exact?Toy%20Story%20(1995)|0|0|0|1|1|1|0|0|0|0|0|0|0|0|0|0|0|0|0
  • 24 alan, pipe-separated
  • İlk 5: id, title, release_date, video_release_date, IMDb URL
  • Sonraki 19: binary genre flag (unknown, Action, Adventure, ..., Western)

u.user
— User metadata#

1|24|M|technician|85711
  • id, age, gender, occupation, zip_code

Önemli Detay: Time Split İçin Hazır#

u1.base
/
u1.test
...
u5.base
/
u5.test
— 5-fold cross-validation için önceden bölünmüş. Ama dikkat: bu random split — sonra göreceğin gibi recommender için time split çok daha gerçekçi.

Polars ile Verimli Yükleme#

Neden Polars, Pandas Değil?#

İşlemPandas (sn)Polars (sn)Hızlanma
ML-25M CSV read18.41.215x
Groupby user_id, count2.30.0829x
Filter timestamp range1.10.0427x
Sort by timestamp4.20.314x
Test: M2 Pro Mac, 16GB RAM, MovieLens-25M dataset.
Polars'ın 3 anahtar avantajı: (1) Rust runtime — multi-thread by default. (2) Lazy execution — query planner optimize eder. (3) Arrow memory layout — zero-copy I/O.
python
# data/movielens_loader.py — Reusable MovieLens loader
import polars as pl
from pathlib import Path
from typing import Literal
 
DATA_DIR = Path("data")
 
def load_ml_100k() -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame]:
"""ML-100K yükler. Returns: (ratings, items, users)"""
base = DATA_DIR / "ml-100k"
 
ratings = pl.read_csv(
base / "u.data",
separator="\t",
has_header=False,
new_columns=["user_id", "item_id", "rating", "timestamp"],
schema_overrides={
"user_id": pl.Int32,
"item_id": pl.Int32,
"rating": pl.Int8, # 1-5 -> int8 yeter
"timestamp": pl.Int64, # Unix epoch
},
)
 
# u.item: 24 sütun, pipe separator
item_cols = ["item_id", "title", "release_date", "video_release_date", "imdb_url"]
genre_cols = [
"unknown", "Action", "Adventure", "Animation", "Children",
"Comedy", "Crime", "Documentary", "Drama", "Fantasy",
"Film-Noir", "Horror", "Musical", "Mystery", "Romance",
"Sci-Fi", "Thriller", "War", "Western"
]
 
items = pl.read_csv(
base / "u.item",
separator="|",
has_header=False,
new_columns=item_cols + genre_cols,
encoding="latin-1", # ascii değil, ML-100K Latin-1
schema_overrides={"item_id": pl.Int32},
)
 
users = pl.read_csv(
base / "u.user",
separator="|",
has_header=False,
new_columns=["user_id", "age", "gender", "occupation", "zip_code"],
schema_overrides={"user_id": pl.Int32, "age": pl.Int8},
)
 
return ratings, items, users
 
 
def load_ml_1m() -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame]:
"""ML-1M yükler. ::-separated, no header."""
base = DATA_DIR / "ml-1m"
 
ratings = pl.read_csv(
base / "ratings.dat",
separator="::",
has_header=False,
new_columns=["user_id", "item_id", "rating", "timestamp"],
encoding="latin-1",
).with_columns([
pl.col("user_id").cast(pl.Int32),
pl.col("item_id").cast(pl.Int32),
pl.col("rating").cast(pl.Int8),
pl.col("timestamp").cast(pl.Int64),
])
 
items = pl.read_csv(
base / "movies.dat",
separator="::",
has_header=False,
new_columns=["item_id", "title", "genres"],
encoding="latin-1",
)
 
users = pl.read_csv(
base / "users.dat",
separator="::",
has_header=False,
new_columns=["user_id", "gender", "age", "occupation", "zip_code"],
encoding="latin-1",
)
 
return ratings, items, users
 
 
def load_ml_25m_lazy() -> pl.LazyFrame:
"""ML-25M lazy load — 250 MB CSV, RAM'e tam alma."""
return pl.scan_csv(
DATA_DIR / "ml-25m" / "ratings.csv",
schema_overrides={
"userId": pl.Int32,
"movieId": pl.Int32,
"rating": pl.Float32, # 0.5 step var ML-25M'de
"timestamp": pl.Int64,
},
)
 
 
if __name__ == "__main__":
# Smoke test
r100k, i100k, u100k = load_ml_100k()
print(f"100K ratings shape: {r100k.shape}")
print(f"100K items: {i100k.shape}")
print(f"100K users: {u100k.shape}")
 
r1m, i1m, u1m = load_ml_1m()
print(f"1M ratings shape: {r1m.shape}")
 
r25m_lazy = load_ml_25m_lazy()
print(f"25M row count: {r25m_lazy.select(pl.count()).collect().item():,}")
 
Tüm 3 MovieLens versiyonunu yükleyen reusable modül. Kursun her dersinde tekrar import edeceğiz.
🚨 Encoding tuzağı
MovieLens-100K ve 1M dosyaları Latin-1 encoding'de — UTF-8 değil. Eğer film başlığı yabancı karakter içeriyorsa (Amélie, Léon vs.) UTF-8 ile okumaya kalkarsan UnicodeDecodeError alırsın.
encoding="latin-1"
parametresini unutma.

İlk EDA — Bir Saatlik Veriye Bakış#

Yeni bir dataset eline geldiğinde ilk yapman gereken modeli eğitmek değil — veriyi tanımak. İşte recommender mühendisinin standard EDA checklist'i:
python
# eda_movielens.py — İlk bakış
import polars as pl
import matplotlib.pyplot as plt
from data.movielens_loader import load_ml_1m
 
ratings, items, users = load_ml_1m()
 
# === 1. Temel istatistikler ===
print(f"Total ratings: {ratings.height:,}")
print(f"Unique users: {ratings['user_id'].n_unique():,}")
print(f"Unique items: {ratings['item_id'].n_unique():,}")
print(f"Rating range: {ratings['rating'].min()} - {ratings['rating'].max()}")
print(f"Mean rating: {ratings['rating'].mean():.3f}")
print(f"Sparsity: {1 - ratings.height / (6040 * 3706):.4%}")
 
# Çıktı:
# Total ratings: 1,000,209
# Unique users: 6,040
# Unique items: 3,706
# Rating range: 1 - 5
# Mean rating: 3.582
# Sparsity: 95.5318%
 
# === 2. Rating dağılımı (J-curve kontrolü) ===
rating_dist = (
ratings.group_by("rating")
.agg(pl.count().alias("count"))
.sort("rating")
)
print(rating_dist)
# shape: (5, 2)
# ┌────────┬────────┐
# │ rating │ count │
# ├────────┼────────┤
# │ 1 │ 56174 │
# │ 2 │ 107557 │
# │ 3 │ 261197 │
# │ 4 │ 348971 │ ← peak
# │ 5 │ 226310 │
# └────────┴────────┘
 
# === 3. Per-user activity dağılımı (long-tail kontrolü) ===
user_activity = (
ratings.group_by("user_id")
.agg(pl.count().alias("n_ratings"))
.sort("n_ratings", descending=True)
)
print("Top 10 active users:")
print(user_activity.head(10))
 
# Percentile dağılımı
percentiles = [10, 25, 50, 75, 90, 95, 99]
for p in percentiles:
val = user_activity["n_ratings"].quantile(p / 100)
print(f" P{p}: {int(val)} ratings")
# P10: 24, P25: 44, P50: 96, P75: 208, P90: 437, P95: 668, P99: 1466
 
# === 4. Per-item popularity dağılımı (long-tail) ===
item_pop = (
ratings.group_by("item_id")
.agg(pl.count().alias("n_ratings"))
.sort("n_ratings", descending=True)
)
 
# 80/20 testi: top-20% item kaç rating alıyor?
top_20pct_count = item_pop["n_ratings"].head(int(item_pop.height * 0.2)).sum()
share = top_20pct_count / ratings.height
print(f"Top 20% items get {share:.2%} of all ratings")
# Top 20% items get 75% of all ratings — popularity bias gerçek
 
# === 5. Time range ===
from datetime import datetime
min_ts = ratings["timestamp"].min()
max_ts = ratings["timestamp"].max()
print(f"Time range: {datetime.fromtimestamp(min_ts)} → {datetime.fromtimestamp(max_ts)}")
# Time range: 2000-04-25 → 2003-02-28
 
# === 6. Cold user/item sayısı (5'ten az interaction) ===
cold_users = user_activity.filter(pl.col("n_ratings") < 5).height
cold_items = item_pop.filter(pl.col("n_ratings") < 5).height
print(f"Cold users (<5 ratings): {cold_users}")
print(f"Cold items (<5 ratings): {cold_items}")
# Cold users: 0 (ML-1M'de minimum 20 rating filter var)
# Cold items: 285
 
EDA checklist — yeni bir dataset karşına çıktığında ilk yapacakların.

Long-Tail Olgusu — Recommender'ın Karanlık Yarısı#

EDA'da gördüğümüz "top 20% items get 75% of ratings" bulgusu klasik long-tail distribution'dur. Bu olgu recommender'ı tanımlayan tek istatistiksel gerçektir.

Niçin Long-Tail Önemli?#

ratings/item │ N ─┤██ │██ │██ │██ │████ │██████ │█████████ │█████████████████ ← uzun kuyruk └────────────────────────────► items
  • Head (popüler %20): yüksek rating sayısı, CF kolay öğrenir, ama "herkes zaten biliyor" — değer az.
  • Tail (long-tail %80): az rating, CF zor öğrenir, ama "asıl öneri değeri burada".
Netflix Prize'ın amacı "iyi öneriler vermek"ti. Ama bir bakıma "uzun kuyruktaki film iyileri keşfettirmek"ti — çünkü head'deki popüler içeriği herkes zaten görmüştü.

Long-Tail'i Görselleştir#

import matplotlib.pyplot as plt import numpy as np # item_pop yukarıda sıralanmıştı counts = item_pop["n_ratings"].to_numpy() ranks = np.arange(1, len(counts) + 1) fig, axes = plt.subplots(1, 2, figsize=(12, 4)) # Lineer skala axes[0].plot(ranks, counts) axes[0].set_title("Item Popularity Distribution (lineer)") axes[0].set_xlabel("Rank") axes[0].set_ylabel("# ratings") # Log-log — "power law" sinyali axes[1].loglog(ranks, counts) axes[1].set_title("Item Popularity (log-log)") axes[1].set_xlabel("Rank (log)") axes[1].set_ylabel("# ratings (log)") plt.tight_layout() plt.show()
Log-log grafikteki çizgi lineer çıkar — bu Zipf yasası (power law). MovieLens-1M'de exponent yaklaşık -1.0. Bu kanıt: dataset gerçekten power-law dağılımdadır.
Item popülerliğinin long-tail (Zipf) dağılımı — head ve tail.
Item popüleritesinde tipik power-law: az item çok rating, çok item az rating.

Sparse Matrix'e Dönüşüm — Recommender'ın Temel Veri Yapısı#

ML-1M'de 6040 user × 3706 item = 22.3M cell. Ama sadece 1M rating var. Yani %95.5'i boş. Bu matrisi dense NumPy array olarak tutamazsın (90MB) — hatta tutabilsen bile sparse matrix'in tüm matematiksel hilelerini kaybedersin.

scipy.sparse — 4 Format Genel Bakış#

FormatYapıİyi Olduğu YerKötü Olduğu Yer
COO (Coordinate)(row, col, data) üçlüInşa etmek hızlıSlice/row access yavaş
CSR (Compressed Sparse Row)Row pointer + col indices + dataRow access çok hızlıConstruction yavaş
CSC (Compressed Sparse Column)CSR'ın column versiyonuColumn accessRow access
LIL (List of Lists)Row-wise listIncremental insertMath operations
Pratik kural: COO ile inşa et, CSR'a dönüştür, CSR ile kullan.
python
# data/sparse_builder.py — Sparse matrix builder
import numpy as np
import polars as pl
from scipy.sparse import coo_matrix, csr_matrix
 
def ratings_to_sparse(
ratings: pl.DataFrame,
n_users: int = None,
n_items: int = None,
binarize: bool = False,
) -> tuple[csr_matrix, dict, dict]:
"""
Polars rating DataFrame'i CSR sparse matrix'e çevirir.
 
Args:
ratings: pl.DataFrame with columns [user_id, item_id, rating]
n_users, n_items: matrix boyutu (None ise max+1)
binarize: True ise rating'ler 1'e çevrilir (implicit feedback)
 
Returns:
R: CSR matrix (n_users × n_items)
user_to_idx: original user_id → matrix row mapping
item_to_idx: original item_id → matrix col mapping
"""
# ID'leri 0-indexli yeniden numaralandır (modeller için kritik)
unique_users = sorted(ratings["user_id"].unique().to_list())
unique_items = sorted(ratings["item_id"].unique().to_list())
user_to_idx = {u: i for i, u in enumerate(unique_users)}
item_to_idx = {it: i for i, it in enumerate(unique_items)}
 
if n_users is None:
n_users = len(unique_users)
if n_items is None:
n_items = len(unique_items)
 
# Vectorized mapping (Polars sayesinde hızlı)
rows = ratings["user_id"].map_elements(lambda u: user_to_idx[u], return_dtype=pl.Int32).to_numpy()
cols = ratings["item_id"].map_elements(lambda i: item_to_idx[i], return_dtype=pl.Int32).to_numpy()
 
if binarize:
data = np.ones(len(rows), dtype=np.float32)
else:
data = ratings["rating"].cast(pl.Float32).to_numpy()
 
R_coo = coo_matrix((data, (rows, cols)), shape=(n_users, n_items))
R_csr = R_coo.tocsr() # CSR'a dönüştür
R_csr.eliminate_zeros()
 
return R_csr, user_to_idx, item_to_idx
 
 
# Smoke test
from data.movielens_loader import load_ml_1m
ratings, _, _ = load_ml_1m()
R, u_map, i_map = ratings_to_sparse(ratings)
 
print(f"Sparse matrix: {R.shape}")
print(f"NNZ: {R.nnz:,}")
print(f"Density: {R.nnz / (R.shape[0] * R.shape[1]):.4%}")
print(f"Memory (CSR): {(R.data.nbytes + R.indices.nbytes + R.indptr.nbytes) / 1e6:.1f} MB")
 
# Sparse matrix: (6040, 3706)
# NNZ: 1,000,209
# Density: 4.4682%
# Memory (CSR): 9.1 MB ← dense olsaydı: 89.6 MB
 
Reusable sparse matrix builder — kurs boyunca defalarca kullanacağız.

Veri Kalite Kontrolleri — "Önce Şüphelen"#

Production'da veri kalitesi her zaman beklediğinden kötüdür. MovieLens "temiz" bir dataset olarak ün yapsa da, içinde görünmeyen sorunlar var. İşte refleks kontroller:

Kontrol 1: Duplicate User-Item Pairs#

duplicates = ( ratings.group_by(["user_id", "item_id"]) .agg(pl.count().alias("n")) .filter(pl.col("n") > 1) ) print(f"Duplicate (u, i) pairs: {duplicates.height}") # ML-1M: 0 (temiz) # RetailRocket: ~3000 (aynı user aynı item'a defalarca click)

Kontrol 2: Future Timestamp / Inversed Ratings#

# Future timestamp (sistem clock hatası) future = ratings.filter(pl.col("timestamp") > 1_900_000_000).height # 2030 sonrası = anomali # Out-of-range rating weird = ratings.filter((pl.col("rating") < 1) | (pl.col("rating") > 5)).height

Kontrol 3: Cold User/Item Yoğunluğu#

cold_user_pct = (user_activity.filter(pl.col("n_ratings") < 5).height / user_activity.height) print(f"Cold user oranı (< 5 rating): {cold_user_pct:.2%}")

Kontrol 4: Rating Inflation Over Time#

Zamanla ortalama rating yükseliyor mu? (Reviewer'lar yorum yapmaya alıştıkça daha cömert oluyor.)
import datetime as dt ratings_with_year = ratings.with_columns( pl.col("timestamp") .map_elements(lambda t: dt.datetime.fromtimestamp(t).year, return_dtype=pl.Int32) .alias("year") ) yearly = ratings_with_year.group_by("year").agg(pl.col("rating").mean().alias("avg_rating")) print(yearly.sort("year"))

Kontrol 5: Outlier User Removal#

Bazı user'lar 5,000+ rating verir — bot olabilir veya power user. Modele zarar verebilir.
# 99. persentilin 5x'ini geçen user'ları cherry-pick et p99 = user_activity["n_ratings"].quantile(0.99) outliers = user_activity.filter(pl.col("n_ratings") > 5 * p99) print(f"Outlier users ({5*p99:.0f}+ ratings): {outliers.height}")
🚨 Üretim Gotcha — Re-numbering Tuzağı
Asla original user_id ve item_id'leri model'e doğrudan vermeyin. Embedding tabloları 0-indexli sürekli aralık (contiguous) bekler. Eğer user_id'leri [1, 7, 942] gibi sparse bir set ise embedding(942) = 943-row'lık table demektir — RAM israfı. Her zaman
LabelEncoder
veya manuel
{orig_id: new_idx}
mapping ile 0-indexli yeniden numaralandırın. Bu mapping'i kaydedin — production'da yeni rating geldiğinde aynı mapping'i kullanmak zorundasınız (yoksa training-serving skew).

Sıradakİ Ders#

Bir sonraki derste (2.2) — implicit log'u (click, view, purchase) modelin anlayacağı etikete çeviriyoruz. Confidence weighting, multi-signal aggregation, sequential session reconstruction. Bu, recommender mühendisinin günlük işin çekirdek becerisi.

Sık Sorulan Sorular

Üçü farklı use case. Polars: single-machine, 1-100M satır. DuckDB: SQL severseniz, embedded analytics. Spark: distributed (100M+ satır birden çok makine). Recommender mühendisinin %80 işi Polars'la biter, %15'i DuckDB ile sorgu yazma, %5'i Spark cluster. Polars'tan başla.

Yorumlar & Soru-Cevap

(0)
Yorum yazmak için giriş yap.
Yorumlar yükleniyor...

İlgili İçerikler