Hafta 5 · Daha Güçlü Modeller: Ağaç Toplulukları, SVM ve "Model Neye Bakıyor?"¶
SVY 5430 Biyomedikal Verilerin Analizi ve Yapay Zeka · Fırat Üniversitesi Yazılım ve Bilişim Araştırma Enstitüsü
Geçen hafta tek bir basit model kurduk ve onu dört soruyla sınadık. Bugün daha güçlü modeller geliyor: Random Forest, XGBoost ve SVM. Hepsini aynı dört soruyla sınayacağız; sonra da "model neye bakıyor?" sorusuna üç farklı cevap alıp birbirine benzeyip benzemediğine bakacağız.
Veri: Heart Failure Clinical Records (UCI). 299 kalp yetmezliği hastası, 12 klinik ölçüm, hedef: takip süresinde ölüm. Defterin sonunda bir de bonus var: tamamen uydurma bir Fenerbahçe veri seti. 🙂
import warnings; warnings.filterwarnings("ignore")
import io, zipfile, urllib.request
import numpy as np, pandas as pd
import matplotlib.pyplot as plt
URL = "https://archive.ics.uci.edu/static/public/519/heart+failure+clinical+records.zip"
try:
z = zipfile.ZipFile(io.BytesIO(urllib.request.urlopen(URL, timeout=30).read()))
df = pd.read_csv(z.open([n for n in z.namelist() if n.endswith(".csv")][0]))
print("Veri indirildi:", df.shape)
except Exception as e:
print("İndirme başarısız, sentetik veri üretiliyor:", e)
rng = np.random.default_rng(0); n = 299
df = pd.DataFrame({"age": rng.normal(61, 12, n).clip(40, 95), "anaemia": rng.integers(0, 2, n), "creatinine_phosphokinase": rng.lognormal(5.5, 1, n),
"diabetes": rng.integers(0, 2, n), "ejection_fraction": rng.normal(38, 12, n).clip(14, 80), "high_blood_pressure": rng.integers(0, 2, n),
"platelets": rng.normal(263000, 97000, n), "serum_creatinine": rng.lognormal(0.2, 0.5, n), "serum_sodium": rng.normal(137, 4, n),
"sex": rng.integers(0, 2, n), "smoking": rng.integers(0, 2, n), "time": rng.integers(4, 285, n)})
df["DEATH_EVENT"] = (rng.random(n) < 1 / (1 + np.exp(-(0.04 * (df.age - 60) - 0.06 * (df.ejection_fraction - 38) + 0.8 * (df.serum_creatinine - 1.4) - 0.012 * (df.time - 130))))).astype(int)
TR = {"age": "yas", "anaemia": "anemi", "creatinine_phosphokinase": "cpk", "diabetes": "diyabet", "ejection_fraction": "ejeksiyon_fraksiyonu",
"high_blood_pressure": "hipertansiyon", "platelets": "trombosit", "serum_creatinine": "serum_kreatinin", "serum_sodium": "serum_sodyum",
"sex": "cinsiyet", "smoking": "sigara", "time": "takip_gun", "DEATH_EVENT": "olum"}
df = df.rename(columns=TR)
print(df["olum"].value_counts().rename({0: "sağ", 1: "öldü"}))
df.head()
Veri indirildi: (299, 13) olum sağ 203 öldü 96 Name: count, dtype: int64
| yas | anemi | cpk | diyabet | ejeksiyon_fraksiyonu | hipertansiyon | trombosit | serum_kreatinin | serum_sodyum | cinsiyet | sigara | takip_gun | olum | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 75.0 | 0 | 582 | 0 | 20 | 1 | 265000.00 | 1.9 | 130 | 1 | 0 | 4 | 1 |
| 1 | 55.0 | 0 | 7861 | 0 | 38 | 0 | 263358.03 | 1.1 | 136 | 1 | 0 | 6 | 1 |
| 2 | 65.0 | 0 | 146 | 0 | 20 | 0 | 162000.00 | 1.3 | 129 | 1 | 1 | 7 | 1 |
| 3 | 50.0 | 1 | 111 | 0 | 20 | 0 | 210000.00 | 1.9 | 137 | 1 | 0 | 7 | 1 |
| 4 | 65.0 | 1 | 160 | 1 | 20 | 0 | 327000.00 | 2.7 | 116 | 0 | 0 | 8 | 1 |
1 · Bir tuzak: takip_gun sütunu¶
Her hastanın "takip süresi" var: kaç gün izlendiği. Ölen hastalar genellikle erken ölür, yani takip süresi kısadır. Bu sütunu modele verirseniz model çok başarılı görünür. Ama bir hasta ilk geldiğinde takip süresini bilmiyorsunuz; o bilgi gelecekten geliyor. Bu, 2. haftada konuştuğumuz zamansal sızıntının ders kitabı örneği. Aşağıda etkisini gösterip sonra sütunu çıkarıyoruz.
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
cv = StratifiedKFold(5, shuffle=True, random_state=42)
y = df["olum"]
lr = lambda: Pipeline([("olcek", StandardScaler()), ("lr", LogisticRegression(max_iter=2000))])
ile = cross_val_score(lr(), df.drop(columns="olum"), y, cv=cv, scoring="roc_auc").mean()
siz = cross_val_score(lr(), df.drop(columns=["olum", "takip_gun"]), y, cv=cv, scoring="roc_auc").mean()
print(f"takip_gun İLE : ROC-AUC = {ile:.3f} <- gelecekten gelen bilgi")
print(f"takip_gun OLMADAN: ROC-AUC = {siz:.3f} <- gerçekçi")
X = df.drop(columns=["olum", "takip_gun"])
takip_gun İLE : ROC-AUC = 0.875 <- gelecekten gelen bilgi takip_gun OLMADAN: ROC-AUC = 0.772 <- gerçekçi
2 · Dört model, aynı dört soru¶
Lojistik regresyon (geçen haftanın temel modeli), Random Forest, XGBoost ve RBF çekirdekli SVM. Hepsi aynı çapraz doğrulamada,
hepsi geçen haftanın ölçütleriyle: ROC-AUC, PR-AUC, Brier. Ağaç modelleri ölçekleme istemez; SVM ve lojistik ister. Hepsini
Pipeline içine koyuyoruz, böylece karşılaştırma adil.
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
from xgboost import XGBClassifier
from sklearn.model_selection import cross_validate
modeller = {
"Lojistik regresyon": Pipeline([("olcek", StandardScaler()), ("m", LogisticRegression(max_iter=2000))]),
"Random Forest": RandomForestClassifier(n_estimators=500, min_samples_leaf=3, random_state=0),
"XGBoost": XGBClassifier(n_estimators=300, learning_rate=0.05, max_depth=3, subsample=0.8, colsample_bytree=0.8,
random_state=0, eval_metric="logloss"),
"SVM (RBF)": Pipeline([("olcek", StandardScaler()), ("m", SVC(C=1.0, gamma="scale", probability=True, random_state=0))]),
}
skor = {"roc_auc": "roc_auc", "pr_auc": "average_precision", "brier": "neg_brier_score"}
satirlar = []
for ad, m in modeller.items():
r = cross_validate(m, X, y, cv=cv, scoring=skor)
satirlar.append({"model": ad, "ROC-AUC": r["test_roc_auc"].mean(), "±": r["test_roc_auc"].std(),
"PR-AUC": r["test_pr_auc"].mean(), "Brier": -r["test_brier"].mean()})
pd.DataFrame(satirlar).set_index("model").round(3)
| ROC-AUC | ± | PR-AUC | Brier | |
|---|---|---|---|---|
| model | ||||
| Lojistik regresyon | 0.772 | 0.049 | 0.648 | 0.178 |
| Random Forest | 0.783 | 0.074 | 0.658 | 0.172 |
| XGBoost | 0.742 | 0.069 | 0.619 | 0.198 |
| SVM (RBF) | 0.757 | 0.058 | 0.594 | 0.186 |
Okuyun: Dört model birbirine yakın mı? 299 hastalık küçük veride genellikle öyledir; karmaşık modelin avantajı veri büyüdükçe ortaya çıkar. Standart sapmalara bakın: fark, katlamalar arası dalgalanmadan büyük mü? Değilse "X modeli daha iyi" demek için kanıt yok.
Deneyin: Random Forest'ta
min_samples_leaf=1yapın (ağaçlar her hastayı ezberleyebilsin). ROC-AUC değişti mi, Brier ne oldu?
3 · Ayar düğmeleri: hiperparametre arama¶
XGBoost'un düğmeleri var: ağaç sayısı, derinlik, öğrenme oranı. Bunları test verisine bakarak ayarlamak sızıntıdır. Doğru yol: arama da çapraz doğrulamanın içinde. Aşağıda rastgele arama yapıyoruz; iç içe (nested) sürümü derste tartışacağız.
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint, uniform
arama = RandomizedSearchCV(
XGBClassifier(random_state=0, eval_metric="logloss"),
{"n_estimators": randint(100, 600), "max_depth": randint(2, 6), "learning_rate": uniform(0.01, 0.2),
"subsample": uniform(0.6, 0.4), "colsample_bytree": uniform(0.5, 0.5), "min_child_weight": randint(1, 8)},
n_iter=25, cv=cv, scoring="roc_auc", random_state=0, n_jobs=-1).fit(X, y)
print("En iyi ayar:", {k: round(v, 3) if isinstance(v, float) else v for k, v in arama.best_params_.items()})
print(f"En iyi ROC-AUC (iç CV): {arama.best_score_:.3f} — bu sayı iyimserdir; dış CV ile doğrulanmalı")
En iyi ayar: {'colsample_bytree': np.float64(0.605), 'learning_rate': np.float64(0.036), 'max_depth': 4, 'min_child_weight': 4, 'n_estimators': 153, 'subsample': np.float64(0.73)}
En iyi ROC-AUC (iç CV): 0.779 — bu sayı iyimserdir; dış CV ile doğrulanmalı
4 · Model neye bakıyor? Üç öznitelik önemi, üç liste¶
Aynı Random Forest'a üç farklı soru soruyoruz:
- Safsızlık temelli (ağacın kendi hesabı): hangi sütun dallanmalarda en çok işe yaradı? Hızlı ama sürekli ve çok değerli sütunlara yanlı.
- Permütasyon (geçen haftalardan tanıdık): bir sütunu karıştırınca AUC ne kadar düşüyor?
- Lojistik regresyonun katsayıları (standartlaştırılmış): basit modelin gözünden aynı soru.
Üç liste aynı çıkmaz. Hangisi doğru? Hepsi, farklı sorulara cevap veriyorlar.
from sklearn.inspection import permutation_importance
rf = RandomForestClassifier(n_estimators=500, min_samples_leaf=3, random_state=0).fit(X, y)
safsizlik = pd.Series(rf.feature_importances_, index=X.columns)
perm = permutation_importance(rf, X, y, n_repeats=30, random_state=0, scoring="roc_auc")
permutasyon = pd.Series(perm.importances_mean, index=X.columns)
lrm = Pipeline([("olcek", StandardScaler()), ("lr", LogisticRegression(max_iter=2000))]).fit(X, y)
katsayi = pd.Series(np.abs(lrm.named_steps["lr"].coef_[0]), index=X.columns)
fig, ax = plt.subplots(1, 3, figsize=(14, 4.2))
for a, (s, baslik, c) in zip(ax, [(safsizlik, "RF safsızlık önemi", "#bcc6d9"), (permutasyon, "RF permütasyon önemi (AUC kaybı)", "#2f3fa3"), (katsayi, "Lojistik |katsayı|", "#0f9b8e")]):
s.sort_values().plot.barh(ax=a, color=c); a.set_title(baslik)
plt.tight_layout()
Okuyun: Serum kreatinin ve ejeksiyon fraksiyonu üç listede de üstte mi? Trombosit ve CPK safsızlık listesinde yukarıda ama permütasyonda aşağıdaysa, bu safsızlık yönteminin "çok farklı değer alan sütunları sever" yanlılığıdır.
5 · Tuzak: birbirine benzeyen iki sütun önemi paylaşır¶
Şimdi küçük bir deney. Serum kreatininin neredeyse kopyası olan yeni bir sütun ekleyelim: kreatinin_kopya (küçük gürültüyle).
Klinikte bu çok olur: kreatinin ve eGFR, kilo ve VKİ, hemoglobin ve hematokrit. Ne olacak?
rng = np.random.default_rng(1)
X2 = X.copy(); X2["kreatinin_kopya"] = X2["serum_kreatinin"] * (1 + rng.normal(0, 0.05, len(X2)))
rf2 = RandomForestClassifier(n_estimators=500, min_samples_leaf=3, random_state=0).fit(X2, y)
p2 = permutation_importance(rf2, X2, y, n_repeats=30, random_state=0, scoring="roc_auc")
karsilastir = pd.DataFrame({"tek başına (önce)": permutasyon, "kopya eklenince": pd.Series(p2.importances_mean, index=X2.columns)}).round(4)
karsilastir.loc[["serum_kreatinin", "kreatinin_kopya", "ejeksiyon_fraksiyonu", "yas"]]
| tek başına (önce) | kopya eklenince | |
|---|---|---|
| serum_kreatinin | 0.0985 | 0.0284 |
| kreatinin_kopya | NaN | 0.0374 |
| ejeksiyon_fraksiyonu | 0.1105 | 0.0953 |
| yas | 0.0563 | 0.0482 |
Serum kreatininin önemi "aniden" düştü; kaybolmadı, kopyasıyla paylaşıldı. Bir modelin "X sütunu önemsiz" demesi, X'in bilgisinin başka bir sütunda da bulunmadığı anlamına gelmez. Bu, 12. haftada SHAP ile geri döneceğimiz bir konu.
Deneyin:
kreatinin_kopyayı çıkarıpejeksiyon_fraksiyonunun kopyasını ekleyin. Aynı paylaşım orada da oluyor mu?
6 · Bonus: Fenerbahçe ve taraftar morali (tamamen uydurma veri) 🟡🔵¶
Bugün moraller biraz düşük olabilir; o yüzden konuyu bir de uydurma bir veri setinde görelim. Aşağıdaki 400 maç tamamen sentetik: hiçbir gerçek maç, hakem ya da futbolcuyla ilgisi yoktur. Gerçek olaylarla benzerlik tamamen tesadüftür (ya da değildir 🙂).
Sütunlar: rakip gücü, deplasman mı, maç saati, son 5 maç formu, sakat sayısı, hakemin "ilginç" karar sayısı, maç öncesi taraftar umudu ve son olarak ertesi gün taraftar moral skoru (0–100). Hedefimiz ikili: moral 50'nin altına düştü mü?
Dikkat ederseniz "taraftar umudu" ile "son 5 maç formu" birbirine çok benziyor. Bölüm 5'teki tuzağı burada yeniden göreceğiz.
rng = np.random.default_rng(1907)
n = 400
fb = pd.DataFrame({
"rakip_gucu": rng.uniform(1, 10, n).round(1), # 10 = en güçlü
"deplasman": rng.integers(0, 2, n),
"mac_saati": rng.choice([19, 20, 21, 22], n),
"son5_form": rng.integers(0, 16, n), # son 5 maçtan alınan puan
"sakat_sayisi": rng.poisson(2, n),
"ilginc_hakem_karar": rng.poisson(1.2, n),
"sampiyonlar_ligi": rng.integers(0, 2, n),
})
fb["taraftar_umudu"] = (fb["son5_form"] * 5 + rng.normal(0, 6, n) + 20).clip(0, 100).round() # forma çok benzer
# Gizli "gerçek": moral neye bağlı? (modelin bunu bulup bulamayacağını göreceğiz)
gol_farki = (-0.45 * (fb["rakip_gucu"] - 5.5) - 0.6 * fb["deplasman"] + 0.08 * (fb["son5_form"] - 7.5) - 0.25 * (fb["sakat_sayisi"] - 2)
- 0.4 * (fb["ilginc_hakem_karar"] - 1.2) - 0.8 * fb["sampiyonlar_ligi"] + rng.normal(0, 1.2, n)) # mac_saati kasıtlı olarak yok
fb["moral_ertesi_gun"] = (50 + 12 * gol_farki + 0.15 * fb["taraftar_umudu"] + rng.normal(0, 8, n)).clip(0, 100).round()
fb["moral_dustu"] = (fb["moral_ertesi_gun"] < 50).astype(int)
print(f"{n} uydurma maç · moral düşüşü oranı: %{100*fb.moral_dustu.mean():.0f}")
fb.head()
400 uydurma maç · moral düşüşü oranı: %48
| rakip_gucu | deplasman | mac_saati | son5_form | sakat_sayisi | ilginc_hakem_karar | sampiyonlar_ligi | taraftar_umudu | moral_ertesi_gun | moral_dustu | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 8.6 | 1 | 21 | 2 | 0 | 3 | 0 | 29.0 | 10.0 | 1 |
| 1 | 8.2 | 0 | 20 | 5 | 2 | 0 | 1 | 43.0 | 29.0 | 1 |
| 2 | 1.7 | 0 | 21 | 2 | 1 | 0 | 0 | 28.0 | 68.0 | 0 |
| 3 | 9.3 | 1 | 19 | 11 | 1 | 2 | 1 | 74.0 | 21.0 | 1 |
| 4 | 7.7 | 1 | 22 | 10 | 1 | 1 | 0 | 76.0 | 40.0 | 1 |
fig, ax = plt.subplots(1, 3, figsize=(14, 3.8))
fb.groupby("sampiyonlar_ligi")["moral_dustu"].mean().rename({0: "Lig", 1: "Şampiyonlar Ligi"}).plot.bar(ax=ax[0], color=["#0f9b8e", "#2f3fa3"]); ax[0].set_title("Moral düşüşü oranı"); ax[0].tick_params(axis="x", rotation=0)
ax[1].scatter(fb["rakip_gucu"], fb["moral_ertesi_gun"], s=12, c=np.where(fb.deplasman == 1, "#be3a5a", "#0f9b8e"), alpha=.6); ax[1].set(xlabel="rakip gücü", ylabel="ertesi gün moral", title="Rakip gücü vs moral (kırmızı: deplasman)")
ax[2].scatter(fb["son5_form"], fb["taraftar_umudu"], s=12, c="#b7791f", alpha=.6); ax[2].set(xlabel="son 5 maç formu", ylabel="taraftar umudu", title="Birbirine çok benzeyen iki sütun")
plt.tight_layout()
Şimdi bugünün üç modelini bu veride çalıştıralım ve "model neye bakıyor?" sorusunu soralım. Doğru cevabı biliyoruz, çünkü veriyi biz ürettik: moral en çok rakip gücüne, Şampiyonlar Ligi'ne, deplasmana ve hakem kararlarına bağlı. Taraftar umudu ise formun kopyası.
Xf = fb.drop(columns=["moral_ertesi_gun", "moral_dustu"]); yf = fb["moral_dustu"]
for ad, m in modeller.items():
s = cross_val_score(m, Xf, yf, cv=cv, scoring="roc_auc")
print(f"{ad:20s} ROC-AUC = {s.mean():.3f} ± {s.std():.3f}")
rff = RandomForestClassifier(n_estimators=500, min_samples_leaf=3, random_state=0).fit(Xf, yf)
pf = permutation_importance(rff, Xf, yf, n_repeats=30, random_state=0, scoring="roc_auc")
onem = pd.Series(pf.importances_mean, index=Xf.columns).sort_values()
plt.figure(figsize=(6.5, 4)); onem.plot.barh(color=np.where(onem.index.isin(["son5_form", "taraftar_umudu"]), "#b7791f", "#2f3fa3"))
plt.title("Taraftar morali: Random Forest permütasyon önemi"); plt.xlabel("AUC kaybı"); plt.tight_layout()
Lojistik regresyon ROC-AUC = 0.885 ± 0.042
Random Forest ROC-AUC = 0.847 ± 0.041
XGBoost ROC-AUC = 0.845 ± 0.040 SVM (RBF) ROC-AUC = 0.868 ± 0.041
Okuyun: Model gizli gerçeği buldu mu? Rakip gücü ve Şampiyonlar Ligi üstte mi? Sarı çubuklara bakın: son5_form ile
taraftar_umudu önemi paylaşıyor; ikisi de "orta" görünüyor ama birini çıkarsanız öbürü yükselir. Bölüm 5'teki tuzak, bir başka kostümle.
Son söz: mac_saatinin önemi sıfıra yakın olmalı, çünkü veriyi üretirken moralle hiç ilişkilendirmedik. Model buna rağmen ona küçük bir
önem verdiyse, bu gürültüdür; gerçek veride de öyle küçük "önemler" çoğu zaman gürültüdür. Moral bozukluğuna gelince: veri diyor ki
rakip güçlüyse ve deplasmandaysanız düşüş normaldir; form gelince geçer. 🙂
7 · Bugün ne gördük?¶
| Konu | Kanıt | Alışkanlık |
|---|---|---|
| Zamansal sızıntı | takip_gun ile AUC şişti |
"Bu bilgi tahmin anında var mıydı?" diye sor |
| Dört model | Küçük veride farklar küçük | Standart sapmaya bak; temel modeli geçemeyeni savunma |
| Hiperparametre | Rastgele arama CV içinde | Test verisine bakarak ayar yapma |
| Üç öznitelik önemi | Üç farklı liste | Hangi soruya cevap verdiğini bil; safsızlık yanlıdır |
| Benzeyen sütunlar | Kreatinin kopyasıyla önem bölüşüldü | "Önemsiz" ≠ "bilgisiz" |
Ders sonrası (isteğe bağlı): BVYZ-01 projesi bu veriyle çalışıyor; bu defter doğrudan 02_model.ipynb için başlangıç olabilir.
Herkes için: kendi proje verinizde "gelecekten gelen" bir sütun var mı? Varsa raporun Yöntem bölümüne yazın.