MovieLens from Zero: Schema, EDA, and Efficient Loading with Polars
File structure of MovieLens-100K, 1M, 25M, row-by-row schema, lazy/streaming load with Polars (10-30x faster than Pandas), sparse matrix conversion, first EDA graphics, and data quality checks.
Şükrü Yusuf KAYA
32 min read
Intermediate📊 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 modülün olacak.
data/movielens_loader.pyNiye Veri Yüklemeye Bir Ders Adıyoruz?#
Üç sebep:
1. Yanlış yükleme = yanlış model. Eğer rating'leri yerine 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.
int8float322. Pandas 10M satırın üstünde yavaşlar. Bu kursun büyük dataset'lerinde (Amazon Reviews 570M, H&M 31M) Pandas ile çalışırsan dakikalar değil saatler yersin. Polars/DuckDB/Spark refleksi bir recommender mühendisinin temel becerisidir.
.apply()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#
| Boyut | User | Item | Rating | Tag | Disk | RAM (load edildikten sonra) | Train süresi (Funk SVD, CPU) |
|---|---|---|---|---|---|---|---|
| ML-100K | 943 | 1,682 | 100,000 | yok | 5 MB | ~50 MB | ~5 sn |
| ML-1M | 6,040 | 3,706 | 1,000,209 | yok | 24 MB | ~200 MB | ~30 sn |
| ML-10M | 71,567 | 10,681 | 10M | 95K | 250 MB | ~2 GB | ~5 dk |
| ML-25M | 162,541 | 62,423 | 25M | 1.1M | 250 MB | ~3 GB | ~15 dk |
| ML-32M (2024) | 200,948 | 87,585 | 32M | 2M | 312 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ı#
u.datauser_id<TAB>item_id<TAB>rating<TAB>timestamp 196 242 3 881250949 186 302 3 891717742 22 377 1 878887116 ...
- : 1-943 arası
user_id - : 1-1682 arası
item_id - : 1-5 integer (explicit)
rating - : Unix epoch (saniye)
timestamp
u.item — Item metadata#
u.item1|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#
u.user1|24|M|technician|85711
- id, age, gender, occupation, zip_code
Önemli Detay: Time Split İçin Hazır#
u1.baseu1.testu5.baseu5.testPolars ile Verimli Yükleme#
Neden Polars, Pandas Değil?#
| İşlem | Pandas (sn) | Polars (sn) | Hızlanma |
|---|---|---|---|
| ML-25M CSV read | 18.4 | 1.2 | 15x |
| Groupby user_id, count | 2.3 | 0.08 | 29x |
| Filter timestamp range | 1.1 | 0.04 | 27x |
| Sort by timestamp | 4.2 | 0.3 | 14x |
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 loaderimport polars as plfrom pathlib import Pathfrom 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. parametresini unutma.
encoding="latin-1"İ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 plimport matplotlib.pyplot as pltfrom 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.heightprint(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 datetimemin_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).heightcold_items = item_pop.filter(pl.col("n_ratings") < 5).heightprint(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.
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ış#
| Format | Yapı | İyi Olduğu Yer | Kö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 + data | Row access çok hızlı | Construction yavaş |
| CSC (Compressed Sparse Column) | CSR'ın column versiyonu | Column access | Row access |
| LIL (List of Lists) | Row-wise list | Incremental insert | Math operations |
Pratik kural: COO ile inşa et, CSR'a dönüştür, CSR ile kullan.
python
# data/sparse_builder.py — Sparse matrix builderimport numpy as npimport polars as plfrom 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 testfrom data.movielens_loader import load_ml_1mratings, _, _ = 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 veya manuel 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).
LabelEncoder{orig_id: new_idx}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.
Frequently Asked Questions
Üçü 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...
Related Content
Module 0: Course Framework & Workshop Setup
Why Do Recommender Systems Matter? Birth, Present, and Future of a Discipline
Start LearningModule 0: Course Framework & Workshop Setup
Who Is a Recommender Engineer? Skill Atlas and Junior → Staff Career Map
Start LearningModule 0: Course Framework & Workshop Setup