Pelanggan yang berhenti berlangganan adalah kejadian yang mahal sekaligus langka: dari 40 pelanggan Toko Kopi Kita mungkin hanya delapan yang benar-benar pergi. Kelangkaan semacam ini membuat model klasifikasi kehilangan semangat belajar — kesalahan pada delapan pelanggan yang pergi nyaris tidak terasa ketika 32 pelanggan yang bertahan sudah menyumbang 80% kebenaran. Imbalanced learning adalah keluarga teknik untuk menghadapi data timpang semacam itu. Chapter ini berfokus pada cara menangani ketimpangan — resampling manual, SMOTE, dan class weight — sedangkan cara menilai model pada data timpang (precision, recall, hingga confusion matrix) sudah kita bahas tuntas pada chapter Evaluasi Model pada Data Imbalanced .
Tiga Jalur Penanganan Ketimpangan #
Semua teknik pada chapter ini mengejar satu tujuan yang sama: membuat kelas minoritas tidak tenggelam saat model belajar. Jalurnya berbeda-beda:
| Teknik | Cara kerja | Keuntungan | Risiko |
|---|---|---|---|
| Undersampling | Buang baris kelas mayoritas sampai seimbang | Cepat, data latih mengecil | Baris mayoritas yang dibuang bisa memuat informasi |
| Oversampling manual | Duplikasi baris kelas minoritas | Sederhana, tanpa library tambahan | Duplikat persis membuat model terlalu hafal baris itu |
| SMOTE | Ciptakan baris minoritas sintetis lewat interpolasi | Variasi minoritas lebih kaya | Sampah keluar bila kelas minoritas berisik atau wilayahnya beririsan |
class_weight | Baris minoritas diberi bobot galat lebih besar | Tanpa mengubah data sama sekali | Perlu model yang mendukung pembobotan |
Dataset kita sama sekpanjang chapter: 32 pelanggan bertahan dan 8 berhenti, dengan tiga fitur — kunjungan per bulan, jumlah keluhan, dan umur keanggotaan (bulan). Wilayah kedua kelas sengaja dibuat beririsan supaya zona batasnya terasa.
Resampling Manual: Undersampling dan Oversampling #
Cara paling jujur memahami resampling adalah melakukannya dengan tangan sendiri di atas pandas. Undersampling membuang kelebihan baris mayoritas; oversampling menyalin baris minoritas sampai jumlahnya menyamai mayoritas. Keduanya bisa kita tulis dengan sample() dan pd.concat():
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
# 32 pelanggan bertahan (0) dan 8 pelanggan berhenti (1), sebagian wilayahnya beririsan
kunjungan_bertahan = rng.integers(5, 15, 32)
keluhan_bertahan = rng.integers(0, 4, 32)
umur_bertahan = rng.integers(3, 41, 32)
kunjungan_berhenti = rng.integers(3, 11, 8)
keluhan_berhenti = rng.integers(1, 6, 8)
umur_berhenti = rng.integers(1, 31, 8)
pelanggan = pd.DataFrame({
"kunjungan_bulan": np.concatenate([kunjungan_bertahan, kunjungan_berhenti]),
"jumlah_keluhan": np.concatenate([keluhan_bertahan, keluhan_berhenti]),
"umur_member": np.concatenate([umur_bertahan, umur_berhenti]),
"berhenti": np.array([0] * 32 + [1] * 8),
})
ringkas = pelanggan.groupby("berhenti").agg(
rata_kunjungan=("kunjungan_bulan", "mean"),
rata_keluhan=("jumlah_keluhan", "mean"),
jumlah_pelanggan=("berhenti", "size"),
).round(1)
print(ringkas.to_string())
mayoritas = pelanggan[pelanggan["berhenti"] == 0]
minoritas = pelanggan[pelanggan["berhenti"] == 1]
# Undersampling manual: buang kelebihan baris mayoritas agar 8 lawan 8
mayoritas_jarang = mayoritas.sample(n=len(minoritas), random_state=42)
under = pd.concat([minoritas, mayoritas_jarang])
print("Undersampling:", under.shape[0], "baris ->", under["berhenti"].value_counts().to_dict())
# Oversampling manual: duplikasi baris minoritas sampai 32 lawan 32
duplikat = minoritas.sample(n=len(mayoritas) - len(minoritas), replace=True, random_state=42)
over = pd.concat([pelanggan, duplikat])
print("Oversampling :", over.shape[0], "baris ->", over["berhenti"].value_counts().to_dict())Output yang Dihasilkan:
rata_kunjungan rata_keluhan jumlah_pelanggan
berhenti
0 9.8 1.7 32
1 6.1 3.6 8
Undersampling: 16 baris -> {1: 8, 0: 8}
Oversampling : 64 baris -> {0: 32, 1: 32}Mari kita telaah baris-baris pentingnya:
- Baris 10-15 (pembuatan data): kedua kelas dibangkitkan dengan
rng(42); pelanggan berhenti memang berkunjung lebih jarang (rata-rata 6,1 vs 9,8) dan mengeluh lebih banyak (3,6 vs 1,7), tetapi rentang nilainya beririsan — tidak ada satu fitur yang bisa memisahkan kelas dengan sempurna. - Baris 27 (
mayoritas.sample(n=len(minoritas), ...)): undersampling mengambil 8 dari 32 baris mayoritas secara acak (random_state=42agar terulang sama); 24 baris lainnya ditinggalkan. Data latih mengecil dari 40 menjadi 16 baris. - Baris 32 (
replace=True): oversampling menyalin 24 baris dari 8 pelanggan minoritas — pasti ada baris yang terpilih lebih dari sekali. Hasilnya 64 baris, tetapi keragaman sesungguhnya tidak bertambah: 8 orang yang sama saja yang diperbanyak.
Di sinilah keterbatasan kedua cara manual itu terlihat: undersampling membuang informasi, oversampling menggandakan hafalan. Kita butuh cara menambah baris minoritas yang benar-benar baru.
SMOTE: Baris Minoritas Sintetis dari Interpolasi #
SMOTE (Synthetic Minority Over-sampling Technique) membuat baris sintetis dengan cara menghubungkan dua baris minoritas yang bertetangga, lalu menaruh titik baru di sepanjang garis penghubungnya — sebuah interpolasi, bukan salinan. Implementasinya tersedia di library imblearn (imbalanced-learn). Satu aturan paling penting dalam pemakaiannya: SMOTE hanya boleh diterapkan pada data latih.
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import SMOTE
rng = np.random.default_rng(42)
X = pd.DataFrame({
"kunjungan_bulan": np.concatenate([rng.integers(5, 15, 32), rng.integers(3, 11, 8)]),
"jumlah_keluhan": np.concatenate([rng.integers(0, 4, 32), rng.integers(1, 6, 8)]),
"umur_member": np.concatenate([rng.integers(3, 41, 32), rng.integers(1, 31, 8)]),
})
y = pd.Series(np.array([0] * 32 + [1] * 8), name="berhenti")
# SMOTE hanya boleh menyentuh data latih; data uji tetap mewakili proporsi dunia nyata
X_latih, X_uji, y_latih, y_uji = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42
)
print("Latih sebelum SMOTE:", y_latih.value_counts().to_dict())
print("Uji (tidak disentuh):", y_uji.value_counts().to_dict())
smote = SMOTE(k_neighbors=5, random_state=42)
X_besar, y_besar = smote.fit_resample(X_latih, y_latih)
print("Latih setelah SMOTE:", y_besar.value_counts().to_dict())
sintetis = X_besar.iloc[len(X_latih):]
print()
print("Tiga pelanggan sintetis pertama buatan SMOTE:")
print(sintetis.head(3).to_string())
tetangga = X_latih[y_latih == 1]
jarak = ((tetangga - sintetis.iloc[2]) ** 2).sum(axis=1).sort_values()
print()
print("Dua pelanggan berhenti terdekat dari baris sintetis (7, 3, 4):")
print(tetangga.loc[jarak.index[:2]].to_string())Output yang Dihasilkan:
Latih sebelum SMOTE: {0: 24, 1: 6}
Uji (tidak disentuh): {0: 8, 1: 2}
Latih setelah SMOTE: {1: 24, 0: 24}
Tiga pelanggan sintetis pertama buatan SMOTE:
kunjungan_bulan jumlah_keluhan umur_member
30 9 2 2
31 5 3 8
32 7 3 4
Dua pelanggan berhenti terdekat dari baris sintetis (7, 3, 4):
kunjungan_bulan jumlah_keluhan umur_member
39 8 4 4
37 9 2 5Bedah baris-barisnya:
- Baris 12-14 (
train_test_split(..., stratify=y)): pembagian berlapis menjaga proporsi kelas di kedua sisi — 24 lawan 6 di latih, 8 lawan 2 di uji — persis seperti data aslinya. - Baris 19 (
fit_resample): SMOTE mempelajari 6 pelanggan minoritas di data latih, lalu menambah 18 baris sintetis sampai 24 lawan 24.k_neighbors=5membatasi jumlah tetangga yang dipakai sebagai bahan interpolasi (harus lebih kecil dari jumlah baris minoritas). - Baris 24 (
X_besar.iloc[len(X_latih):]):imblearnmenempel baris-baris sintetis di bagian akhir hasil, sehingga baris berindeks 30 ke atas adalah buatan SMOTE. - Bacaan hasilnya: baris sintetis (7, 3, 4) berdiri di antara dua pelanggan asli (8, 4, 4) dan (9, 2, 5) — nilainya berada di tengah-tengah keduanya. Itulah wajah interpolasi: titik baru selalu terjepit di dalam wilayah kelas minoritas, bukan di luar batasnya.
class_weight: Menimbang Kelas Tanpa Mengubah Data #
Jalur ketiga tidak menyentuh data sama sekali. Sebagian besar model scikit-learn menerima parameter class_weight="balanced": setiap baris diberi bobot galat berbanding terbalik dengan ukuran kelasnya, sehingga satu kesalahan pada kelas minoritas “terasa” lebih berat. Rumusnya sederhana — jumlah baris dibagi dua kali ukuran kelas — dan kita bisa menghitungnya sendiri untuk melihat angkanya:
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
rng = np.random.default_rng(42)
X = pd.DataFrame({
"kunjungan_bulan": np.concatenate([rng.integers(5, 15, 32), rng.integers(3, 11, 8)]),
"jumlah_keluhan": np.concatenate([rng.integers(0, 4, 32), rng.integers(1, 6, 8)]),
"umur_member": np.concatenate([rng.integers(3, 41, 32), rng.integers(1, 31, 8)]),
})
y = pd.Series(np.array([0] * 32 + [1] * 8), name="berhenti")
X_latih, X_uji, y_latih, y_uji = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42
)
jumlah = np.bincount(y_latih)
bobot = len(y_latih) / (2 * jumlah)
print(f"Bobot kelas 'bertahan' (0): {bobot[0]:.3f}")
print(f"Bobot kelas 'berhenti' (1): {bobot[1]:.3f}")
model_biasa = LogisticRegression(max_iter=1000).fit(X_latih, y_latih)
model_bobot = LogisticRegression(
max_iter=1000, class_weight="balanced"
).fit(X_latih, y_latih)
pelanggan_batas = pd.DataFrame({
"kunjungan_bulan": [5, 6, 7, 8, 4],
"jumlah_keluhan": [1, 2, 3, 1, 3],
"umur_member": [16, 16, 15, 30, 8],
})
hasil = pelanggan_batas.copy()
hasil["proba_biasa"] = model_biasa.predict_proba(pelanggan_batas)[:, 1].round(2)
hasil["proba_bobot"] = model_bobot.predict_proba(pelanggan_batas)[:, 1].round(2)
print()
print("Perbandingan peluang 'berhenti' untuk pelanggan di zona batas:")
print(hasil.to_string(index=False))Output yang Dihasilkan:
Bobot kelas 'bertahan' (0): 0.625
Bobot kelas 'berhenti' (1): 2.500
Perbandingan peluang 'berhenti' untuk pelanggan di zona batas:
kunjungan_bulan jumlah_keluhan umur_member proba_biasa proba_bobot
5 1 16 0.37 0.58
6 2 16 0.44 0.70
7 3 15 0.58 0.83
8 1 30 0.00 0.00
4 3 8 0.99 1.00Bedah hasilnya:
- Baris 16-18 (
len(y_latih) / (2 * jumlah)): dengan 30 baris latih (24 dan 6), bobot kelas mayoritas menjadi 0,625 dan kelas minoritas 2,5 — tepat empat kali lipat, mengikuti rasio 24:6.class_weight="balanced"memakai rumus yang sama persis di balik layar. - Dua kolom probabilitas: untuk pelanggan yang jelas-jelas sehat (kunjungan 8, keluhan 1) atau jelas-jelas bermasalah (kunjungan 4, keluhan 3), kedua model sepakat. Perbedaan muncul di zona batas: dua pelanggan pertama melompat dari 0,37 dan 0,44 menjadi 0,58 dan 0,70 — melewati ambang 0,5 dan berubah prediksi menjadi “berhenti”.
- Maknanya: pembobotan menggeser batas keputusan ke arah kelas mayoritas. Model menjadi lebih mudah mengibarkan bendera risiko — berguna bila yang kita kejar adalah tidak melewatkan pelanggan yang akan pergi, dan bisa diterima bila biaya intervensinya murah.
Rangkuman #
- Data timpang membuat kelas minoritas tenggelam saat model belajar; seluruh teknik berupaya mengangkat bobot kelas langka tanpa berbohong tentang dunia nyata.
- Undersampling membuang baris mayoritas (informasi hilang); oversampling manual menduplikasi baris minoritas (keragaman tidak bertambah).
- SMOTE menciptakan baris minoritas sintetis lewat interpolasi antar tetangga; terapkan hanya pada data latih dan jaga data uji tetap timpang.
class_weight="balanced"memberi bobot galat berbanding terbalik ukuran kelas — pada data kita 0,625 lawan 2,5 — tanpa mengubah satu baris pun.- Efek nyata ketimpangan dan penanganannya pada metrik evaluasi dibahas pada chapter Evaluasi Model pada Data Imbalanced .
Kita sudah memperbaiki cara model melihat kelas langka. Bab berikutnya mengangkat strategi yang lebih umum dipakai praktisi untuk mengangkat mutu prediksi: menggabungkan beberapa model menjadi satu pasukan — kita masuk ke Ensemble Learning .
Referensi: