Ana içeriğe geç
Kesit Python
İleri · İleri bitirme

Bitirme projesi 2: Ilgaz Bank müşteri kaybı (churn) tahmini

Sentetik banka verisi üretip keşfeder, özellik mühendisliği yapar, ColumnTransformer'lı pipeline'ı çapraz doğrulamayla değerlendirir, iş maliyetine göre eşik seçer, modeli açıklar, f-string ile yönetim raporu yazar ve modeli BytesIO ile kaydedip yüklersin.

Uygulamada aç, 30 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ç.

Proje: Ilgaz Bank müşteri kaybı tahmini

Ilgaz Bank'ın bireysel bankacılık ekibi her ay bazı müşterilerin hesabını kapatıp başka bankaya geçtiğini görüyor. Yeni müşteri kazanmak, mevcut müşteriyi tutmaktan çok daha pahalı. Soru şu: "Önümüzdeki dönemde hangi müşteriler ayrılmaya (churn) yakın, kime tutundurma teklifi yapalım?" Bu bitirme projesinde makine öğrenmesi derslerinde (Makine öğrenmesine giriş, Regresyon ve sınıflandırma modelleri, Ön işleme, pipeline ve model değerlendirme) öğrendiğin her şeyi tek bir iş problemi etrafında birleştireceksin: veri, keşif, özellik mühendisliği (feature engineering), pipeline, çapraz doğrulama (cross-validation), eşik seçimi, açıklama, rapor ve model dosyası.

AdımSoruAraçÇıktı
1. VeriElimizde ne var?numpy default_rng(seed)veri_uret
2. KeşifChurn oranı ne, kimde yüksek?pandas groupbyOran tabloları
3. ÖzelliklerHangi türetilmiş bilgi işe yarar?pandasozellik_ekle
4. PipelineHazırlık + model tek nesnedeColumnTransformer, Pipelinepipeline_kur
5. DoğrulamaModel ne kadar iyi?StratifiedKFold, ROC AUCCV skoru
6. EşikKime teklif gidecek?Maliyet fonksiyonuEn ucuz eşik
7. AçıklamaNeden ayrılıyorlar?KatsayılarÖzellik önemi
8. Rapor ve teslimYönetime ne söyleyeceğiz?f-string, pickle + BytesIORapor metni, model dosyası
Her kutu bağımsızSayfadaki her kod kutusu kendi başına çalışır; bu yüzden veri üretimi gibi parçalar kutularda tekrar eder. Gerçek projede bunlar veri.py, ozellik.py, model.py, rapor.py modüllerinde bir kez yazılır.

1. Sentetik veri üretimi

Gerçek müşteri verisi bu derste kullanılamaz (KVKK), o yüzden gerçekçi ilişkiler taşıyan sentetik veri üretiyoruz: şikayet sayısı arttıkça ayrılma olasılığı artar; ürün sayısı, kıdem, işlem sıklığı ve mobil uygulama kullanımı ise müşteriyi bağlar. Churn, bu etkilerden hesaplanan olasılıkla rastgele çekilir; böylece model mükemmel değil, gerçek hayattaki gibi "iyi ama hatalı" olur. seed sabitlendiği için herkes aynı veriyi alır.

import numpy as np
import pandas as pd

SEHIRLER = ["İstanbul", "Ankara", "İzmir", "Bursa"]

def veri_uret(n=1000, seed=42):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "yas": rng.integers(18, 75, n),
        "kidem_ay": rng.integers(1, 120, n),
        "urun_sayisi": rng.integers(1, 5, n),
        "aylik_islem": rng.poisson(12, n),
        "bakiye": np.round(rng.gamma(2.0, 15000, n), 2),
        "sehir": rng.choice(SEHIRLER, n, p=[0.4, 0.25, 0.2, 0.15]),
        "mobil_aktif": rng.integers(0, 2, n),
        "sikayet_sayisi": rng.poisson(0.6, n),
    })
    z = (-0.6 + 1.1 * df["sikayet_sayisi"] - 0.5 * (df["urun_sayisi"] - 1)
         - 0.015 * df["kidem_ay"] - 0.06 * (df["aylik_islem"] - 12) - 0.9 * df["mobil_aktif"])
    olasilik = 1 / (1 + np.exp(-z))
    df["churn"] = (rng.random(n) < olasilik).astype(int)
    return df

df = veri_uret()
print(df.shape)
print(df.head(3).to_string())
print("churn oranı:", round(df["churn"].mean(), 3))
Sentetik veri kendi cevabını bilirFormülü biz yazdığımız için modelin "şikayet sayısı önemli" demesi bizi şaşırtmaz. Bu bir öğrenme düzeneğidir; gerçek projede ilişkileri veri söyler ve sürprizler olur.

2. Keşifsel analiz ve 3. özellik mühendisliği

Modelden önce veriye bak: churn oranı ne (sınıf dengesizliği var mı), hangi gruplarda yüksek? Sonra ham sütunlardan iş anlamı olan yeni sütunlar türet. Kural: her özellik tahmin anında bilinebilir olmalı. "Hesap kapanış tarihi" ya da "kapanış nedeni" gibi churn'den sonra oluşan bilgiler modele girerse sahte bir başarı elde edersin (veri sızıntısı, data leakage).

import numpy as np
import pandas as pd

SEHIRLER = ["İstanbul", "Ankara", "İzmir", "Bursa"]

def veri_uret(n=1000, seed=42):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "yas": rng.integers(18, 75, n),
        "kidem_ay": rng.integers(1, 120, n),
        "urun_sayisi": rng.integers(1, 5, n),
        "aylik_islem": rng.poisson(12, n),
        "bakiye": np.round(rng.gamma(2.0, 15000, n), 2),
        "sehir": rng.choice(SEHIRLER, n, p=[0.4, 0.25, 0.2, 0.15]),
        "mobil_aktif": rng.integers(0, 2, n),
        "sikayet_sayisi": rng.poisson(0.6, n),
    })
    z = (-0.6 + 1.1 * df["sikayet_sayisi"] - 0.5 * (df["urun_sayisi"] - 1)
         - 0.015 * df["kidem_ay"] - 0.06 * (df["aylik_islem"] - 12) - 0.9 * df["mobil_aktif"])
    olasilik = 1 / (1 + np.exp(-z))
    df["churn"] = (rng.random(n) < olasilik).astype(int)
    return df
def ozellik_ekle(df):
    df = df.copy()
    df["islem_urun_orani"] = df["aylik_islem"] / df["urun_sayisi"]
    df["yeni_musteri"] = (df["kidem_ay"] < 12).astype(int)
    df["bakiye_log"] = np.log1p(df["bakiye"])
    return df

df = ozellik_ekle(veri_uret())
print("Genel churn:", round(df["churn"].mean(), 3))
print(df.groupby("sikayet_sayisi")["churn"].agg(["mean", "size"]).round(3).head(4))
print(df.groupby("mobil_aktif")["churn"].mean().round(3).to_dict())
df["kidem_grubu"] = pd.cut(df["kidem_ay"], [0, 12, 36, 120], labels=["<1 yıl", "1-3 yıl", "3+ yıl"])
print(df.groupby("kidem_grubu", observed=True)["churn"].mean().round(3).to_dict())
print(df[["aylik_islem", "urun_sayisi", "islem_urun_orani", "yeni_musteri", "bakiye_log"]].head(2).round(2).to_string())

Şikayet sayısı arttıkça churn oranı belirgin biçimde artıyor, mobil uygulama kullananlar daha az ayrılıyor, 3 yıldan uzun süredir müşteri olanlar daha sadık. Churn oranı %18 civarında: dengesiz bir sınıf, bu yüzden doğruluk (accuracy) yerine ROC AUC ve maliyet odaklı değerlendirme kullanacağız.

ÖzellikFormülİş anlamı
islem_urun_oraniaylık işlem / ürün sayısıÜrün başına kullanım yoğunluğu
yeni_musterikıdem < 12 ayİlk yıl kritik dönem
bakiye_loglog(1 + bakiye)Çarpık dağılımı yumuşatır

4. Pipeline ve 5. çapraz doğrulama

Sayısal sütunlar ölçeklenir, şehir one-hot kodlanır, sonra lojistik regresyon. Hepsi tek Pipeline içinde olduğu için çapraz doğrulamada her katta hazırlık yalnızca eğitim kısmına fit edilir ve yeni müşteriye tahmin yaparken aynı adımlar otomatik uygulanır. handle_unknown="ignore", eğitimde görülmemiş bir şehir (ör. "Antalya") geldiğinde hata yerine sıfır vektör üretir. StratifiedKFold her katta churn oranını korur; skorun ortalaması kadar dağılımı (±) da önemlidir.

6. Eşik seçimi ve iş maliyeti

Model bir olasılık verir; "teklif gönder" kararı için bir eşik gerekir. Varsayılan 0.5 bir iş kararı değil, bir varsayımdır. Hataların maliyeti eşit değildir:

FN daha pahalıysa eşik düşer: daha çok kişiye teklif gönderip daha az kişiyi kaçırırız. Rakamlar örnektir; gerçek değerleri finans ekibiyle belirlersin.

import numpy as np
import pandas as pd

SEHIRLER = ["İstanbul", "Ankara", "İzmir", "Bursa"]

def veri_uret(n=1000, seed=42):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "yas": rng.integers(18, 75, n),
        "kidem_ay": rng.integers(1, 120, n),
        "urun_sayisi": rng.integers(1, 5, n),
        "aylik_islem": rng.poisson(12, n),
        "bakiye": np.round(rng.gamma(2.0, 15000, n), 2),
        "sehir": rng.choice(SEHIRLER, n, p=[0.4, 0.25, 0.2, 0.15]),
        "mobil_aktif": rng.integers(0, 2, n),
        "sikayet_sayisi": rng.poisson(0.6, n),
    })
    z = (-0.6 + 1.1 * df["sikayet_sayisi"] - 0.5 * (df["urun_sayisi"] - 1)
         - 0.015 * df["kidem_ay"] - 0.06 * (df["aylik_islem"] - 12) - 0.9 * df["mobil_aktif"])
    olasilik = 1 / (1 + np.exp(-z))
    df["churn"] = (rng.random(n) < olasilik).astype(int)
    return df
def ozellik_ekle(df):
    df = df.copy()
    df["islem_urun_orani"] = df["aylik_islem"] / df["urun_sayisi"]
    df["yeni_musteri"] = (df["kidem_ay"] < 12).astype(int)
    df["bakiye_log"] = np.log1p(df["bakiye"])
    return df
SAYISAL = ["yas", "kidem_ay", "urun_sayisi", "aylik_islem", "bakiye_log", "mobil_aktif",
           "sikayet_sayisi", "islem_urun_orani", "yeni_musteri"]
KATEGORIK = ["sehir"]
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression

def pipeline_kur(sayisal, kategorik):
    hazirlik = ColumnTransformer([
        ("sayi", StandardScaler(), sayisal),
        ("kat", OneHotEncoder(handle_unknown="ignore"), kategorik),
    ])
    return Pipeline([("hazirlik", hazirlik), ("model", LogisticRegression(max_iter=1000))])

from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.metrics import roc_auc_score

df = ozellik_ekle(veri_uret())
X, y = df[SAYISAL + KATEGORIK], df["churn"].to_numpy()
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
skorlar = cross_val_score(pipeline_kur(SAYISAL, KATEGORIK), X, y, cv=cv, scoring="roc_auc")
print(f"CV ROC AUC: {skorlar.mean():.3f} ± {skorlar.std():.3f}")

X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, stratify=y, random_state=0)
olas = pipeline_kur(SAYISAL, KATEGORIK).fit(X_tr, y_tr).predict_proba(X_te)[:, 1]

def is_maliyeti(y_gercek, olas, esik, fp_maliyet=300, fn_maliyet=1500):
    tahmin = olas >= esik
    fp = int((tahmin & (y_gercek == 0)).sum())
    fn = int((~tahmin & (y_gercek == 1)).sum())
    return fp * fp_maliyet + fn * fn_maliyet

for e in [0.1, 0.2, 0.3, 0.5, 0.7]:
    print(f"eşik {e:.1f}: {is_maliyeti(y_te, olas, e):>7,} TL")
print("hiç teklif yok:", is_maliyeti(y_te, olas, 1.01))

Tablo, 0.5 eşiğinin bu maliyet yapısında pahalı olduğunu, daha düşük bir eşiğin binlerce lira tasarruf ettirdiğini gösterir. "Hiç teklif yok" satırı da modelin iş değerini ölçmek için temel çizgidir.

Eşiği test setinde seçmeEn iyi eşiği test setinde arayıp aynı sette raporlarsan iyimser bir rakam verirsin. Doğrusu, eşiği eğitim verisinden (ör. çapraz doğrulama tahminleriyle, cross_val_predict) seçip test setinde bir kez raporlamaktır. Bitirme çözümünde böyle yapacağız.

7. Model açıklama: özellik önemi

Yönetim "neden?" diye soracak. Lojistik regresyonda ölçeklenmiş sütunların katsayıları, o özelliğin bir standart sapma artışının churn log-olasılığını ne kadar değiştirdiğini söyler: pozitif katsayı riski artırır, negatif azaltır. pipe.named_steps["hazirlik"].get_feature_names_out() ColumnTransformer'ın ürettiği sütun adlarını (sayi__sikayet_sayisi, kat__sehir_İzmir) verir; bunları pipe.named_steps["model"].coef_[0] ile eşleştirip mutlak değere göre sıralarsın. Tam çözümde bu tabloyu göreceksin. Ağaç modellerinde feature_importances_, model türünden bağımsız olarak da permutation_importance kullanılır. Unutma: katsayı nedensellik değil ilişkidir; "mobil uygulamayı zorunlu yapalım, churn düşsün" sonucu bir deney (A/B testi) olmadan çıkarılamaz.

8. İş raporu ve modeli kaydetmek

Rapor, teknik olmayan biri için yazılır: kaç müşteriye teklif, beklenen tasarruf, ana nedenler. Şablonla rapor üretme: string.Template ve Jinja2 dersindeki jinja2 burada gerekmez; düz f-string yeter. Model ise pickle ile bayta çevrilip dosyaya, nesne deposuna ya da veritabanına yazılır. Tarayıcıda disk yerine bellekte bir dosya gibi davranan io.BytesIO kullanıyoruz.

import numpy as np
import pandas as pd

SEHIRLER = ["İstanbul", "Ankara", "İzmir", "Bursa"]

def veri_uret(n=1000, seed=42):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "yas": rng.integers(18, 75, n),
        "kidem_ay": rng.integers(1, 120, n),
        "urun_sayisi": rng.integers(1, 5, n),
        "aylik_islem": rng.poisson(12, n),
        "bakiye": np.round(rng.gamma(2.0, 15000, n), 2),
        "sehir": rng.choice(SEHIRLER, n, p=[0.4, 0.25, 0.2, 0.15]),
        "mobil_aktif": rng.integers(0, 2, n),
        "sikayet_sayisi": rng.poisson(0.6, n),
    })
    z = (-0.6 + 1.1 * df["sikayet_sayisi"] - 0.5 * (df["urun_sayisi"] - 1)
         - 0.015 * df["kidem_ay"] - 0.06 * (df["aylik_islem"] - 12) - 0.9 * df["mobil_aktif"])
    olasilik = 1 / (1 + np.exp(-z))
    df["churn"] = (rng.random(n) < olasilik).astype(int)
    return df
def ozellik_ekle(df):
    df = df.copy()
    df["islem_urun_orani"] = df["aylik_islem"] / df["urun_sayisi"]
    df["yeni_musteri"] = (df["kidem_ay"] < 12).astype(int)
    df["bakiye_log"] = np.log1p(df["bakiye"])
    return df
SAYISAL = ["yas", "kidem_ay", "urun_sayisi", "aylik_islem", "bakiye_log", "mobil_aktif",
           "sikayet_sayisi", "islem_urun_orani", "yeni_musteri"]
KATEGORIK = ["sehir"]
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression

def pipeline_kur(sayisal, kategorik):
    hazirlik = ColumnTransformer([
        ("sayi", StandardScaler(), sayisal),
        ("kat", OneHotEncoder(handle_unknown="ignore"), kategorik),
    ])
    return Pipeline([("hazirlik", hazirlik), ("model", LogisticRegression(max_iter=1000))])

import io
import pickle

df = ozellik_ekle(veri_uret())
X = df[SAYISAL + KATEGORIK]
pipe = pipeline_kur(SAYISAL, KATEGORIK).fit(X, df["churn"])

tampon = io.BytesIO()
pickle.dump(pipe, tampon)
print("model boyutu:", len(tampon.getvalue()), "bayt")

tampon.seek(0)
yuklenen = pickle.load(tampon)
print("aynı tahmin:", (yuklenen.predict_proba(X)[:, 1] == pipe.predict_proba(X)[:, 1]).all())

yeni = pd.DataFrame([{"yas": 34, "kidem_ay": 5, "urun_sayisi": 1, "aylik_islem": 3, "bakiye": 1200.0,
                      "sehir": "Antalya", "mobil_aktif": 0, "sikayet_sayisi": 3}])
print("yeni müşteri churn olasılığı:", round(float(yuklenen.predict_proba(ozellik_ekle(yeni)[SAYISAL + KATEGORIK])[0, 1]), 2))
pickle güvenliği ve sürümlerpickle.load güvenilmeyen bir dosyayı açarsa keyfi kod çalıştırabilir; yalnızca kendi ürettiğin modelleri yükle. Model, kaydedildiği scikit-learn sürümüyle yüklenmelidir; sürümü requirements.txt'e sabitle. Büyük modellerde joblib.dump daha verimlidir.

Tam çözüm

Aşağıdaki kutu tüm adımları birleştirir: eşik, eğitim verisinde çapraz doğrulama tahminleriyle seçilir, test setinde bir kez raporlanır.

import numpy as np
import pandas as pd

SEHIRLER = ["İstanbul", "Ankara", "İzmir", "Bursa"]

def veri_uret(n=1000, seed=42):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "yas": rng.integers(18, 75, n),
        "kidem_ay": rng.integers(1, 120, n),
        "urun_sayisi": rng.integers(1, 5, n),
        "aylik_islem": rng.poisson(12, n),
        "bakiye": np.round(rng.gamma(2.0, 15000, n), 2),
        "sehir": rng.choice(SEHIRLER, n, p=[0.4, 0.25, 0.2, 0.15]),
        "mobil_aktif": rng.integers(0, 2, n),
        "sikayet_sayisi": rng.poisson(0.6, n),
    })
    z = (-0.6 + 1.1 * df["sikayet_sayisi"] - 0.5 * (df["urun_sayisi"] - 1)
         - 0.015 * df["kidem_ay"] - 0.06 * (df["aylik_islem"] - 12) - 0.9 * df["mobil_aktif"])
    olasilik = 1 / (1 + np.exp(-z))
    df["churn"] = (rng.random(n) < olasilik).astype(int)
    return df
def ozellik_ekle(df):
    df = df.copy()
    df["islem_urun_orani"] = df["aylik_islem"] / df["urun_sayisi"]
    df["yeni_musteri"] = (df["kidem_ay"] < 12).astype(int)
    df["bakiye_log"] = np.log1p(df["bakiye"])
    return df
SAYISAL = ["yas", "kidem_ay", "urun_sayisi", "aylik_islem", "bakiye_log", "mobil_aktif",
           "sikayet_sayisi", "islem_urun_orani", "yeni_musteri"]
KATEGORIK = ["sehir"]
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression

def pipeline_kur(sayisal, kategorik):
    hazirlik = ColumnTransformer([
        ("sayi", StandardScaler(), sayisal),
        ("kat", OneHotEncoder(handle_unknown="ignore"), kategorik),
    ])
    return Pipeline([("hazirlik", hazirlik), ("model", LogisticRegression(max_iter=1000))])

from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_predict
from sklearn.metrics import roc_auc_score

FP_TL, FN_TL = 300, 1500

def is_maliyeti(y, olas, esik):
    t = olas >= esik
    return int(((t == 1) & (y == 0)).sum()) * FP_TL + int(((t == 0) & (y == 1)).sum()) * FN_TL

df = ozellik_ekle(veri_uret())
X, y = df[SAYISAL + KATEGORIK], df["churn"].to_numpy()
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, stratify=y, random_state=0)
pipe = pipeline_kur(SAYISAL, KATEGORIK)

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
oof = cross_val_predict(pipe, X_tr, y_tr, cv=cv, method="predict_proba")[:, 1]
esikler = [round(e, 2) for e in np.arange(0.05, 0.95, 0.05)]
esik = min(esikler, key=lambda e: (is_maliyeti(y_tr, oof, e), e))

pipe.fit(X_tr, y_tr)
olas = pipe.predict_proba(X_te)[:, 1]
model_maliyet = is_maliyeti(y_te, olas, esik)
bos_maliyet = is_maliyeti(y_te, olas, 1.01)
adlar = [a.split("__", 1)[1] for a in pipe.named_steps["hazirlik"].get_feature_names_out()]
katsayi = pipe.named_steps["model"].coef_[0]
sira = np.argsort(-np.abs(katsayi))
ilk3 = [adlar[i] for i in sira[:3]]
print("özellik önemi:", {adlar[i]: round(float(katsayi[i]), 2) for i in sira[:5]})

rapor = (
    f"ILGAZ BANK | Müşteri kaybı modeli raporu\n"
    f"Test müşteri sayısı: {len(y_te)} (gerçek churn oranı %{100 * y_te.mean():.1f})\n"
    f"ROC AUC: {roc_auc_score(y_te, olas):.3f}\n"
    f"Seçilen eşik: {esik:.2f} -> teklif gönderilecek: {int((olas >= esik).sum())} müşteri\n"
    f"Maliyet: model ile {model_maliyet:,} TL, modelsiz {bos_maliyet:,} TL\n"
    f"Tahmini tasarruf: {bos_maliyet - model_maliyet:,} TL\n"
    f"Riski en çok etkileyen özellikler: {', '.join(ilk3)}"
)
print(rapor)
Gerçek işteBankalarda churn modeli genelde aylık çalışan bir toplu işin (batch) parçasıdır: veri ambarından son 6-12 ayın davranış özellikleri çekilir, model skorları üretilir, en riskli müşteriler CRM'e "tutundurma listesi" olarak düşer ve şube/çağrı merkezi aksiyon alır. Model, kampanya grubu ile kontrol grubu karşılaştırılarak (A/B testi) değerlendirilir; çünkü asıl soru "kimin gideceğini bildik mi" değil, "teklif gerçekten fark yarattı mı"dır. Ayrıca model risk yönetimi ekipleri modelin açıklanabilirliğini, adil olup olmadığını (ör. yaş gruplarına göre hata oranları) ve veri kaymasını (data drift) düzenli denetler.
Derinleş

Derinleş: sentetik veriden gerçek bankaya

Projenin ilk hâlinde churn verisini kendin ürettin; etiketi de sen yazdığın için model doğal olarak iyi çıktı. Gerçek işte etiket hazır gelmez, onu tanımlarsın. Bu bölümde aynı hattı ilgaz_bank setiyle kuracağız: bank_accounts (849 hesap, closed_date kapanış tarihi), bank_customers (500 müşteri) ve bank_txns (2024 yılına ait 42.284 işlem). Sonuçlar sentetik veridekinden çok daha mütevazı çıkacak ve bu bir başarısızlık değil, gerçekçiliğin kendisi.

1. Etiketi tanımlamak: anlık görüntü ve ufuk

Manifestte closed_date "Kapanış tarihi (açıksa boş)" olarak tanımlı ve status = "kapali" ile birebir örtüşüyor. 2024 boyunca 42 müşteri 43 hesap kapatmış. Churn tanımımız: kesim tarihinde (snapshot) en az bir açık hesabı olan müşteri, sonraki 60 gün içinde herhangi bir hesabını kapatırsa churn = 1. Kesim tarihi, modelin canlıda skor üreteceği günü taklit eder: o güne kadar olan her şey özellik, sonrası etikettir.

import veri
import numpy as np
import pandas as pd

A = veri.oku("ilgaz_bank", "bank_accounts", parse_dates=["opened_date", "closed_date"])
C = veri.oku("ilgaz_bank", "bank_customers", parse_dates=["joined_date"])
TX = veri.oku("ilgaz_bank", "bank_txns", usecols=["account_id", "txn_time", "amount", "txn_type"],
              parse_dates=["txn_time"]).merge(A[["account_id", "customer_id"]], on="account_id")

def etiket_tablosu(snap, ufuk=60):
    s = pd.Timestamp(snap)
    acik = A[(A["opened_date"] <= s) & (A["closed_date"].isna() | (A["closed_date"] > s))]
    kapanan = acik[(acik["closed_date"] > s) & (acik["closed_date"] <= s + pd.Timedelta(days=ufuk))]
    e = pd.DataFrame({"customer_id": sorted(acik["customer_id"].unique())})
    e["churn"] = e["customer_id"].isin(kapanan["customer_id"]).astype(int)
    e["snap"] = s
    return e

for sn in ["2024-04-01", "2024-07-01", "2024-10-01"]:
    e = etiket_tablosu(sn)
    print(sn, len(e), "müşteri,", int(e["churn"].sum()), "churn")

Her kesimde yalnızca 5–8 pozitif örnek var. Tek bir kesimle model kurulamaz; bu yüzden aylık kesimleri üst üste koyarız (Nisan–Temmuz eğitim, Ağustos–Ekim test). Bir müşteri birden çok kesimde yer alır; bu yüzden bölme rastgele değil zamana göre yapılır, aksi hâlde aynı müşterinin komşu ayları eğitim ve teste dağılıp sızıntı yaratır.

2. 90 günlük pencere özellikleri

Özellikler yalnızca kesimden önceki 90 günün işlemlerinden hesaplanır: işlem sayısı (90 ve 30 gün), maaş yatışı sayısı, son işlemden bu yana geçen gün ve ivme (son 30 günün, 90 günlük aylık ortalamaya oranı). Sınır koşulu kritiktir: txn_time < snap. Bir gün bile fazlası geleceği sızdırır.

import veri
import numpy as np
import pandas as pd

A = veri.oku("ilgaz_bank", "bank_accounts", parse_dates=["opened_date", "closed_date"])
C = veri.oku("ilgaz_bank", "bank_customers", parse_dates=["joined_date"])
TX = veri.oku("ilgaz_bank", "bank_txns", usecols=["account_id", "txn_time", "amount", "txn_type"],
              parse_dates=["txn_time"]).merge(A[["account_id", "customer_id"]], on="account_id")

def ozellikler(snap, gun=90):
    s = pd.Timestamp(snap)
    t = TX[(TX["txn_time"] < s) & (TX["txn_time"] >= s - pd.Timedelta(days=gun))]
    son30 = t[t["txn_time"] >= s - pd.Timedelta(days=30)]
    g = pd.DataFrame({
        "islem_90": t.groupby("customer_id").size(),
        "islem_30": son30.groupby("customer_id").size(),
        "maas_90": t[t["txn_type"] == "maas"].groupby("customer_id").size(),
        "son": t.groupby("customer_id")["txn_time"].max(),
    }).fillna({"islem_30": 0, "maas_90": 0})
    g["son_islem_gun"] = (s - g["son"]).dt.days
    g["ivme"] = g["islem_30"] / (g["islem_90"] / 3)
    return g.drop(columns="son")

f = ozellikler("2024-07-01")
print(len(f), "müşterinin 90 günde işlemi var")
print(f.loc[[1, 41]].round(2))
Pencerenin başı da önemlibank_txns 1 Ocak 2024'te başlıyor. 1 Mart kesiminde 90 günlük pencerenin bir kısmı veri öncesine düşer; işlem sayıları düşük, ivme yapay olarak yüksek çıkar. İlk geçerli kesim 1 Nisan'dır. Aşağıdaki PSI hesabı bu hatayı nasıl yakalayacağını gösteriyor.

3. Model ve dürüst değerlendirme

Derste kurduğun ColumnTransformer + LogisticRegression(class_weight="balanced") hattını aynen kullan. Zaman bölmeli testte ROC AUC yaklaşık 0,64, en riskli %10'luk dilimde churn oranı ortalamanın yaklaşık 2,8 katı. Sentetik verideki 0,8+ AUC'ye göre düşük, ama 26 pozitif örnekle eğitilen bir model için anlamlı bir sinyal. Raporda bu sayıyı güven aralığı ya da "pozitif sayısı az" notuyla verirsin; kampanya ekibi için önemli olan lift'tir.

4. Servisleme: FastAPI ile skor uç noktası

Eğitilen hattı joblib ile kaydedip küçük bir API arkasına koyarsın. Tarayıcıda FastAPI çalışmaz; kod gösterim amaçlıdır.

# servis.py  (pip install fastapi uvicorn joblib scikit-learn pandas)
import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel

model = joblib.load("churn_model.joblib")
app = FastAPI(title="Ilgaz Bank churn")

class Musteri(BaseModel):
    islem_90: float
    islem_30: float
    maas_90: float
    son_islem_gun: float
    ivme: float
    risk_score: int
    kidem_yil: float
    segment: str

@app.post("/skor")
def skor(m: Musteri):
    olasilik = model.predict_proba(pd.DataFrame([m.model_dump()]))[0, 1]
    return {"churn_olasiligi": round(float(olasilik), 4)}
# Çalıştır: uvicorn servis:app --reload  ->  http://127.0.0.1:8000/docs
Kendi bilgisayarında deneKomut: pip install fastapi uvicorn joblib. Modeli eğittiğin defterde joblib.dump(pipe, "churn_model.joblib") çalıştır, sonra uvicorn servis:app --reload. Ayrıntılar FastAPI dersinde.

5. İzleme: PSI ile veri kayması

Etiket 60 gün sonra gelir; o zamana kadar modelin bozulduğunu girdilere bakarak anlarsın. PSI (population stability index) eğitim dağılımını referans alıp kutulara (genellikle 10 kantil) böler ve yeni dönemin aynı kutulardaki payıyla karşılaştırır: PSI = Σ (yeni − ref) · ln(yeni / ref). Yaygın eşikler: < 0,1 kararlı, 0,1–0,25 izle, > 0,25 alarm. Aynı hesap model skorlarına da uygulanır.

import veri
import numpy as np
import pandas as pd

A = veri.oku("ilgaz_bank", "bank_accounts", parse_dates=["opened_date", "closed_date"])
C = veri.oku("ilgaz_bank", "bank_customers", parse_dates=["joined_date"])
TX = veri.oku("ilgaz_bank", "bank_txns", usecols=["account_id", "txn_time", "amount", "txn_type"],
              parse_dates=["txn_time"]).merge(A[["account_id", "customer_id"]], on="account_id")

def etiket_tablosu(snap, ufuk=60):
    s = pd.Timestamp(snap)
    acik = A[(A["opened_date"] <= s) & (A["closed_date"].isna() | (A["closed_date"] > s))]
    kapanan = acik[(acik["closed_date"] > s) & (acik["closed_date"] <= s + pd.Timedelta(days=ufuk))]
    e = pd.DataFrame({"customer_id": sorted(acik["customer_id"].unique())})
    e["churn"] = e["customer_id"].isin(kapanan["customer_id"]).astype(int)
    e["snap"] = s
    return e

def ozellikler(snap, gun=90):
    s = pd.Timestamp(snap)
    t = TX[(TX["txn_time"] < s) & (TX["txn_time"] >= s - pd.Timedelta(days=gun))]
    son30 = t[t["txn_time"] >= s - pd.Timedelta(days=30)]
    g = pd.DataFrame({
        "islem_90": t.groupby("customer_id").size(),
        "islem_30": son30.groupby("customer_id").size(),
        "maas_90": t[t["txn_type"] == "maas"].groupby("customer_id").size(),
        "son": t.groupby("customer_id")["txn_time"].max(),
    }).fillna({"islem_30": 0, "maas_90": 0})
    g["son_islem_gun"] = (s - g["son"]).dt.days
    g["ivme"] = g["islem_30"] / (g["islem_90"] / 3)
    return g.drop(columns="son")

def veri_seti(snaplar):
    parcalar = []
    for sn in snaplar:
        d = etiket_tablosu(sn).merge(ozellikler(sn), on="customer_id", how="left")
        d = d.merge(C[["customer_id", "segment", "risk_score", "joined_date"]], on="customer_id")
        d["kidem_yil"] = (d["snap"] - d["joined_date"]).dt.days / 365.25
        d = d.fillna({"islem_90": 0, "islem_30": 0, "maas_90": 0, "son_islem_gun": 90, "ivme": 0})
        parcalar.append(d.drop(columns="joined_date"))
    return pd.concat(parcalar, ignore_index=True)

def psi(beklenen, gercek, kutu=10):
    beklenen = np.asarray(beklenen, dtype=float)
    gercek = np.asarray(gercek, dtype=float)
    kenar = np.unique(np.quantile(beklenen, np.linspace(0, 1, kutu + 1)))
    kenar[0], kenar[-1] = -np.inf, np.inf
    pb = np.histogram(beklenen, kenar)[0] / len(beklenen)
    pg = np.histogram(gercek, kenar)[0] / len(gercek)
    pb, pg = np.clip(pb, 1e-4, None), np.clip(pg, 1e-4, None)
    return float(np.sum((pg - pb) * np.log(pg / pb)))

egitim = veri_seti(pd.date_range("2024-04-01", "2024-07-01", freq="MS"))
for ad, yeni in [("Aralık", veri_seti(["2024-12-01"])), ("Mart (eksik pencere)", veri_seti(["2024-03-01"]))]:
    print(ad, {c: round(psi(egitim[c], yeni[c]), 3) for c in ["islem_90", "islem_30", "ivme"]})

Aralık kesiminde tüm PSI değerleri 0,03'ün altında: model güvenle skorlamaya devam edebilir. Mart kesiminde ivme için PSI 4'ün üstünde: bu bir pazar değişimi değil, veri hattı hatasıdır (pencere eksik). İzleme, modelin kendisinden çok veri hattını korur.

Uzmanlaş

Daha iyi model ve kalibrasyon

Lojistik regresyon güçlü ve açıklanabilir bir temel çizgidir. HistGradientBoostingClassifier gibi ağaç tabanlı modeller etkileşimleri kendiliğinden yakalayıp AUC'yi artırabilir. Maliyet hesabı olasılıklara dayandığı için olasılıkların kalibre olması (0.3 diyen müşterilerin gerçekten ~%30'unun ayrılması) önemlidir; CalibratedClassifierCV ve kalibrasyon eğrisi (calibration_curve) ile kontrol et.

Hedef: kim ayrılacak değil, kim ikna edilebilir

Churn modeli riskli müşteriyi bulur ama teklife kimin olumlu tepki vereceğini söylemez. Bir sonraki adım "uplift modeling"dir: kampanya ve kontrol grubu verisiyle, teklifin ayrılma olasılığını en çok düşürdüğü müşterileri hedeflemek.

Üretime alma

Modeli bir paket fonksiyonu (skorla(df) -> olasılıklar) arkasına koy, girdi şemasını pydantic ya da pandera ile doğrula, model sürümünü ve eğitim tarihini meta veri olarak kaydet, aylık dağılım değişimlerini izle (ör. churn oranı, özellik ortalamaları). Veri boru hattı (ETL, data pipeline): çıkar, dönüştür, yükle ve İş akışı orkestrasyonu: cron, DAG ve Airflow dersleri bu kısmı ele alır.

Alıştırmalara geç (30)