Müşteri kaybı tahmini: kim gidecek, kime yatırım yapmalısın?
Telco Customer Churn · 18 dakika okuma · Python + LightGBM + maliyet matrisi
Yeni bir müşteri kazanmak, mevcut bir müşteriyi elde tutmaktan 5–7 kat daha pahalı. Bu rakam pazarlama literatüründe o kadar tekrarlandı ki klişe oldu — ama veri bilimi perspektifinden bakıldığında somut bir optimizasyon problemi demek.
Problem şu: müşteri ayrılmadan önce kim ayrılacak? Doğru kişiye doğru zamanda müdahale etmek için bir sınıflandırma modeli yetmez. Sınıf dengesizliğini, eşik seçimini ve maliyet-fayda dengesini de doğru kurman gerekir.
Veri seti: Telco Customer Churn
IBM'in hazırladığı, Kaggle'da yaygın kullanılan klasik dataset. 7.043 müşteri, 20 özellik, hedef değişken: Churn (Yes/No). Churn oranı ~%26 — dengesiz ama yönetilebilir.
import pandas as pd
import numpy as np
import lightgbm as lgb
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import (roc_auc_score, average_precision_score,
classification_report, confusion_matrix)
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")
# Kaggle'dan veya IBM örnek verilerinden indirilebilir
df = pd.read_csv("WA_Fn-UseC_-Telco-Customer-Churn.csv")
print(df.shape) # (7043, 21)
print(df["Churn"].value_counts(normalize=True).round(3))
# No 0.734
# Yes 0.266 ← yaklaşık 1:3 oranıKeşifsel analiz: kim ayrılıyor?
Modele girmeden önce veriye bakalım. Churn davranışını en iyi ayıran üç değişken genellikle şunlardır:
# Sayısal değişkenlerin churn'e göre dağılımı
sayisal = ["tenure", "MonthlyCharges", "TotalCharges"]
df["TotalCharges"] = pd.to_numeric(df["TotalCharges"], errors="coerce")
df["TotalCharges"].fillna(df["TotalCharges"].median(), inplace=True)
ozet = df.groupby("Churn")[sayisal].median()
print(ozet)
# tenure MonthlyCharges TotalCharges
# Churn
# No 38.0 64.4 1683.0
# Yes 10.0 79.7 703.8
# Bulgular:
# → Ayrılanlar ortalama 10 ay müşteri (kalanlar 38 ay)
# → Ayrılanların aylık ücreti daha yüksek (79 vs 64 TL)
# → Toplam ödeme düşük — kısa süre, yüksek fiyat = riskli profil# Kategorik değişkenlerde churn oranı
for col in ["Contract", "InternetService", "PaymentMethod"]:
tablo = df.groupby(col)["Churn"].apply(
lambda x: (x == "Yes").mean()
).sort_values(ascending=False)
print(f"
{col}:")
print(tablo.round(3))
# Contract:
# Month-to-month 0.427 ← aydan aya kontrat çok riskli
# One year 0.113
# Two year 0.028
# InternetService:
# Fiber optic 0.419 ← fiber kullanıcıları daha fazla ayrılıyor
# DSL 0.190
# No 0.074Aydan aya sözleşmeli, fiber internet kullanan, yeni müşteri — bu üç özellik bir arada churn olasılığını %40'ın üzerine taşıyor. Model kurmadan bile bu profili bilmek kampanya tasarımını değiştirir.
Hazırlık ve feature engineering
# Hedef encode
df["Churn"] = (df["Churn"] == "Yes").astype(int)
df.drop("customerID", axis=1, inplace=True)
# Kategorikleri encode et
kat_sutunlar = df.select_dtypes(include="object").columns.tolist()
le = LabelEncoder()
for col in kat_sutunlar:
df[col] = le.fit_transform(df[col])
# Yeni özellikler
df["ucret_suresi_orani"] = df["MonthlyCharges"] / (df["tenure"] + 1)
df["ortalama_aylik_odeme"] = df["TotalCharges"] / (df["tenure"] + 1)
df["kisa_sozlesme"] = (df["Contract"] == 0).astype(int) # month-to-month
X = df.drop("Churn", axis=1)
y = df["Churn"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)Neden accuracy yanıltır?
Sınıf dengesizliği olan problemlerde accuracy metriği tehlikeli. "Kimse ayrılmıyor" diyen bir model bile %73 accuracy alır. Gerçek metrikler AUC-ROC ve Average Precision (PR-AUC).
model = lgb.LGBMClassifier(
n_estimators=500,
learning_rate=0.03,
num_leaves=31,
scale_pos_weight=3, # ← dengesizliği telafi et (neg/pos oranı)
random_state=42,
verbose=-1
)
model.fit(X_train, y_train,
eval_set=[(X_test, y_test)],
callbacks=[lgb.early_stopping(50, verbose=False)])
y_prob = model.predict_proba(X_test)[:, 1]
print(f"Accuracy (0.5 eşiği): {(model.predict(X_test) == y_test).mean():.3f}")
print(f"ROC-AUC: {roc_auc_score(y_test, y_prob):.3f}")
print(f"PR-AUC: {average_precision_score(y_test, y_prob):.3f}")
# Accuracy: 0.807 ← yanıltıcı
# ROC-AUC: 0.851 ← gerçek ayırt etme gücü
# PR-AUC: 0.657 ← pozitif sınıftaki hassasiyetEşik optimizasyonu: 0.5 neden yanlış seçim?
Model varsayılan olarak 0.5 eşiği kullanır: olasılık > 0.5 ise "churn" de. Ama iş problemi simetrikteyse nadiren 0.5 doğru eşiktir.
Şunu düşün: bir müşteriyi yanlış "churn edecek" diye işaretlersen gereksiz bir indirim kodu gönderirsin — maliyeti küçük. Ama churn edecek birini kaçırırsan müşteriyi tamamen kaybedersin — maliyeti büyük. Bu asimetri eşiği aşağı çekmeyi gerektirir.
from sklearn.metrics import f1_score, precision_recall_curve
# F1 maksimizasyonu
esikler = np.arange(0.1, 0.9, 0.01)
f1_skorlari = [f1_score(y_test, y_prob >= e) for e in esikler]
en_iyi_esik = esikler[np.argmax(f1_skorlari)]
print(f"F1 maksimum eşiği: {en_iyi_esik:.2f}") # ~0.36
# Maliyet matrisi yaklaşımı
# FN maliyeti (kaçırılan churn): 500 TL (müşteri yaşam boyu değeri kaybı)
# FP maliyeti (gereksiz kampanya): 20 TL (indirim kuponu)
fn_maliyet = 500
fp_maliyet = 20
maliyetler = []
for e in esikler:
tahmin = (y_prob >= e).astype(int)
cm = confusion_matrix(y_test, tahmin)
tn, fp, fn, tp = cm.ravel()
toplam = fp * fp_maliyet + fn * fn_maliyet
maliyetler.append(toplam)
optimal_esik = esikler[np.argmin(maliyetler)]
print(f"Maliyet-optimal eşik: {optimal_esik:.2f}") # ~0.28# Optimal eşik ile classification report y_pred_opt = (y_prob >= optimal_esik).astype(int) print(classification_report(y_test, y_pred_opt, target_names=["Kalıyor", "Ayrılıyor"])) # precision recall f1-score # Kalıyor 0.93 0.82 0.87 # Ayrılıyor 0.58 0.80 0.67 ← recall yüksek, churn'ü kaçırmıyoruz
Özellik önemi: neden ayrılıyorlar?
import shap
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)
shap.summary_plot(shap_values, X_test, max_display=12, show=False)
plt.tight_layout()
plt.savefig("churn_shap.png", dpi=150, bbox_inches="tight")Tipik bulgular:
- tenure — En güçlü koruyucu faktör. Müşteri ne kadar uzun süredir varsa ayrılma olasılığı o kadar düşük. İlk 12 ay kritik pencere.
- Contract — Aydan aya sözleşme, churn olasılığını ciddi artırıyor. Yıllık sözleşmeye geçiş teşviki en etkili strateji.
- MonthlyCharges — Yüksek aylık ücret + kısa tenure = tehlikeli kombinasyon.
- ucret_suresi_orani — Türettiğimiz özellik beklenmedik kadar önemli çıktı. "Kısa sürede çok ödeme" sinyali modeli güçlendirdi.
- InternetService (Fiber) — Fiber müşterileri daha pahalı paket alıyor ve daha fazla rakip seçeneği var; churn riski yüksek.
Müşteri segmentleri ve aksiyon
Model skoru üzerinden üç aksiyon segmenti tanımla. Herkese kampanya göndermek hem bütçe israfı hem müşteriyi rahatsız eder.
# Segment etiketleri
df_test = X_test.copy()
df_test["churn_olasiligi"] = y_prob
df_test["gercek"] = y_test.values
def segment_ata(p):
if p >= 0.70: return "Kritik Risk" # derhal müdahale
if p >= 0.35: return "Orta Risk" # hedefli kampanya
return "Düşük Risk" # periyodik takip
df_test["segment"] = df_test["churn_olasiligi"].apply(segment_ata)
print(df_test["segment"].value_counts())
# Düşük Risk 876
# Orta Risk 228
# Kritik Risk 305# Her segment için farklı aksiyon
AKSIYONLAR = {
"Kritik Risk": "Önce ara, kişisel teklif sun (aylık %20 indirim veya ücretsiz upgrade)",
"Orta Risk": "Otomatik e-posta serisi: sadakat puanı, uzun dönem sözleşme avantajları",
"Düşük Risk": "Aylık bülten yeterli, kaynak harcama",
}
for segment, aksiyon in AKSIYONLAR.items():
musteri_sayisi = (df_test["segment"] == segment).sum()
ort_olasilik = df_test[df_test["segment"] == segment]["churn_olasiligi"].mean()
print(f"{segment}: {musteri_sayisi} müşteri, ort. olas. {ort_olasilik:.2f}")
print(f" → {aksiyon}
")İş etkisi: model olmadan vs modelle
Somut hesap yapalım. 1.409 test müşterisi, %26 gerçek churn oranı (~366 kişi). Ortalama müşteri yaşam boyu değeri: 1.500 TL.
# Senaryo karşılaştırması
toplam_musteri = len(y_test)
gercek_churn = y_test.sum() # 366
ltv = 1500 # TL
kampanya_maliyeti = 20 # kişi başı TL
# Senaryo A: kimseye müdahale yok
kayip_A = gercek_churn * ltv
print(f"Senaryo A (müdahalesiz): {kayip_A:,.0f} TL kayıp")
# → 549.000 TL
# Senaryo B: herkese kampanya (kör atış)
yakalanan_B = int(gercek_churn * 0.35) # ortalama %35 kampanya etkinliği
kurtarilan_B = yakalanan_B * ltv
harcanan_B = toplam_musteri * kampanya_maliyeti
net_B = kurtarilan_B - harcanan_B
print(f"Senaryo B (herkese): net +{net_B:,.0f} TL")
# → net +100.000 TL (harcama yüksek, isabetsiz tetiklemeler çok)
# Senaryo C: model ile kritik+orta risk segmenti
hedef_C = df_test[df_test["segment"] != "Düşük Risk"]
gercek_churn_C = (hedef_C["gercek"] == 1).sum()
yakalanan_C = int(gercek_churn_C * 0.35)
kurtarilan_C = yakalanan_C * ltv
harcanan_C = len(hedef_C) * kampanya_maliyeti
net_C = kurtarilan_C - harcanan_C
print(f"Senaryo C (model ile): net +{net_C:,.0f} TL")
# → net +163.000 TL (daha az harcama, daha yüksek isabet)Model olmadan herkese kampanya göndermek, modelle hedefli göndermekten ~%60 daha az net kazanç sağlıyor. Modelin değeri sadece tahmin doğruluğu değil, kaynakların doğru yere yönlendirilmesi.
Dikkat edilmesi gereken tuzaklar
- Data leakage."TotalCharges" gibi değişkenler tenure'ın doğrudan türevi. Modelde birlikte kullanmak sızdırma riski taşıyabilir. Gerçek uygulamada feature korelasyon matrisini mutlaka kontrol et.
- Zamana göre bölme. Random split yerine tarih bazlı train/test bölmesi daha gerçekçi. Gelecekteki müşterileri geçmiş veriyle tahmin edersin — karışık split gelecekten veri sızdırabilir.
- Kampanya etkisi geri bildirimi. Müdahale ettiğin müşteri ayrılmadıysa bu modelin başarısı mı, yoksa zaten ayrılmayacak mıydı? A/B testi yapılmadan gerçek kampanya etkisini ölçemezsin.
- Model çürümesi. Churn davranışı aylara, fiyatlara, rakip tekliflere göre değişir. Modeli en az çeyrek yılda bir yeniden eğitmek gerekir.
Üretim pipeline'ı için hızlı şablon
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OrdinalEncoder
import joblib
sayisal_sutunlar = ["tenure", "MonthlyCharges", "TotalCharges",
"ucret_suresi_orani", "ortalama_aylik_odeme"]
kategorik_sutunlar = [c for c in X.columns
if c not in sayisal_sutunlar]
onisleme = ColumnTransformer([
("num", StandardScaler(), sayisal_sutunlar),
("cat", OrdinalEncoder(handle_unknown="use_encoded_value",
unknown_value=-1), kategorik_sutunlar),
])
pipeline = Pipeline([
("onisleme", onisleme),
("model", lgb.LGBMClassifier(
n_estimators=500,
scale_pos_weight=3,
random_state=42,
verbose=-1
))
])
pipeline.fit(X_train, y_train)
joblib.dump(pipeline, "churn_model_v1.pkl")
# Yükleme ve tahmin
model_yuklu = joblib.load("churn_model_v1.pkl")
yeni_prob = model_yuklu.predict_proba(yeni_musteriler)[:, 1]Sonuç
Churn tahmini pratikte üç katmanlı bir problemdir:
- Modelleme katmanı — dengesiz veri, doğru metrik, erken durdurma
- Karar katmanı — eşik seçimi, maliyet matrisi, segment tanımı
- İş katmanı — hangi segmente ne kadar bütçe, nasıl ölçüm
Sadece modeli kurup bırakmak, iş etkisinin ancak küçük bir kısmını yaratır. Eşik optimizasyonu ve segment bazlı aksiyon planı olmadan iyi bir modelkötü bir kampanyaya dönüşebilir.
Kaynaklar: Telco Customer Churn (Kaggle) · LightGBM dokümantasyonu · SHAP dokümantasyonu