Ana içeriğe geç
Kesit Python
İleri · Makine öğrenmesi

Ön işleme, pipeline ve model değerlendirme

StandardScaler, OneHotEncoder, SimpleImputer ve ColumnTransformer ile ön işleme; Pipeline ile sızıntısız akış; çapraz doğrulama, GridSearchCV/RandomizedSearchCV, öğrenme eğrisi, joblib ile kaydetme ve model kartı.

Uygulamada aç, 28 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Model kodunun %80'i model değildir

Önceki iki derste veriler hep tertemiz sayılardı. Gerçekte Ilgaz Bank'ın müşteri tablosunda yaş bazen boş, şehir metin, segment kategorik, bakiye 100 TL ile 2 milyon TL arasında değişiyor. Modeller metni anlamaz, boş hücrede durur, ölçek farkından etkilenir. Bu yüzden her projede bir ön işleme (preprocessing) katmanı vardır: eksik doldurma, ölçekleme, kategorik kodlama. Bu derste bu adımları scikit-learn'ün Pipeline ve ColumnTransformer yapılarıyla tek bir nesneye bağlamayı, modeli çapraz doğrulamayla dürüstçe ölçmeyi, hiperparametreleri sistematik aramayı, modeli kaydetmeyi ve sonuçları bir model kartıyla raporlamayı öğreneceksin.

Senaryo: Ilgaz Bank, önümüzdeki üç ayda hesabını kapatacak (churn) müşterileri önceden bulup elde tutma kampanyası yapmak istiyor. Veri kurgusaldır ve tohumla üretilir.

import numpy as np
import pandas as pd


def churn_verisi(n=600, seed=1):
    """Ilgaz Bank kurgusal müşteri kaybı (churn) verisi; eksik değerler içerir."""
    rng = np.random.default_rng(seed)
    yas = rng.integers(18, 75, n).astype(float)
    bakiye = rng.gamma(2, 15000, n)
    urun = rng.integers(1, 5, n)
    sikayet = rng.poisson(0.5, n)
    sehir = rng.choice(["İstanbul", "Ankara", "İzmir", "Bursa"], n)
    segment = rng.choice(["Bireysel", "Premium", "KOBİ"], n, p=[0.6, 0.25, 0.15])
    logit = -1.2 + 0.9 * sikayet - 0.6 * (urun - 2) + 0.03 * (yas - 45) - 0.8 * (segment == "Premium")
    y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    yas[rng.random(n) < 0.08] = np.nan
    bakiye[rng.random(n) < 0.05] = np.nan
    X = pd.DataFrame({"yas": yas, "bakiye": bakiye.round(2), "urun": urun, "sikayet": sikayet,
                      "sehir": sehir, "segment": segment})
    return X, y

X, y = churn_verisi()
print(X.shape, "kayıp oranı:", round(y.mean(), 3))
print(X.head(3))
print(X.isna().sum().to_dict())

Dönüştürücüler: ölçek, kodlama, doldurma

Dönüştürücüler (transformer) fit ile veriden bir şey öğrenir (ortalama, medyan, kategori listesi) ve transform ile bunu uygular. fit_transform ikisini birden yapar. Kritik kural: fit yalnızca eğitim verisinde yapılır; test verisine yalnızca transform uygulanır.

DönüştürücüNe öğrenirNe yaparNe zaman
StandardScalerOrtalama ve standart sapma(x − ort) / std; ortalama 0, std 1Doğrusal modeller, KNN, SVM
MinMaxScalerMin ve max0-1 aralığına sıkıştırırSınırlı aralık gereken durumlar, sinir ağları
OneHotEncoderKategori listesiHer kategoriye 0/1 sütunSırasız kategoriler (şehir, segment)
OrdinalEncoderKategori listesiKategoriyi tam sayıya çevirirSıralı kategoriler, ağaç modelleri
SimpleImputerOrtalama/medyan/en sık değerEksikleri doldururEksik değer içeren her sütun
import numpy as np
import pandas as pd
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import MinMaxScaler, OneHotEncoder, StandardScaler

bakiye = np.array([[1000.0], [5000.0], [9000.0]])
sc = StandardScaler().fit(bakiye)
print(sc.mean_, sc.scale_.round(1))
print(sc.transform([[5000.0], [13000.0]]).ravel().round(3))
print(MinMaxScaler().fit_transform(bakiye).ravel())

sehir = pd.DataFrame({"sehir": ["İzmir", "Ankara", "İzmir"]})
enc = OneHotEncoder(handle_unknown="ignore", sparse_output=False).fit(sehir)
print(enc.get_feature_names_out().tolist())
print(enc.transform(pd.DataFrame({"sehir": ["Ankara", "Van"]})).tolist())   # Van hiç görülmedi

yas = np.array([[30.0], [np.nan], [50.0], [41.0]])
print(SimpleImputer(strategy="median").fit_transform(yas).ravel())
handle_unknown="ignore" neden şart?Canlıda yarın "Van" şubesinden ilk müşteri geldiğinde, varsayılan OneHotEncoder eğitimde görmediği kategori için ValueError verir ve tahmin servisi çöker. handle_unknown="ignore" bilinmeyen kategoriyi tüm sütunları 0 olan bir satırla temsil eder. sparse_output=False ise sonucu seyrek matris yerine normal numpy dizisi yapar (sklearn 1.2 öncesinde adı sparse idi).

ColumnTransformer ve Pipeline

ColumnTransformer farklı sütun gruplarına farklı işlem uygular: sayısallara doldur + ölçekle, kategoriklere doldur + kodla. Pipeline ise adımları sıraya dizer ve son adım olarak modeli ekler. Ortaya çıkan nesne bir model gibi davranır: fit, predict, score. Her adıma bir ad verirsin; bu adlar hiperparametre ararken adim__parametre biçiminde kullanılır.

import numpy as np
import pandas as pd


def churn_verisi(n=600, seed=1):
    """Ilgaz Bank kurgusal müşteri kaybı (churn) verisi; eksik değerler içerir."""
    rng = np.random.default_rng(seed)
    yas = rng.integers(18, 75, n).astype(float)
    bakiye = rng.gamma(2, 15000, n)
    urun = rng.integers(1, 5, n)
    sikayet = rng.poisson(0.5, n)
    sehir = rng.choice(["İstanbul", "Ankara", "İzmir", "Bursa"], n)
    segment = rng.choice(["Bireysel", "Premium", "KOBİ"], n, p=[0.6, 0.25, 0.15])
    logit = -1.2 + 0.9 * sikayet - 0.6 * (urun - 2) + 0.03 * (yas - 45) - 0.8 * (segment == "Premium")
    y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    yas[rng.random(n) < 0.08] = np.nan
    bakiye[rng.random(n) < 0.05] = np.nan
    X = pd.DataFrame({"yas": yas, "bakiye": bakiye.round(2), "urun": urun, "sikayet": sikayet,
                      "sehir": sehir, "segment": segment})
    return X, y

from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler


def on_isleyici(sayisal, kategorik):
    sayi_hatti = Pipeline([
        ("doldur", SimpleImputer(strategy="median")),
        ("olcek", StandardScaler()),
    ])
    kat_hatti = Pipeline([
        ("doldur", SimpleImputer(strategy="most_frequent")),
        ("kodla", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
    ])
    return ColumnTransformer([
        ("sayisal", sayi_hatti, sayisal),
        ("kategorik", kat_hatti, kategorik),
    ])

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score

X, y = churn_verisi()
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=0, stratify=y)

model = Pipeline([
    ("on_isleme", on_isleyici(["yas", "bakiye", "urun", "sikayet"], ["sehir", "segment"])),
    ("model", LogisticRegression(max_iter=1000)),
])
model.fit(X_tr, y_tr)                       # tüm fit'ler yalnızca eğitim verisinde
risk = model.predict_proba(X_te)[:, 1]      # test yalnızca transform edilir
print("Test ROC-AUC:", round(roc_auc_score(y_te, risk), 3))
print(model.named_steps["on_isleme"].get_feature_names_out()[:6].tolist())
print(model.get_params()["model__C"])

Pipeline'ın üç büyük kazancı var: (1) sızıntıyı önler, çünkü çapraz doğrulamada her katta dönüştürücüler yalnızca o katın eğitim kısmında fit edilir; (2) canlıya tek nesne gider, ön işlemeyi ayrıca kopyalamazsın; (3) ön işleme ayarları da hiperparametre gibi aranabilir (on_isleme__sayisal__doldur__strategy).

Çapraz doğrulama

Tek bir eğitim/test bölmesi şansa bağlıdır: başka bir random_state ile skor ±0.03 oynayabilir. k-katlı çapraz doğrulama (k-fold cross-validation) veriyi k parçaya böler; her seferinde bir parçayı doğrulama, kalanını eğitim yapar ve k skor üretir. Ortalaması daha güvenilir bir tahmin, standart sapması ise belirsizlik ölçüsüdür.

BölücüNe yaparNe zaman
KFold(n_splits, shuffle, random_state)Düz k parçaRegresyon
StratifiedKFoldHer katta sınıf oranını korurSınıflandırma (varsayılan seçim)
GroupKFoldAynı gruptaki (müşteri) satırları aynı katta tutarBir müşterinin birden fazla satırı varsa
TimeSeriesSplitHep geçmişle eğitir, gelecekle doğrularZaman serisi
import numpy as np
import pandas as pd


def churn_verisi(n=600, seed=1):
    """Ilgaz Bank kurgusal müşteri kaybı (churn) verisi; eksik değerler içerir."""
    rng = np.random.default_rng(seed)
    yas = rng.integers(18, 75, n).astype(float)
    bakiye = rng.gamma(2, 15000, n)
    urun = rng.integers(1, 5, n)
    sikayet = rng.poisson(0.5, n)
    sehir = rng.choice(["İstanbul", "Ankara", "İzmir", "Bursa"], n)
    segment = rng.choice(["Bireysel", "Premium", "KOBİ"], n, p=[0.6, 0.25, 0.15])
    logit = -1.2 + 0.9 * sikayet - 0.6 * (urun - 2) + 0.03 * (yas - 45) - 0.8 * (segment == "Premium")
    y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    yas[rng.random(n) < 0.08] = np.nan
    bakiye[rng.random(n) < 0.05] = np.nan
    X = pd.DataFrame({"yas": yas, "bakiye": bakiye.round(2), "urun": urun, "sikayet": sikayet,
                      "sehir": sehir, "segment": segment})
    return X, y

from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler


def on_isleyici(sayisal, kategorik):
    sayi_hatti = Pipeline([
        ("doldur", SimpleImputer(strategy="median")),
        ("olcek", StandardScaler()),
    ])
    kat_hatti = Pipeline([
        ("doldur", SimpleImputer(strategy="most_frequent")),
        ("kodla", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
    ])
    return ColumnTransformer([
        ("sayisal", sayi_hatti, sayisal),
        ("kategorik", kat_hatti, kategorik),
    ])

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score, cross_validate

X, y = churn_verisi()
pipe = Pipeline([
    ("on_isleme", on_isleyici(["yas", "bakiye", "urun", "sikayet"], ["sehir", "segment"])),
    ("model", LogisticRegression(max_iter=1000)),
])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
skorlar = cross_val_score(pipe, X, y, cv=cv, scoring="roc_auc")
print(skorlar.round(3), "ort:", round(skorlar.mean(), 3), "std:", round(skorlar.std(), 3))

detay = cross_validate(pipe, X, y, cv=cv, scoring=["roc_auc", "recall"], return_train_score=True)
print("eğitim AUC:", detay["train_roc_auc"].mean().round(3), "doğrulama AUC:", detay["test_roc_auc"].mean().round(3))
Sızıntıyı pipeline ile önlemekYanlış: X_olcekli = StandardScaler().fit_transform(X) deyip sonra cross_val_score(model, X_olcekli, y) çağırmak. Ölçekleyici tüm veriyi, yani doğrulama katlarını da görmüş olur. Doğru: ölçekleyiciyi pipeline'a koy ve pipeline'ı çapraz doğrula. Kural: veriden bir şey öğrenen her adım pipeline'ın içinde olmalı.

Hiperparametre arama

GridSearchCV verdiğin tüm kombinasyonları dener, her biri için çapraz doğrulama yapar ve en iyisini seçer; sonunda en iyi ayarla tüm eğitim verisinde yeniden fit eder (refit=True). Kombinasyon sayısı hızla patlar (4 × 4 × 3 = 48 ayar × 5 kat = 240 eğitim). RandomizedSearchCV ise dağılımlardan n_iter kadar rastgele ayar dener; geniş arama uzaylarında aynı bütçeyle genellikle daha iyi sonuç bulur.

import numpy as np
import pandas as pd


def churn_verisi(n=600, seed=1):
    """Ilgaz Bank kurgusal müşteri kaybı (churn) verisi; eksik değerler içerir."""
    rng = np.random.default_rng(seed)
    yas = rng.integers(18, 75, n).astype(float)
    bakiye = rng.gamma(2, 15000, n)
    urun = rng.integers(1, 5, n)
    sikayet = rng.poisson(0.5, n)
    sehir = rng.choice(["İstanbul", "Ankara", "İzmir", "Bursa"], n)
    segment = rng.choice(["Bireysel", "Premium", "KOBİ"], n, p=[0.6, 0.25, 0.15])
    logit = -1.2 + 0.9 * sikayet - 0.6 * (urun - 2) + 0.03 * (yas - 45) - 0.8 * (segment == "Premium")
    y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    yas[rng.random(n) < 0.08] = np.nan
    bakiye[rng.random(n) < 0.05] = np.nan
    X = pd.DataFrame({"yas": yas, "bakiye": bakiye.round(2), "urun": urun, "sikayet": sikayet,
                      "sehir": sehir, "segment": segment})
    return X, y

from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler


def on_isleyici(sayisal, kategorik):
    sayi_hatti = Pipeline([
        ("doldur", SimpleImputer(strategy="median")),
        ("olcek", StandardScaler()),
    ])
    kat_hatti = Pipeline([
        ("doldur", SimpleImputer(strategy="most_frequent")),
        ("kodla", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
    ])
    return ColumnTransformer([
        ("sayisal", sayi_hatti, sayisal),
        ("kategorik", kat_hatti, kategorik),
    ])

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, StratifiedKFold, train_test_split

X, y = churn_verisi()
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=0, stratify=y)
pipe = Pipeline([
    ("on_isleme", on_isleyici(["yas", "bakiye", "urun", "sikayet"], ["sehir", "segment"])),
    ("model", LogisticRegression(max_iter=1000)),
])
izgara = {
    "model__C": [0.01, 0.1, 1, 10],
    "model__class_weight": [None, "balanced"],
}
arama = GridSearchCV(pipe, izgara, cv=StratifiedKFold(5, shuffle=True, random_state=0), scoring="roc_auc")
arama.fit(X_tr, y_tr)
print("en iyi:", arama.best_params_)
print("CV AUC:", round(arama.best_score_, 3))
print("Test AUC:", round(arama.score(X_te, y_te), 3))   # scoring="roc_auc" ile ölçülür

best_score_ çapraz doğrulama ortalamasıdır, test skoru değildir. Seçim süreci bu skorları kullandığı için hafif iyimserdir; asıl raporlayacağın sayı, aramaya hiç girmemiş test setindeki skordur.

Öğrenme eğrisi mantığı

"Daha fazla veri toplamaya değer mi?" sorusunun cevabı öğrenme eğrisidir (learning curve): eğitim verisi miktarını artırarak eğitim ve doğrulama skorlarını çizersin. İki eğri birbirine yaklaşıp düz bir platoya oturduysa daha çok veri pek yardım etmez; daha zengin özellik ya da daha güçlü model gerekir. Doğrulama skoru hâlâ yükseliyor ve eğitim skoruyla arasında büyük boşluk varsa (yüksek varyans), daha fazla veri işe yarar. sklearn.model_selection.learning_curve bunu çapraz doğrulamayla hesaplar.

import numpy as np
import pandas as pd


def churn_verisi(n=600, seed=1):
    """Ilgaz Bank kurgusal müşteri kaybı (churn) verisi; eksik değerler içerir."""
    rng = np.random.default_rng(seed)
    yas = rng.integers(18, 75, n).astype(float)
    bakiye = rng.gamma(2, 15000, n)
    urun = rng.integers(1, 5, n)
    sikayet = rng.poisson(0.5, n)
    sehir = rng.choice(["İstanbul", "Ankara", "İzmir", "Bursa"], n)
    segment = rng.choice(["Bireysel", "Premium", "KOBİ"], n, p=[0.6, 0.25, 0.15])
    logit = -1.2 + 0.9 * sikayet - 0.6 * (urun - 2) + 0.03 * (yas - 45) - 0.8 * (segment == "Premium")
    y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    yas[rng.random(n) < 0.08] = np.nan
    bakiye[rng.random(n) < 0.05] = np.nan
    X = pd.DataFrame({"yas": yas, "bakiye": bakiye.round(2), "urun": urun, "sikayet": sikayet,
                      "sehir": sehir, "segment": segment})
    return X, y

from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler


def on_isleyici(sayisal, kategorik):
    sayi_hatti = Pipeline([
        ("doldur", SimpleImputer(strategy="median")),
        ("olcek", StandardScaler()),
    ])
    kat_hatti = Pipeline([
        ("doldur", SimpleImputer(strategy="most_frequent")),
        ("kodla", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
    ])
    return ColumnTransformer([
        ("sayisal", sayi_hatti, sayisal),
        ("kategorik", kat_hatti, kategorik),
    ])

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, learning_curve

X, y = churn_verisi()
pipe = Pipeline([("on_isleme", on_isleyici(["yas", "bakiye", "urun", "sikayet"], ["sehir", "segment"])),
                 ("model", LogisticRegression(max_iter=1000))])
boyutlar, egitim, dogrulama = learning_curve(
    pipe, X, y, train_sizes=[0.1, 0.3, 0.6, 1.0],
    cv=StratifiedKFold(5, shuffle=True, random_state=0), scoring="roc_auc",
)
for n, e, d in zip(boyutlar, egitim.mean(axis=1), dogrulama.mean(axis=1)):
    print(f"{n:>4} örnek  eğitim={e:.3f}  doğrulama={d:.3f}")

Modeli kaydetmek

Eğitilmiş pipeline'ı diske yazıp tahmin servisinde yüklersin. scikit-learn için önerilen araç joblib'dir (büyük numpy dizilerinde pickle'dan verimli). Aşağıda dosya yerine bellek içi io.BytesIO kullanıyoruz; gerçek projede joblib.dump(model, "churn_v3.joblib") yazarsın.

import io
import joblib
import pickle
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_iris(return_X_y=True)
pipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)).fit(X, y)

tampon = io.BytesIO()
joblib.dump(pipe, tampon)
print("boyut > 0:", tampon.tell() > 0)
tampon.seek(0)                      # okumadan önce başa sar
yuklenen = joblib.load(tampon)
print((yuklenen.predict(X[:5]) == pipe.predict(X[:5])).all())

veri = pickle.dumps(pipe)           # pickle da çalışır
print(type(pickle.loads(veri)).__name__)
Pickle güvenliği ve sürümPickle/joblib dosyası yüklenirken kod çalıştırabilir: güvenmediğin kaynaktan model dosyası yükleme. Ayrıca kaydedilen model, kaydedildiği scikit-learn sürümüyle yüklenmelidir; farklı sürüm uyarı verir ya da hatalı çalışabilir. Model dosyasının yanına sklearn sürümünü, eğitim tarihini ve özellik listesini mutlaka yaz.

Model kartı ve raporlama

Model kartı (model card), bir modelin kimlik belgesidir: ne için yapıldı, hangi veriyle eğitildi, nasıl ölçüldü, nerede kullanılmamalı. Denetçinin, ürün yöneticisinin ve altı ay sonraki senin ilk okuyacağın belgedir.

Bölümİçerik örneği
Amaç ve kapsam3 ay içinde hesap kapatma riskini tahmin; yalnız bireysel ve KOBİ müşterileri
VeriKaynak tablolar, tarih aralığı, satır sayısı, hedef tanımı, eksik oranları
ModelPipeline adımları, hiperparametreler, sklearn sürümü, eğitim tarihi
DeğerlendirmeCV ortalama ± std, test metrikleri, taban çizgi, seçilen eşik ve gerekçesi
Sınırlar ve risklerYeni şubelerde güven düşük; segment bazında performans farkları; izleme planı
Gerçek işteÜretimdeki ML hatalarının büyük kısmı modelden değil ön işleme farklarından çıkar: veri bilimci not defterinde eksikleri ortalamayla doldurmuş, yazılım ekibi servisi yazarken sıfırla doldurmuştur ("training-serving skew"). Ön işleme ve modeli tek bir Pipeline nesnesinde kaydetmek bu sınıf hataları baştan ortadan kaldırır. Birçok ekip model dosyasını, model kartını ve CV sonuçlarını aynı sürüm etiketiyle bir model kayıt sistemine (MLflow gibi) yükler.
Derinleş

Derinleş: modeli açıklamak, adil olup olmadığını ölçmek, aramayı hızlandırmak

Skor tablosu "model ne kadar iyi?" sorusunu cevaplar; iş ekibi ise "neden bu kişiyi riskli buldu, kimde yanılıyor, herkese eşit davranıyor mu?" diye sorar. Bu bölümde Pusula İK'nın gerçek çalışan verisiyle (pusula_ik, 2199 çalışan) gönüllü ayrılma modeli kuracak, sonra onu açıklayacak ve denetleyeceksin. Etiket: term_type == "gönüllü" (yaklaşık %27). Cinsiyet modele özellik olarak verilmiyor; yalnızca adalet denetiminde kullanılıyor.

import veri
import pandas as pd

def ik_verisi():
    e = veri.oku("pusula_ik", "ik_employees")
    p = veri.oku("pusula_ik", "ik_positions")
    d = e.merge(p[["position_id", "level"]], on="position_id")
    d["ise_giris_yasi"] = (pd.to_datetime(d["hire_date"]).dt.year - d["birth_year"]).astype(float)
    d["level"] = d["level"].astype(float)
    y = (d["term_type"] == "gönüllü").astype(int)
    X = d[["ise_giris_yasi", "level", "education", "city", "work_mode", "hire_source"]]
    return X, y, d["gender"]

from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression

X, y, cinsiyet = ik_verisi()
X_tr, X_te, y_tr, y_te, g_tr, g_te = train_test_split(
    X, y, cinsiyet, test_size=0.3, random_state=0, stratify=y)
kategorik = ["education", "city", "work_mode", "hire_source"]
on_isleme = ColumnTransformer(
    [("kat", OneHotEncoder(handle_unknown="ignore"), kategorik)], remainder="passthrough")
model = Pipeline([("on", on_isleme), ("model", LogisticRegression(max_iter=1000))])
model.fit(X_tr, y_tr)
from sklearn.metrics import roc_auc_score
print("test ROC-AUC:", round(roc_auc_score(y_te, model.predict_proba(X_te)[:, 1]), 3))

AUC'nin 0.6 civarında olması gerçekçi: işe girişte bilinen birkaç özellik, kimin ayrılacağını ancak kısmen açıklar. Böyle bir modeli canlıya almadan önce aşağıdaki dört soruyu sormak zorundasın.

1) Hangi özellik önemli? Permutation importance

Fikir: bir sütunu karıştır (satırlar arasında rastgele yer değiştir), modeli yeniden eğitmeden skoru ölç. Skor ne kadar düşerse model o sütuna o kadar dayanıyordur. Ağaçların feature_importances_ değerinden farkı: test verisinde ve seçtiğin metrikle ölçülür, her model türünde çalışır ve çok değerli (yüksek kardinaliteli) sütunlara karşı yanlı değildir. Pipeline'a ham sütunları verdiğin için önem de ham sütun düzeyinde çıkar.

from sklearn.inspection import permutation_importance

r = permutation_importance(model, X_te, y_te, scoring="roc_auc", n_repeats=5, random_state=0)
onem = pd.Series(r.importances_mean, index=X_te.columns).sort_values(ascending=False)
print(onem.round(4))
İlişkili sütun tuzağıİki sütun aynı bilgiyi taşıyorsa (pozisyon seviyesi ve maaş bandı gibi) birini karıştırınca model diğerinden telafi eder ve ikisi de "önemsiz" görünür. Önem değeri nedensellik de değildir: "seviyeyi yükseltirsek çalışan kalır" sonucu buradan çıkmaz.

2) Etki hangi yönde? PDP ve ICE

Kısmi bağımlılık (partial dependence, PDP) tek bir özelliği sırayla her değere sabitler, tüm satırlar için tahmin alır ve ortalamasını çizer: "diğer her şey veri gibiyken seviye 1'den 6'ya çıkınca ortalama ayrılma olasılığı nasıl değişir?" ICE (individual conditional expectation) aynı hesabı ortalama almadan, her satır için ayrı çizgi olarak gösterir; çizgiler birbirine paralel değilse etkileşim vardır.

from sklearn.inspection import partial_dependence

pdp = partial_dependence(model, X_tr, ["level"], kind="both")
print("ızgara:", pdp["grid_values"][0])
print("PDP   :", pdp["average"][0].round(3))
print("ICE boyutu:", pdp["individual"].shape)    # (çıktı, satır, ızgara)

Grafiği PartialDependenceDisplay.from_estimator(model, X_tr, ["level"], kind="both") ile çizersin. Tamsayı sütunlar için scikit-learn uyarı verir; sütunu önceden float yapmamızın nedeni bu. SHAP değerleri ise her tahmini özellik katkılarına böler ("bu çalışanın riskini en çok seviyesi artırdı"); pip install shap ile kendi bilgisayarında dene:

import shap
ozellik = model[:-1].transform(X_te)
aciklayici = shap.LinearExplainer(model[-1], ozellik)
degerler = aciklayici(ozellik)
shap.plots.beeswarm(degerler)        # genel tablo
shap.plots.waterfall(degerler[0])    # tek çalışan için açıklama

3) Kimde yanılıyor? Hata dilimi analizi

Ortalama hata, küçük bir segmentte çok kötü çalışmayı gizler. Test tahminlerini segmentlere (şehir, çalışma biçimi, işe alım kaynağı) göre grupla; her segment için satır sayısı ve hata oranı çıkar, en kötüden iyiye sırala. Çok küçük segmentleri (ör. 30 satırdan az) ayrı işaretle; orada gördüğün fark gürültü olabilir.

tahmin = (model.predict_proba(X_te)[:, 1] >= 0.3).astype(int)
dilim = X_te.assign(hata=(tahmin != y_te.to_numpy()))
tablo = dilim.groupby("city")["hata"].agg(n="size", hata_orani="mean")
print(tablo.sort_values("hata_orani", ascending=False).round(3))

4) Herkese eşit mi? Grup bazında metrikler

MetrikGrup başına hesapİK örneğinde soru
Seçim oranı (demographic parity)tahmin = 1 oranıKadınların ne kadarı "riskli" işaretleniyor?
FPR (yanlış alarm)FP / (FP + TN)Kalacak çalışanların ne kadarına gereksiz müdahale ediliyor?
FNR (kaçırma, equal opportunity)FN / (FN + TP)Ayrılacak çalışanların ne kadarı gözden kaçıyor?
def oranlar(s):
    fp = ((s.tahmin == 1) & (s.y == 0)).sum(); tn = ((s.tahmin == 0) & (s.y == 0)).sum()
    fn = ((s.tahmin == 0) & (s.y == 1)).sum(); tp = ((s.tahmin == 1) & (s.y == 1)).sum()
    return pd.Series({"secim": s.tahmin.mean(), "fpr": fp / (fp + tn), "fnr": fn / (fn + tp)})

grup = pd.DataFrame({"y": y_te.to_numpy(), "tahmin": tahmin, "cinsiyet": g_te.to_numpy()})
print(grup.groupby("cinsiyet")[["y", "tahmin"]].apply(oranlar).round(3))

Cinsiyeti modelden çıkarmak adaleti garanti etmez: şehir, eğitim ya da pozisyon seviyesi cinsiyetle ilişkiliyse (vekil değişken, proxy) model farkı yine öğrenir. Bu yüzden ölçmeden "adil" denmez. Farkı gördüğünde seçeneklerin: eşiği gruplara göre değil süreç düzeyinde gözden geçirmek, veriyi incelemek, sonucu model kartına yazmak ve kararı insan incelemesine bağlamak. Bu bulguları dersin önceki bölümündeki model kartına "Açıklanabilirlik" ve "Adalet denetimi" başlıklarıyla ekle.

Gerçek işteİK, kredi ve sağlık gibi alanlarda model kararları kişileri doğrudan etkiler; KVKK ve kurum içi etik kurullar "bu kararı nasıl verdiniz?" diye sorar. Ekipler bu yüzden her sürümde PDP, permutation importance, dilim tablosu ve grup metriklerini otomatik rapora koyar. Fairlearn kütüphanesi (MetricFrame) bu tabloyu tek satırda üretir.

Arama bütçesi: Halving, Optuna ve erken durdurma

GridSearchCV her adayı tüm veriyle eğitir. HalvingGridSearchCV ardışık yarılama (successive halving) yapar: ilk turda tüm adaylar az kaynakla (az satırla) yarışır, en iyi 1/factor kadarı bir sonraki tura daha çok kaynakla geçer. Kötü adaylara bütçe harcanmaz.

from sklearn.experimental import enable_halving_search_cv  # deneysel API'yi açar
from sklearn.model_selection import HalvingGridSearchCV

izgara = {"model__C": [0.1, 1.0], "model__class_weight": [None, "balanced"]}
arama = HalvingGridSearchCV(model, izgara, factor=2, scoring="roc_auc", cv=3, random_state=0)
arama.fit(X_tr, y_tr)
print("aday sayıları :", list(arama.n_candidates_))
print("kaynak (satır):", list(arama.n_resources_))
print("en iyi        :", arama.best_params_)

Izgara büyüdükçe rastgele ya da Bayesçi arama daha verimlidir. Optuna önceki denemelerin sonucuna bakarak umut verici bölgeden yeni değer önerir ve kötü giden denemeleri erkenden budar (pip install optuna):

import optuna
from sklearn.model_selection import cross_val_score

def amac(trial):
    C = trial.suggest_float("C", 1e-3, 100, log=True)
    agirlik = trial.suggest_categorical("class_weight", [None, "balanced"])
    model.set_params(model__C=C, model__class_weight=agirlik)
    return cross_val_score(model, X_tr, y_tr, cv=3, scoring="roc_auc").mean()

calisma = optuna.create_study(direction="maximize")
calisma.optimize(amac, n_trials=40, timeout=120)   # bütçe: deneme sayısı ve süre
print(calisma.best_params)

Erken durdurma (early stopping) ise tek bir modelin içinde bütçe tasarrufudur: boosting ya da sinir ağı her turda doğrulama kaybını ölçer, n_iter_no_change tur boyunca iyileşme yoksa durur (HistGradientBoosting'de early_stopping=True). Pratik kural: arama bütçesini (deneme sayısı × kat sayısı × eğitim süresi) önceden yaz, kaba aramayla başla, iyi bölgeyi daralt.

Uzmanlaş

İç içe çapraz doğrulama

Hem model seçip hem de seçimin performansını yansız tahmin etmek istiyorsan iç içe CV (nested cross-validation) kullan: dış döngü performansı ölçer, her dış katın içinde ayrı bir GridSearchCV hiperparametre seçer. Kod olarak tek satırdır: cross_val_score(GridSearchCV(pipe, izgara, cv=ic_cv), X, y, cv=dis_cv). Küçük veri setlerinde raporlanan skorun güvenilirliğini ciddi artırır.

pandas çıktısı ve özellik adları

pipe.set_output(transform="pandas") ile dönüştürücüler numpy yerine sütun adlı DataFrame döndürür (OneHotEncoder'da sparse_output=False gerekir). Hata ayıklarken ve katsayıları özellik adlarıyla eşlerken çok işe yarar: pipe.named_steps["on_isleme"].get_feature_names_out() ile pipe.named_steps["model"].coef_ yan yana konunca hangi şehrin ya da segmentin kayıp riskini artırdığı tek tabloda görünür.

Kendi dönüştürücün

İşe özgü bir özellik (ör. bakiye / ürün sayısı) gerekiyorsa FunctionTransformer ile bir fonksiyonu pipeline adımına çevirebilir ya da BaseEstimator ve TransformerMixin'den türeyen bir sınıf yazabilirsin. Kural aynı: veriden öğrenilen her şey fit'te, uygulanan her şey transform'da.

Alıştırmalara geç (28)