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 #

TeknikCara kerjaKapan dipakai
Oversampling minoritasmenduplikasi/mensintesis sampel minoritasdata minoritas sangat sedikit, sumber daya melimpah
Undersampling mayoritasmembuang sampel mayoritas secara acakdata mayoritas sangat besar, kelas batas tetap terwakili
SMOTEmembuat titik baru di antara tetangga minoritasduplikasi mentah membuat model menghafal
Class weightingmemberi bobot loss lebih besar pada kelas minoritastidak ingin mengubah data (chapter berikutnya)
Penyesuaian thresholdmenurunkan ambang keputusan kelas minoritastahap 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:

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

text
===== 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.178

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

  1. Baris 13-15 (cluster): fungsi pembantu menghasilkan sampel gaussian 2D — tiga cluster terpisah memudahkan kita melihat efek resampling pada struktur data.
  2. 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.
  3. Baris 31-41 (oversampling): rng.choice(..., replace=True) mengambil 675 indeks minoritas secara acak dengan pengulangan, lalu jitter gaussian kecil menggeser salinan agar tidak identik persis — tanpa jitter, model hanya menghafal 45 titik yang sama.
  4. 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.
  5. 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.
  6. 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.