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ım | Soru | Araç | Çıktı |
|---|---|---|---|
| 1. Veri | Elimizde ne var? | numpy default_rng(seed) | veri_uret |
| 2. Keşif | Churn oranı ne, kimde yüksek? | pandas groupby | Oran tabloları |
| 3. Özellikler | Hangi türetilmiş bilgi işe yarar? | pandas | ozellik_ekle |
| 4. Pipeline | Hazırlık + model tek nesnede | ColumnTransformer, Pipeline | pipeline_kur |
| 5. Doğrulama | Model ne kadar iyi? | StratifiedKFold, ROC AUC | CV skoru |
| 6. Eşik | Kime teklif gidecek? | Maliyet fonksiyonu | En ucuz eşik |
| 7. Açıklama | Neden ayrılıyorlar? | Katsayılar | Özellik önemi |
| 8. Rapor ve teslim | Yönetime ne söyleyeceğiz? | f-string, pickle + BytesIO | Rapor metni, model dosyası |
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))
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.
| Özellik | Formül | İş anlamı |
|---|---|---|
islem_urun_orani | aylık işlem / ürün sayısı | Ürün başına kullanım yoğunluğu |
yeni_musteri | kıdem < 12 ay | İlk yıl kritik dönem |
bakiye_log | log(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:
- Yanlış pozitif (FP): kalacak müşteriye teklif gönderdik; maliyet = teklif bedeli (ör. 300 TL).
- Yanlış negatif (FN): gidecek müşteriyi kaçırdık; maliyet = müşterinin bırakacağı değer (ör. 1.500 TL).
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.
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.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)
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))
bank_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
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.
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.