İçeriğe geç

Doğruluk Metrikleri: RMSE, MAE, Precision@K, Recall@K, MAP, MRR, NDCG, HR@K — Tam Matematik + NumPy

8 ana doğruluk metriğinin tam matematiksel tanımı, sıfırdan NumPy implementasyonu, MovieLens üzerinde karşılaştırmalı çalıştırma, ve hangi durumda hangi metriği seçmen gerektiği — recommender mühendisinin metric cheat sheet'i.

Şükrü Yusuf KAYA
35 dakikalık okuma
İleri
Doğruluk Metrikleri: RMSE, MAE, Precision@K, Recall@K, MAP, MRR, NDCG, HR@K — Tam Matematik + NumPy
📏 Bu dersin amacı
Recommender'lar 8 farklı doğruluk metriği etrafında konuşur. Bu metriklerin bir kısmı birbirini ölçer, bir kısmı birbirine zıt sonuçlar verebilir. Aynı modelin RMSE'si düşük ama HR@10'u yüksek olabilir. Bu derste her metriği matematiksel olarak türetiyor, NumPy ile sıfırdan yazıyor ve MovieLens üzerinde karşılaştırmalı koşturuyoruz. Sonunda kendi cheat sheet'in olur.

8 Metrik — Hızlı Bakış#

MetrikTipAralıkYön (iyi)Asıl Soru
RMSERating prediction[0, ∞)↓ düşük"Tahminlerin gerçek puana ne kadar yakın?"
MAERating prediction[0, ∞)↓ düşük"Ortalama tahmin hatası kaç?"
Precision@KTop-N[0, 1]↑ yüksek"Önerdiğim K item'ın yüzde kaçı doğruydu?"
Recall@KTop-N[0, 1]↑ yüksek"Doğruların yüzde kaçını yakaladım?"
HR@K (Hit Rate)Top-N[0, 1]↑ yüksek"En az 1 doğru var mı?"
MAP@KRanking[0, 1]↑ yüksek"Sıra-hassas precision"
MRRSequential[0, 1]↑ yüksek"İlk doğru hangi sırada?"
NDCG@KRanking[0, 1]↑ yüksek"Sıralama ne kadar 'ideale' yakın?"
Bu metriklerin her biri farklı amaç için tasarlanmış. Tek bir "doğru metrik" yok — soruya göre seçim.

1) RMSE ve MAE — Rating Prediction Klasikleri#

RMSE (Root Mean Squared Error)#

RMSE = √( (1/|D|) Σ (r_ui - r̂_ui)² )
Özellikleri:
  • Büyük hataları karesi ile cezalandırır — 1 birim hata 1, 2 birim hata 4 ağırlık.
  • Birimi rating ile aynı (1-5 ölçekli sistemde 0-4 arası).
  • Netflix Prize bu metric üzerine kuruldu (0.9525 → 0.8567 hedefi).

MAE (Mean Absolute Error)#

MAE = (1/|D|) Σ |r_ui - r̂_ui|
Özellikleri:
  • Lineer ceza — 1 birim hata 1, 2 birim hata 2.
  • Outlier'lara karşı daha robust.
  • Yorumlama daha kolay ("ortalama 0.7 puan sapıyoruz").

RMSE vs MAE — Hangisini Seçmeli?#

DurumSeç
Büyük hatalar çok ağır kayıpRMSE
Outlier ratings (1★ inflation)MAE
Production bias varsaMAE (RMSE patlatır)
Geleneksel benchmarkRMSE (Netflix Prize)

RMSE'nin Şişme Tuzağı#

Bir kullanıcı genelde 4★ verirse ve bir item'a 1★ verdiyse — RMSE bunu çok ağır cezalandırır. Halbuki bu kullanıcı için 1★ "outlier" olabilir, gerçekten anlamlı bir sapma değil. Pratik: ya MAE kullan, ya da rating'leri normalize et.
python
# metrics/rating_metrics.py — Rating prediction metrikleri
import numpy as np
 
def rmse(predictions: np.ndarray, actuals: np.ndarray) -> float:
"""Root Mean Squared Error."""
return float(np.sqrt(np.mean((predictions - actuals) ** 2)))
 
 
def mae(predictions: np.ndarray, actuals: np.ndarray) -> float:
"""Mean Absolute Error."""
return float(np.mean(np.abs(predictions - actuals)))
 
 
def mse(predictions: np.ndarray, actuals: np.ndarray) -> float:
"""Mean Squared Error — RMSE'nin karekökü öncesi."""
return float(np.mean((predictions - actuals) ** 2))
 
 
# Smoke test
np.random.seed(42)
n = 1000
actuals = np.random.choice([1, 2, 3, 4, 5], size=n, p=[0.05, 0.10, 0.20, 0.35, 0.30])
 
# Model 1: Naive — herkese ortalama ver
mean_rating = actuals.mean()
preds_naive = np.full(n, mean_rating)
 
# Model 2: Düşük gürültülü model
preds_good = actuals + np.random.normal(0, 0.5, n)
 
# Model 3: Outlier yapan model — 5%'i çok kötü tahmin eder
preds_outlier = actuals + np.random.normal(0, 0.2, n)
outlier_mask = np.random.random(n) < 0.05
preds_outlier[outlier_mask] += np.random.choice([-4, 4], outlier_mask.sum())
 
print(f"Naive model | RMSE: {rmse(preds_naive, actuals):.4f} | MAE: {mae(preds_naive, actuals):.4f}")
print(f"Good model | RMSE: {rmse(preds_good, actuals):.4f} | MAE: {mae(preds_good, actuals):.4f}")
print(f"Outlier-prone model | RMSE: {rmse(preds_outlier, actuals):.4f} | MAE: {mae(preds_outlier, actuals):.4f}")
 
# Çıktı:
# Naive model | RMSE: 1.0996 | MAE: 0.9170
# Good model | RMSE: 0.5031 | MAE: 0.4001
# Outlier-prone model | RMSE: 0.9420 | MAE: 0.3950 ← outlier RMSE'yi şişiriyor
# ama MAE iyi gösteriyor
 
RMSE ve MAE — outlier davranışını gözle.

2) Precision@K ve Recall@K — Top-N'in Temelleri#

Tanımlar#

K = 10 önerdik. Test set'inde user'ın gerçek pozitif item set'i
T_u
.
TopK_u = modelin verdiği ilk K item HitSet = TopK_u ∩ T_u ← doğru olan kısım Precision@K = |HitSet| / K ← önerdiklerin yüzde kaçı doğru? Recall@K = |HitSet| / |T_u| ← doğruların yüzde kaçını yakaladım?

Tablolu Örnek#

User u'nun gerçek beğendiği item'lar: T_u = {A, B, C, D, E} Model top-10: [B, X, A, Y, Z, C, W, V, U, T] HitSet = {A, B, C} Precision@10 = 3 / 10 = 0.30 Recall@10 = 3 / 5 = 0.60

Trade-off#

Precision↑ Recall↓ — kısa liste ver, kalitesi yüksek olsun. Precision↓ Recall↑ — uzun liste ver, daha çok yakala.
Recommender'da genelde Recall@K daha önemli (kapsama), ama NDCG (sırayı da hesaba katar) daha pratik bir bileşim.

F1@K#

İkisinin harmonik ortalaması:
F1@K = 2 · P · R / (P + R)
Pratikte F1 nadiren raporlanır (NDCG daha güçlüdür).
python
# metrics/topn_metrics.py — Top-N metrikleri (sıfırdan)
import numpy as np
 
def precision_at_k(
recommended: list[int], # modelin önerileri, sıralı liste
ground_truth: set[int], # gerçek pozitif item'lar
k: int,
) -> float:
"""Precision@K — önerdiğin ilk K'da doğru oranı."""
top_k = recommended[:k]
hits = sum(1 for item in top_k if item in ground_truth)
return hits / k
 
 
def recall_at_k(
recommended: list[int],
ground_truth: set[int],
k: int,
) -> float:
"""Recall@K — doğruların yüzde kaçını ilk K'da yakaladın."""
if not ground_truth:
return 0.0
top_k = recommended[:k]
hits = sum(1 for item in top_k if item in ground_truth)
return hits / len(ground_truth)
 
 
def hit_rate_at_k(
recommended: list[int],
ground_truth: set[int],
k: int,
) -> float:
"""HR@K — en az 1 doğru var mı? 0 ya da 1."""
top_k = recommended[:k]
return 1.0 if any(item in ground_truth for item in top_k) else 0.0
 
 
def f1_at_k(p: float, r: float) -> float:
"""F1@K — harmonik ortalama."""
if p + r == 0:
return 0.0
return 2 * p * r / (p + r)
 
 
# Test
recommended = [101, 102, 103, 104, 105, 106, 107, 108, 109, 110]
ground_truth = {103, 105, 108, 999} # 4 gerçek pozitif
 
print(f"Precision@10: {precision_at_k(recommended, ground_truth, 10):.4f}")
print(f"Recall@10: {recall_at_k(recommended, ground_truth, 10):.4f}")
print(f"HR@10: {hit_rate_at_k(recommended, ground_truth, 10):.4f}")
print(f"F1@10: {f1_at_k(precision_at_k(recommended, ground_truth, 10), recall_at_k(recommended, ground_truth, 10)):.4f}")
 
# Precision@10: 0.3000
# Recall@10: 0.7500 (3/4 yakalandı, 999 dışarıda)
# HR@10: 1.0000
# F1@10: 0.4286
 
Top-N metrikleri — sıfırdan, kolay anlaşılır.

3) MAP@K ve MRR — Sıralı Precision Aileleri#

MAP@K (Mean Average Precision)#

Precision@K'da sıralama önemli değil (top-10'da 1. mi 10. mi olduğu fark etmiyor). MAP@K bunu düzeltir:
Yani: her doğru-hit pozisyonundaki Precision@k'yı ortala. Erken pozisyondaki hit'ler daha güçlü.

Tablolu Örnek#

Test set: T_u = {A, B, C} (3 gerçek pozitif) Top-10: [A, X, Y, B, Z, W, C, U, V, T] Pozisyonlar: A=1, B=4, C=7 AP = (1/3) * [P@1 + P@4 + P@7] = (1/3) * [1/1 + 2/4 + 3/7] = (1/3) * [1.0 + 0.5 + 0.4286] = 0.6429
MAP birden çok kullanıcı için: AP'lerin ortalaması.

MRR (Mean Reciprocal Rank)#

Sequential rec ve search'te yaygın. İlk doğru hit'in sırasının tersi:

Tablolu Örnek#

User A: ilk hit pozisyon 1 → RR = 1.0 User B: ilk hit pozisyon 5 → RR = 0.2 User C: hiç hit yok → RR = 0.0 MRR = (1.0 + 0.2 + 0.0) / 3 = 0.4

MAP vs MRR Farkı#

  • MAP: Tüm doğru hit'leri sayar (multi-relevant).
  • MRR: Sadece ilk hit'i önemser.
"Search engine"de MRR sık (kullanıcı genelde ilk doğru sonuca tıklar). Recommender'da MAP daha pratik.
python
# metrics/ranked_metrics.py — Sıralı metrikler
import numpy as np
 
def average_precision_at_k(
recommended: list[int],
ground_truth: set[int],
k: int,
) -> float:
"""AP@K — sıralama-hassas precision."""
if not ground_truth:
return 0.0
top_k = recommended[:k]
hits = 0
sum_precisions = 0.0
for i, item in enumerate(top_k, start=1):
if item in ground_truth:
hits += 1
sum_precisions += hits / i
return sum_precisions / min(len(ground_truth), k)
 
 
def reciprocal_rank(
recommended: list[int],
ground_truth: set[int],
) -> float:
"""RR — ilk doğru hit'in pozisyonunun tersi."""
for i, item in enumerate(recommended, start=1):
if item in ground_truth:
return 1.0 / i
return 0.0
 
 
def mean_average_precision_at_k(
all_recommended: list[list[int]],
all_ground_truth: list[set[int]],
k: int,
) -> float:
"""MAP@K — tüm kullanıcılar için AP'lerin ortalaması."""
aps = [
average_precision_at_k(rec, gt, k)
for rec, gt in zip(all_recommended, all_ground_truth)
]
return float(np.mean(aps))
 
 
def mean_reciprocal_rank(
all_recommended: list[list[int]],
all_ground_truth: list[set[int]],
) -> float:
"""MRR — tüm kullanıcılar için RR'lerin ortalaması."""
rrs = [
reciprocal_rank(rec, gt)
for rec, gt in zip(all_recommended, all_ground_truth)
]
return float(np.mean(rrs))
 
 
# Test
recommended = [101, 102, 103, 104, 105, 106, 107, 108, 109, 110]
ground_truth = {101, 104, 107}
 
print(f"AP@10: {average_precision_at_k(recommended, ground_truth, 10):.4f}")
print(f"RR: {reciprocal_rank(recommended, ground_truth):.4f}")
 
# AP@10:
# Pos 1 (101): hit, P@1 = 1/1 = 1.0
# Pos 4 (104): hit, P@4 = 2/4 = 0.5
# Pos 7 (107): hit, P@7 = 3/7 = 0.4286
# AP@10 = (1.0 + 0.5 + 0.4286) / 3 = 0.6429
# RR: 1.0 (ilk hit pozisyon 1)
 
MAP@K + MRR — sıralı metrikler.

4) NDCG@K — Recommender'ın Altın Standardı#

Niçin NDCG Özel?#

NDCG (Normalized Discounted Cumulative Gain) iki özel şey yapar:
  1. Discount — geç sıralardaki hit'lere düşük ağırlık.
  2. Normalization — "ideal" durumla normalize, sonuç 0-1 arası.
Bunlar onu sıralama-hassas metric'lerin en güçlüsü yapıyor.

Adım 1: DCG (Discounted Cumulative Gain)#

Pozisyondaki item'ın relevance'ını log(pozisyon) ile böl:

Discount Faktörü — Tabloyla#

Pozisyon ilog₂(i+1)Discount = 1/log₂(i+1)
11.001.000
21.580.631
32.000.500
42.320.431
52.580.387
103.460.289
204.390.228
Birinci sıradakine tam ağırlık, 10. sıradakine 0.289 ağırlık. Üstteki çok önemli.

Adım 2: IDCG (Ideal DCG)#

Aynı kullanıcı için mükemmel sıralamadaki DCG. Yani gerçek pozitif item'ları en yukarı koysak DCG ne olurdu.

Adım 3: NDCG#

Tablolu Örnek#

Test set: T_u = {A, B, C} ← 3 pozitif, rel = 1 her biri Top-10: [A, X, Y, B, Z, W, C, U, V, T] Pozisyonlar: A=1, B=4, C=7 DCG = 1/log₂(2) + 1/log₂(5) + 1/log₂(8) = 1/1.0 + 1/2.32 + 1/3.0 = 1.0 + 0.431 + 0.333 = 1.764 Ideal sıralama: [A, B, C, ...] IDCG = 1/log₂(2) + 1/log₂(3) + 1/log₂(4) = 1/1.0 + 1/1.585 + 1/2.0 = 1.0 + 0.631 + 0.5 = 2.131 NDCG@10 = 1.764 / 2.131 = 0.828

Binary vs Graded Relevance#

Yukarıda her item için rel=1 (binary). Eğer rating'ler 1-5 ise:
rel_i = 2^rating - 1
Bu durumda 5★ item 31 puan, 4★ item 15 puan getirir — graded relevance.
python
# metrics/ndcg.py — NDCG@K implementasyonu
import numpy as np
 
def dcg_at_k(relevances: list[float], k: int) -> float:
"""Discounted Cumulative Gain — verilen sıralamada."""
relevances = np.asarray(relevances[:k], dtype=np.float32)
if len(relevances) == 0:
return 0.0
discounts = 1.0 / np.log2(np.arange(2, len(relevances) + 2))
return float(np.sum(relevances * discounts))
 
 
def ndcg_at_k(
recommended: list[int],
ground_truth: dict[int, float] | set[int],
k: int,
) -> float:
"""
NDCG@K — normalized.
 
ground_truth: dict ise {item_id: relevance_score}, set ise binary (rel=1).
"""
# ground_truth'u dict'e normalize et
if isinstance(ground_truth, set):
gt_dict = {item: 1.0 for item in ground_truth}
else:
gt_dict = ground_truth
 
if not gt_dict:
return 0.0
 
# Verilen sıralamadaki relevance'lar
rels_predicted = [gt_dict.get(item, 0.0) for item in recommended[:k]]
 
# Ideal sıralama — relevance'ları büyükten küçüğe
ideal_rels = sorted(gt_dict.values(), reverse=True)[:k]
 
dcg = dcg_at_k(rels_predicted, k)
idcg = dcg_at_k(ideal_rels, k)
 
return dcg / idcg if idcg > 0 else 0.0
 
 
# Test (binary)
recommended = [101, 102, 103, 104, 105, 106, 107, 108, 109, 110]
ground_truth_set = {101, 104, 107}
print(f"NDCG@10 (binary): {ndcg_at_k(recommended, ground_truth_set, 10):.4f}")
 
# Test (graded — 5★ rating sistemi)
ground_truth_dict = {101: 5.0, 104: 4.0, 107: 3.0, 999: 5.0}
print(f"NDCG@10 (graded): {ndcg_at_k(recommended, ground_truth_dict, 10):.4f}")
 
# NDCG@10 (binary): 0.8290 ← yukarıdaki manuel hesapla aynı
# NDCG@10 (graded): farklı çünkü relevance ağırlıkları farklı
 
NDCG@K — binary ve graded relevance ile.

Karşılaştırmalı Çalışma — Aynı Tahmin Üzerine Tüm Metrikler#

Aynı user için aynı top-10 önerisi ile tüm metrikleri hesaplayalım:
python
import numpy as np
from metrics.topn_metrics import precision_at_k, recall_at_k, hit_rate_at_k
from metrics.ranked_metrics import average_precision_at_k, reciprocal_rank
from metrics.ndcg import ndcg_at_k
 
recommended = [101, 102, 103, 104, 105, 106, 107, 108, 109, 110]
ground_truth = {101, 104, 107}
 
print(f"Precision@10: {precision_at_k(recommended, ground_truth, 10):.4f}")
print(f"Recall@10: {recall_at_k(recommended, ground_truth, 10):.4f}")
print(f"HR@10: {hit_rate_at_k(recommended, ground_truth, 10):.4f}")
print(f"AP@10: {average_precision_at_k(recommended, ground_truth, 10):.4f}")
print(f"RR: {reciprocal_rank(recommended, ground_truth):.4f}")
print(f"NDCG@10: {ndcg_at_k(recommended, ground_truth, 10):.4f}")
 
# Çıktı:
# Precision@10: 0.3000 ← önerimin 30%'i doğru
# Recall@10: 1.0000 ← gerçeklerin 100%'ü top-10'da
# HR@10: 1.0000 ← en az 1 hit var
# AP@10: 0.6429 ← sıra hassas precision
# RR: 1.0000 ← ilk hit pozisyon 1
# NDCG@10: 0.8290 ← sıralama gücünü ölç
 
# 💡 Aynı sıralama 6 farklı sayı veriyor. Hangisi "doğru" olduğu sorulan soruya bağlı.
 
Tüm metriklerin birarada karşılaştırması.

Hangi Metric Ne Zaman?#

ProblemMetric (öncelik sırasıyla)
Rating prediction (Netflix Prize-tarzı)RMSE, sonra MAE
Top-N öneri (e-ticaret feed)NDCG@10, sonra Recall@20
Search rankingNDCG@10, sonra MAP@10
Next-item prediction (sequential)HR@10, sonra MRR
Çoklu item retrieve (basket)Recall@K, sonra MAP@K
Recommendation diversityCoverage (Modül 3.2)

Üretimde "Birincil" Metric Seçme#

Tek metric optimize edersin — yoksa optimization conflict olur. Yaygın pratik:
  • Birincil: NDCG@10 (en bilgilendirici, sıra-hassas).
  • İkincil (sanity checks): HR@10, Recall@20, Coverage.
  • Guardrails: Latency, popularity bias (artmamalı).
🚨 Sık yapılan hata: K seçimi
K=10 ve K=100 farklı şeyler söyler. K küçükse model 'top-1'i bilmek' iyi olmalı (çok zor). K büyükse 'top-100 listede olmak yeter' — daha kolay. Bir paper okuyup 'Recall@K = 0.5' gördüğünde K'yı kontrol et. Bazı paper K=20, bazıları K=100 — direct karşılaştırma yanlış.

Sıradaki Ders#

Bir sonraki derste (3.2) — beyond-accuracy metrikler. Coverage, ILS (intra-list similarity), novelty, serendipity, popularity bias. Bunlar tek başına "iyi öneri" değil; ana metrikler ile birlikte raporlanır. Bir mühendis "modelim NDCG'si iyi ama coverage'ı çöp" diyebilmeli.

Sık Sorulan Sorular

Tipik benchmark aralıkları: Popularity baseline: 0.06-0.08. Item-Item k-NN: 0.10-0.13. ALS / BiasedMF: 0.13-0.16. BPR-MF: 0.14-0.17. LightGCN: 0.17-0.20. SASRec: 0.18-0.22. State-of-art (2024+): 0.22-0.27. Bunlar ortalama — exact protocol (leave-one-out vs time split) çok farklı sonuç verir.

Yorumlar & Soru-Cevap

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

İlgili İçerikler