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:

TeknikCara kerjaKeuntunganRisiko
UndersamplingBuang baris kelas mayoritas sampai seimbangCepat, data latih mengecilBaris mayoritas yang dibuang bisa memuat informasi
Oversampling manualDuplikasi baris kelas minoritasSederhana, tanpa library tambahanDuplikat persis membuat model terlalu hafal baris itu
SMOTECiptakan baris minoritas sintetis lewat interpolasiVariasi minoritas lebih kayaSampah keluar bila kelas minoritas berisik atau wilayahnya beririsan
class_weightBaris minoritas diberi bobot galat lebih besarTanpa mengubah data sama sekaliPerlu 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():

python
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:

text
          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:

  1. 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.
  2. Baris 27 (mayoritas.sample(n=len(minoritas), ...)): undersampling mengambil 8 dari 32 baris mayoritas secara acak (random_state=42 agar terulang sama); 24 baris lainnya ditinggalkan. Data latih mengecil dari 40 menjadi 16 baris.
  3. 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.

python
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:

text
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            5

Bedah baris-barisnya:

  1. 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.
  2. Baris 19 (fit_resample): SMOTE mempelajari 6 pelanggan minoritas di data latih, lalu menambah 18 baris sintetis sampai 24 lawan 24. k_neighbors=5 membatasi jumlah tetangga yang dipakai sebagai bahan interpolasi (harus lebih kecil dari jumlah baris minoritas).
  3. Baris 24 (X_besar.iloc[len(X_latih):]): imblearn menempel baris-baris sintetis di bagian akhir hasil, sehingga baris berindeks 30 ke atas adalah buatan SMOTE.
  4. 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:

python
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:

text
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.00

Bedah hasilnya:

  1. 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.
  2. 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”.
  3. 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: