Data nyata hampir tidak pernah seimbang: transaksi penipuan hanya sepersekian persen dari seluruh transaksi, citra penyakit langka jauh lebih sedikit daripada citra sehat. Model yang dilatih pada data seperti itu belajar satu trik sederhana — selalu menebak kelas mayoritas — karena strategi tersebut meminimalkan rata-rata loss. Chapter ini membongkar mekanisme ketimpangan, mendeteksinya lewat distribusi kelas, lalu menerapkan tiga teknik resampling pada dataset mainan dengan hasil terukur.
Kenapa Ketimpangan Menjadi Masalah #
Loss rata-rata menjumlahkan kontribusi tiap sampel. Pada data 95:5, kelas mayoritas menyumbang 95% gradien; lima persen sisanya nyaris tak terdengar. Akurasi total 95% dapat dicapai model yang tidak pernah sekali pun memprediksi kelas minoritas — angka yang tampak bagus namun tidak berguna. Karena itu diagnosis dimulai dari menghitung distribusi kelas, bukan dari akurasi; pembacaan metrik yang benar untuk kondisi ini dibahas di Metrik Klasifikasi .
Teknik Penanganan #
| Teknik | Cara kerja | Kapan dipakai |
|---|---|---|
| Oversampling minoritas | menduplikasi/mensintesis sampel minoritas | data minoritas sangat sedikit, sumber daya melimpah |
| Undersampling mayoritas | membuang sampel mayoritas secara acak | data mayoritas sangat besar, kelas batas tetap terwakili |
| SMOTE | membuat titik baru di antara tetangga minoritas | duplikasi mentah membuat model menghafal |
| Class weighting | memberi bobot loss lebih besar pada kelas minoritas | tidak ingin mengubah data (chapter berikutnya) |
| Penyesuaian threshold | menurunkan ambang keputusan kelas minoritas | tahap inferensi, setelah model terlatih |
Demo: Deteksi dan Resampling #
Berkas resampling_imbalance.py membangun dataset tiga kelas berdasarkan cluster gaussian dengan distribusi 80/15/5 (rasio mayor:minor 16:1), lalu menerapkan ketiga teknik dan menampilkan hasil SMOTE untuk satu titik:
"""Ketimpangan kelas: deteksi, oversampling, undersampling, dan SMOTE mini.
Dataset mainan 900 sampel dengan distribusi 80/15/5. Kita tunjukkan
distribusi sebelum, hasil oversampling kelas minor, undersampling kelas
mayor, dan satu titik SMOTE buatan (interpolasi tetangga terdekat).
"""
import numpy as np
rng = np.random.default_rng(42)
def buat_cluster(pusat, n, label):
"""Sampel gaussian 2D di sekitar pusat."""
return rng.normal(loc=pusat, scale=0.8, size=(n, 2)), np.full(n, label)
# ---------- 1. Dataset tak seimbang 80/15/5 ----------
X_mayor, y_mayor = buat_cluster([0.0, 0.0], 720, 0)
X_mid, y_mid = buat_cluster([4.0, 0.5], 135, 1)
X_min, y_min = buat_cluster([2.0, 4.0], 45, 2)
X = np.vstack([X_mayor, X_mid, X_min])
y = np.concatenate([y_mayor, y_mid, y_min])
print("===== DISTRIBUSI AWAL =====")
for k in (0, 1, 2):
n_k = int((y == k).sum())
print(f"kelas {k}: {n_k:4d} sampel ({n_k / len(y) * 100:5.1f}%)")
print("rasio mayor:minor =", 720 // 45, ": 1")
# ---------- 2. Oversampling kelas minor (duplikasi + jitter) ----------
butuh = 720 - 45
indeks_min = np.where(y == 2)[0]
ambil = rng.choice(indeks_min, size=butuh, replace=True)
jitter = rng.normal(0, 0.15, size=(butuh, 2))
X_over = np.vstack([X, X[ambil] + jitter])
y_over = np.concatenate([y, np.full(butuh, 2)])
print()
print("===== OVERSAMPLING (minoritas 45 -> 720) =====")
for k in (0, 1, 2):
print(f"kelas {k}: {int((y_over == k).sum()):4d} sampel")
# ---------- 3. Undersampling kelas mayor (acak tanpa pengembalian) ----------
target = 135 # samakan dengan kelas tengah
indeks_mayor = np.where(y == 0)[0]
ambil = rng.choice(indeks_mayor, size=target, replace=False)
sisa = np.ones(len(y), dtype=bool)
sisa[indeks_mayor] = False # buang seluruh kelas mayoritas dulu
X_under = np.vstack([X[sisa], X[ambil]])
y_under = np.concatenate([y[sisa], y[ambil]])
print()
print("===== UNDERSAMPLING (mayoritas 720 -> 135) =====")
for k in (0, 1, 2):
print(f"kelas {k}: {int((y_under == k).sum()):4d} sampel")
# ---------- 4. SMOTE mini: interpolasi tetangga terdekat ----------
def smote_satu(x_min, X_min, rng_):
"""Satu titik SMOTE: gabungan acak x_min dan tetangga terdekatnya."""
jarak = np.linalg.norm(X_min - x_min, axis=1)
tetangga = X_min[np.argsort(jarak)[1]] # [0] adalah dirinya sendiri
lam = rng_.random()
return x_min + lam * (tetangga - x_min), lam
x_baru, lam = smote_satu(X_min[0], X_min, rng)
print()
print("===== SMOTE MINI (1 titik baru) =====")
print("titik asli :", np.round(X_min[0], 3).tolist())
print("tetangga :", np.round(
X_min[np.argsort(np.linalg.norm(X_min - X_min[0], axis=1))[1]], 3
).tolist())
print(f"lambda : {lam:.3f}")
print("titik SMOTE :", np.round(x_baru, 3).tolist())
print("jarak ke asli:", round(float(np.linalg.norm(x_baru - X_min[0])), 3))Output yang Dihasilkan:
===== DISTRIBUSI AWAL =====
kelas 0: 720 sampel ( 80.0%)
kelas 1: 135 sampel ( 15.0%)
kelas 2: 45 sampel ( 5.0%)
rasio mayor:minor = 16 : 1
===== OVERSAMPLING (minoritas 45 -> 720) =====
kelas 0: 720 sampel
kelas 1: 135 sampel
kelas 2: 720 sampel
===== UNDERSAMPLING (mayoritas 720 -> 135) =====
kelas 0: 135 sampel
kelas 1: 135 sampel
kelas 2: 45 sampel
===== SMOTE MINI (1 titik baru) =====
titik asli : [1.535, 2.179]
tetangga : [1.828, 2.846]
lambda : 0.244
titik SMOTE : [1.607, 2.342]
jarak ke asli: 0.178Contoh di atas diuji nyata dengan NumPy 2.1.3 pada mesin CPU dan selesai dalam waktu di bawah satu detik. Mari kita telaah kode di atas baris per baris:
- Baris 13-15 (cluster): fungsi pembantu menghasilkan sampel gaussian 2D — tiga cluster terpisah memudahkan kita melihat efek resampling pada struktur data.
- Baris 18-28 (distribusi awal):
np.where(y == k)menghitung anggota tiap kelas; keluaran menegaskan 720/135/45 atau rasio 16:1 — angka ini sendiri sudah cukup untuk memutuskan perlunya penanganan. - Baris 31-41 (oversampling):
rng.choice(..., replace=True)mengambil 675 indeks minoritas secara acak dengan pengulangan, lalujittergaussian kecil menggeser salinan agar tidak identik persis — tanpa jitter, model hanya menghafal 45 titik yang sama. - Baris 43-54 (undersampling): seluruh kelas mayoritas dibuang lebih dulu lewat mask
sisa, lalu 135 sampel terpilih (replace=False) digabung kembali; hasilnya 135/135/45 — seimbang di dua kelas atas dan tetap timpang terhadap kelas termayor. - Baris 57-63 (SMOTE mini): titik baru diletakkan pada ruas garis antara satu titik minoritas dan tetangga terdekatnya dengan bobot acak
lambda; keluaran memperlihatkan hasil interpolasi berjarak 0.178 dari titik aslinya — berada di dalam wilayah kelas, bukan salinan. - Baris 65-74 (cetak SMOTE):
np.argsort(jarak)[1]mengambil tetangga terdekat kedua karena indeks[0]adalah titik itu sendiri dengan jarak nol.
Perhatikan trade-off kedua jalur resampling pada keluaran: oversampling mempertahankan seluruh informasi mayoritas tetapi melipatgandakan sampel minor menjadi 720 (16 kali lipat), sedangkan undersampling memangkas 585 sampel mayoritas (81%) demi kecepatan training. SMOTE menawarkan jalan tengah — titik baru yang berbeda dari aslinya — dan versi penuhnya dengan opsi lanjutan tersedia di pustaka imbalanced-learn serta publikasi aslinya di arXiv:1106.1813 .
Distribusi kini dapat kita bentuk sesuai kebutuhan, tetapi resampling mengubah data secara fisik: baris bertambah atau berkurang, pipeline penyimpanan ikut berubah, dan augmentasi data harus dikoordinasikan. Pendekatan yang lebih ringan — mengubah bobot di dalam loss tanpa menyentuh satu baris data pun — menjadi fokus chapter berikutnya.