Bagian 2 menutup dengan garis dasar yang harus dikalahkan: model mayoritas mencetak akurasi 0,728 dengan recall nol. Pada bagian ini kita melatih model sungguhan dan menghadapi ketimpangan kelas secara langsung. Tiga konfigurasi LogisticRegression dibangun di atas preprocessing yang sama: satu tanpa penanganan apa pun sebagai pembanding, satu dengan class_weight="balanced", dan satu dengan SMOTE yang ditaruh di dalam Pipeline milik imbalanced-learn. Ketiganya dievaluasi dengan akurasi, presisi, recall, dan PR-AUC pada data uji yang sama.
Tiga Strategi Menghadapi Ketimpangan #
Sebelum menulis kode, mari kita petakan pilihan yang tersedia. Strategi penanganan imbalance terbagi dalam dua keluarga: mengubah bobot dalam loss function, atau mengubah komposisi data latih.
| Strategi | Mekanisme | Menyentuh data latih | Menyentuh data uji | Peran di proyek ini |
|---|---|---|---|---|
| Tanpa penanganan | loss mengikuti frekuensi kelas asli | tidak | tidak | pembanding dasar |
class_weight="balanced" | kelas minoritas diberi bobot lebih besar dalam loss | tidak | tidak | kandidat |
| SMOTE | sampel churn sintetis disisipkan antar tetangga terdekat | ya | tidak | kandidat |
| Undersampling mayoritas | baris kelas mayoritas dibuang | ya | tidak | tidak dipakai — data latih menyusut |
| SMOTENC | SMOTE yang menjaga fitur kategorikal asli | ya | tidak | alternatif, dibahas di akhir |
Keluarga bobot murah dan aman: class_weight="balanced" menyuruh model membayar kesalahan pada kelas churn delapan per tiga lebih mahal (proporsi 274 berbanding 101 pada data latih), tanpa mengubah satu baris data pun. Keluarga resampling mengubah data latih secara fisik; SMOTE memilih pasangan churner terdekat lalu menyisipkan titik baru di sepanjang garis penghubungnya. Keduanya diuji berdampingan pada bagian ini karena keduanya punya cara gagal yang berbeda — dan hasilnya akan kita baca tanpa prasangka.
SMOTE Hanya pada Data Latih #
Bagian 2 menetapkan aturan mutlak: resampling tidak boleh menyentuh data uji. Skrip pada bagian ini menegakkannya lewat struktur, bukan lewat kedisiplinan. Pipeline dari imblearn (bukan sklearn) menempatkan SMOTE sebagai langkah di antara preprocessing dan model: saat fit() dipanggil, pipeline menjalankan imputasi-penskalaan-encoding pada data latih, mensintesis sampel churn baru, lalu melatih model pada data yang sudah seimbang. Saat predict() atau predict_proba() dipanggil, jalur resampling dilewati sepenuhnya — prediksi selalu berjalan pada data asli apa adanya.
Blok pembuka skrip membuktikan hal itu dengan angka: sebelum SMOTE data latih berisi 274 pelanggan bertahan dan 101 churn; sesudah SMOTE kedua kelas berisi 274; data uji tetap 91 berbanding 34 dan tidak pernah diresample.
# Model dan penanganan imbalance: bandingkan tiga konfigurasi
# LogisticRegression pada data churn yang timpang (27% kelas churn).
# 1. tanpa penanganan -> Pipeline sklearn
# 2. class_weight='balanced' -> Pipeline sklearn
# 3. SMOTE di dalam pipeline -> Pipeline imblearn (resampling HANYA
# pada data latih saat fit; data uji tidak pernah disentuh SMOTE)
# Jalankan dari root proyek: python3 src/04_model_imbalance.py
import pandas as pd
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
accuracy_score,
average_precision_score,
precision_score,
recall_score,
)
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
df = pd.read_csv("data/churn.csv")
X = df.drop(columns="churn")
y = df["churn"]
X_latih, X_uji, y_latih, y_uji = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42
)
fitur_numerik = ["usia", "tenure_bulan", "tagihan_bulanan_ribu",
"komplain_12bulan"]
fitur_kategorikal = ["layanan"]
def bangun_pra():
"""ColumnTransformer baru untuk tiap pipeline agar status fit tidak
dibagikan antar konfigurasi."""
return ColumnTransformer(
transformers=[
(
"angka",
Pipeline(
steps=[
("imputasi", SimpleImputer(strategy="median")),
("skala", StandardScaler()),
]
),
fitur_numerik,
),
(
"kategori",
OneHotEncoder(handle_unknown="ignore"),
fitur_kategorikal,
),
]
)
def bangun_logreg(berat_kelas=None):
return LogisticRegression(
max_iter=1000, class_weight=berat_kelas, random_state=42
)
# ---------- Bukti SMOTE hanya bekerja pada data latih ----------
pra_demo = bangun_pra()
X_latih_tr = pra_demo.fit_transform(X_latih)
smote = SMOTE(random_state=42)
X_seimbang, y_seimbang = smote.fit_resample(X_latih_tr, y_latih)
print("== SMOTE diterapkan pada DATA LATIH saja ==")
print(f"sebelum SMOTE : {int((y_latih == 0).sum())} bertahan, "
f"{int((y_latih == 1).sum())} churn")
print(f"sesudah SMOTE : {int((y_seimbang == 0).sum())} bertahan, "
f"{int((y_seimbang == 1).sum())} churn")
print(f"data uji tetap: {int((y_uji == 0).sum())} bertahan, "
f"{int((y_uji == 1).sum())} churn (tidak pernah diresample)")
# ---------- Tiga konfigurasi model ----------
konfigurasi = {
"tanpa penanganan": Pipeline(
steps=[("pra", bangun_pra()), ("model", bangun_logreg())]
),
"class_weight=balanced": Pipeline(
steps=[
("pra", bangun_pra()),
("model", bangun_logreg(berat_kelas="balanced")),
]
),
"SMOTE (pipeline imblearn)": ImbPipeline(
steps=[
("pra", bangun_pra()),
("smote", SMOTE(random_state=42)),
("model", bangun_logreg()),
]
),
}
hasil = {}
for nama, pipeline in konfigurasi.items():
pipeline.fit(X_latih, y_latih)
pred = pipeline.predict(X_uji)
skor = pipeline.predict_proba(X_uji)[:, 1]
hasil[nama] = {
"akurasi": accuracy_score(y_uji, pred),
"presisi": precision_score(y_uji, pred, zero_division=0),
"recall": recall_score(y_uji, pred),
"pr_auc": average_precision_score(y_uji, skor),
"tp": int(((pred == 1) & (y_uji == 1)).sum()),
"fp": int(((pred == 1) & (y_uji == 0)).sum()),
"fn": int(((pred == 0) & (y_uji == 1)).sum()),
"tn": int(((pred == 0) & (y_uji == 0)).sum()),
}
print("\n== Perbandingan pada data uji (125 pelanggan, 34 churn) ==")
print(f"{'model':26s} {'akurasi':>8s} {'presisi':>8s} "
f"{'recall':>8s} {'PR-AUC':>8s}")
for nama, m in hasil.items():
print(f"{nama:26s} {m['akurasi']:8.3f} {m['presisi']:8.3f} "
f"{m['recall']:8.3f} {m['pr_auc']:8.3f}")
print("\n== Confusion matrix tiap konfigurasi ==")
print(f"{'model':26s} {'TP':>3s} {'FP':>3s} {'FN':>3s} {'TN':>3s}")
for nama, m in hasil.items():
print(f"{nama:26s} {m['tp']:3d} {m['fp']:3d} {m['fn']:3d} {m['tn']:3d}")Output yang Dihasilkan:
== SMOTE diterapkan pada DATA LATIH saja ==
sebelum SMOTE : 274 bertahan, 101 churn
sesudah SMOTE : 274 bertahan, 274 churn
data uji tetap: 91 bertahan, 34 churn (tidak pernah diresample)
== Perbandingan pada data uji (125 pelanggan, 34 churn) ==
model akurasi presisi recall PR-AUC
tanpa penanganan 0.792 0.722 0.382 0.631
class_weight=balanced 0.728 0.500 0.706 0.627
SMOTE (pipeline imblearn) 0.736 0.511 0.706 0.636
== Confusion matrix tiap konfigurasi ==
model TP FP FN TN
tanpa penanganan 13 5 21 86
class_weight=balanced 24 24 10 67
SMOTE (pipeline imblearn) 24 23 10 68Mari kita bedah skrip model dan penanganan imbalance:
- Baris 29–31 (
train_test_split): pembagian identik dengan Bagian 2 —stratify=y,random_state=42,test_size=0.25. Kesamaan ini bukan kebetulan; seluruh proyek membandingkan model pada potongan data yang persis sama. - Baris 38–59 (
bangun_pra): fungsi pabrik yang mengembalikanColumnTransformerbaru setiap kali dipanggil. Tanpa fungsi ini, ketiga pipeline berbagi satu objek preprocessing yang statusnya saling ditimpa; dengan fungsi ini, tiap konfigurasi melatih statusnya sendiri. - Baris 62–65 (
bangun_logreg): pembungkusLogisticRegression(max_iter=1000, class_weight=..., random_state=42). Parametermax_iter=1000memberi ruang konvergensi setelah fitur terskala;random_state=42membekukan perilaku bawaan solver. - Baris 69–79 (bukti resampling): sebuah
ColumnTransformerdemo di-fit pada data latih, hasilnya diumpankan keSMOTE(random_state=42).fit_resample. Tiga baris cetakan menunjukkan 274/101 menjadi 274/274 sementara data uji tidak berubah. - Baris 82–99 (
konfigurasi): kamus berisi tiga pipeline — duaPipelinesklearn dan satuImbPipelineimblearn yang menyisipkan langkah("smote", SMOTE(random_state=42))sebelum model. - Baris 102–115 (loop evaluasi): tiap pipeline di-fit pada data latih lalu memprediksi data uji. Empat metrik simpan (akurasi, presisi, recall, PR-AUC dari
average_precision_scoreatas probabilitas) ditambah empat sel confusion matrix dihitung dari prediksi keras. - Baris 117–127 (cetakan): dua tabel — perbandingan metrik dan confusion matrix — dicetak berjajar agar ketiga konfigurasi mudah dibaca bersama.
Membaca Hasil Perbandingan #
Keluaran memperlihatkan pola khas penanganan imbalance. Model tanpa penanganan memang memimpin akurasi (0,792) dan presisi (0,722), tetapi hanya menangkap 13 dari 34 churner — recall 0,382, hampir dua dari tiga pelanggan berisiko luput. Kedua penanganan membalik gambaran itu: class_weight="balanced" dan SMOTE sama-sama menaikkan recall ke 0,706 (24 dari 34 churner tertangkap, tersisa 10) dengan harga presisi turun ke sekitar 0,50 — dari enam penawaran retensi, kira-kira tiga tepat sasaran. Akurasi turun ke 0,728 dan 0,736, nyaris menyamai baseline mayoritas; penurunan ini wajar dan bukan kemunduran, karena model kini berani menebak “churn”.
Pada PR-AUC — metrik yang mengukur kualitas ranking tanpa bergantung ambang — SMOTE mencatat angka terbaik (0,636), disusul model tanpa penanganan (0,631) dan class_weight="balanced" (0,627). Ketiganya berdekatan karena ketiganya memakai model dan preprocessing yang sama; pembeda utamanya ada pada distribusi probabilitas, bukan urutan pelanggan. Konfigurasi SMOTE-lah yang dibawa ke Bagian 4: PR-AUC tertinggi dan recall tinggi sekaligus.
Model terbaik sudah terpilih, tetapi seluruh perbandingan di bagian ini memakai ambang bawaan 0,50 — angka yang tidak punya alasan bisnis apa pun. Pertanyaan penutup proyek menunggu pada Bagian 4: Evaluasi Bisnis — ambang berapa yang meminimalkan total biaya ketika satu penawaran retensi berharga Rp500.000 dan satu pelanggan yang hilang berharga Rp4.000.000?
Referensi Resmi #
- imblearn.over_sampling.SMOTE — API
— parameter
random_statedan mekanisme interpolasi tetangga. - Common pitfalls and recommended practices — imbalanced-learn — mengapa resampling hanya pada data latih.
- sklearn.linear_model.LogisticRegression — API
—
class_weight="balanced". - Classification metrics — scikit-learn User Guide — presisi, recall, dan average precision.