Resampling menyeimbangkan data secara fisik, tetapi ada cara yang lebih ringan: biarkan data apa adanya dan ubah cara loss menghukum kesalahan. Class weighting memberi sampel kelas minoritas kontribusi gradien lebih besar sehingga rata-rata kontribusi tiap kelas menjadi setara — tanpa satu baris data ditambah atau dibuang. Chapter ini membedah rumus bobotnya, menerapkannya pada CrossEntropyLoss PyTorch dan class_weight sklearn, lalu membuktikan efeknya lewat eksperimen terkendali dua model identik.
Mekanisme Bobot Kelas #
CrossEntropyLoss menghitung rata-rata loss seluruh sampel. Dengan bobot w_k per kelas, loss tiap sampel dikalikan bobot kelasnya:
L = (1/N) * sum_i w_{y_i} * CE(model(x_i), y_i)Bobot standar industri mengikuti rumus sebalik-proporsi, sudah dibahas sebagai opsi class_weight="balanced" di scikit-learn:
w_k = n_total / (K * n_k)Dengan n_total jumlah sampel latih, K jumlah kelas, dan n_k anggota kelas k. Pada data latih 600 sampel dengan kelas 0 berisi 570 dan kelas 1 berisi 30, bobotnya menjadi 0.53 dan 10.0 — kesalahan pada satu sampel minoritas setara dengan hampir sepuluh sampel mayoritas.
Demo: Dua Model Identik, Satu Perbedaan #
Berkas class_weight_demo.py membangun dataset 95:5 dengan tumpang tindih ringan, lalu melatih dua MLP dengan seed, arsitektur, learning rate, dan jumlah epoch yang sama persis. Satu-satunya perbedaan: parameter weight pada CrossEntropyLoss:
"""Class weighting: melatih model mini pada data tak seimbang.
Dataset buatan 2 kelas (95% vs 5%) dengan sedikit tumpang tindih.
Dua MLP identik dilatih dengan seed sama: satu tanpa bobot kelas, satu
dengan weight di CrossEntropyLoss. F1 kelas minoritas membedakan keduanya.
"""
import numpy as np
import torch
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score
from sklearn.model_selection import train_test_split
torch.manual_seed(42)
rng = np.random.default_rng(42)
# ---------- 1. Dataset tak seimbang: 950 vs 50 ----------
X0 = rng.normal([0.0, 0.0], 1.05, size=(950, 2))
X1 = rng.normal([1.6, 1.6], 1.05, size=(50, 2))
X = np.vstack([X0, X1]).astype(np.float32)
y = np.concatenate([np.zeros(950), np.ones(50)]).astype(np.int64)
X_latih, X_uji, y_latih, y_uji = train_test_split(
X, y, test_size=0.4, random_state=42, stratify=y
)
unik, hitung = np.unique(y_latih, return_counts=True)
print("===== DISTRIBUSI DATA LATIH =====")
for k, c in zip(unik, hitung):
print(f"kelas {int(k)}: {c:4d} sampel ({c / len(y_latih) * 100:.1f}%)")
# ---------- 2. Rumus bobot kelas ----------
n_total = len(y_latih)
bobot = np.array([n_total / (len(unik) * c) for c in hitung])
bobot = bobot / bobot.mean() # normalisasi agar rata-rata bobot = 1
print()
print("===== BOBOT KELAS (n_total / (K * n_k)) =====")
print(f"kelas 0: {n_total} / ({len(unik)} x {hitung[0]}) = "
f"{n_total / (len(unik) * hitung[0]):.4f}")
print(f"kelas 1: {n_total} / ({len(unik)} x {hitung[1]}) = "
f"{n_total / (len(unik) * hitung[1]):.4f}")
print("setelah dinormalisasi :", np.round(bobot, 3).tolist())
# ---------- 3. Melatih MLP: tanpa bobot vs dengan bobot ----------
def latih_mlp(bobot_kelas=None, epoch=150):
torch.manual_seed(42) # inisialisasi identik untuk kedua run
model = torch.nn.Sequential(
torch.nn.Linear(2, 32), torch.nn.ReLU(), torch.nn.Linear(32, 2)
)
opt = torch.optim.Adam(model.parameters(), lr=0.01)
loss_fn = torch.nn.CrossEntropyLoss(
weight=None if bobot_kelas is None else torch.tensor(bobot_kelas)
)
xt, yt = torch.from_numpy(X_latih), torch.from_numpy(y_latih)
for _ in range(epoch):
opt.zero_grad()
loss = loss_fn(model(xt), yt)
loss.backward()
opt.step()
with torch.no_grad():
return model(torch.from_numpy(X_uji)).argmax(dim=1).numpy()
pred_plain = latih_mlp()
pred_weight = latih_mlp(bobot.astype(np.float32).tolist())
def lapor(nama, pred):
f1 = f1_score(y_uji, pred, average=None)
recall_min = float((pred[y_uji == 1] == 1).mean())
presisi_min = float((pred[pred == 1] == 1).mean())
print(f"{nama:14s} akurasi {float((pred == y_uji).mean()):.3f} | "
f"F1 kelas 1 {f1[1]:.3f} | presisi {presisi_min:.3f} | "
f"recall {recall_min:.3f}")
return f1[1]
print()
print("===== HASIL MLP (PyTorch) =====")
f1_plain = lapor("tanpa bobot", pred_plain)
f1_weight = lapor("dengan bobot", pred_weight)
print()
print(f"selisih F1 kelas minoritas: {f1_weight - f1_plain:+.3f}")
# ---------- 4. Pembanding sklearn: class_weight='balanced' ----------
lr_plain = LogisticRegression(max_iter=1000, random_state=42).fit(
X_latih, y_latih
)
lr_bal = LogisticRegression(
max_iter=1000, random_state=42, class_weight="balanced"
).fit(X_latih, y_latih)
print()
print("===== PEMBANDING sklearn LogisticRegression =====")
f1_lr_plain = lapor("tanpa bobot", lr_plain.predict(X_uji))
f1_lr_bal = lapor("'balanced'", lr_bal.predict(X_uji))
print(f"selisih F1 kelas minoritas: {f1_lr_bal - f1_lr_plain:+.3f}")Output yang Dihasilkan:
===== DISTRIBUSI DATA LATIH =====
kelas 0: 570 sampel (95.0%)
kelas 1: 30 sampel (5.0%)
===== BOBOT KELAS (n_total / (K * n_k)) =====
kelas 0: 600 / (2 x 570) = 0.5263
kelas 1: 600 / (2 x 30) = 10.0000
setelah dinormalisasi : [0.1, 1.9]
===== HASIL MLP (PyTorch) =====
tanpa bobot akurasi 0.950 | F1 kelas 1 0.167 | presisi 1.000 | recall 0.100
dengan bobot akurasi 0.833 | F1 kelas 1 0.337 | presisi 1.000 | recall 0.850
selisih F1 kelas minoritas: +0.170
===== PEMBANDING sklearn LogisticRegression =====
tanpa bobot akurasi 0.945 | F1 kelas 1 0.083 | presisi 1.000 | recall 0.050
'balanced' akurasi 0.843 | F1 kelas 1 0.376 | presisi 1.000 | recall 0.950
selisih F1 kelas minoritas: +0.293Contoh di atas diuji nyata dengan PyTorch 2.14.1+cpu dan scikit-learn 1.5.2 pada mesin CPU, selesai dalam waktu di bawah dua detik. Mari kita telaah kode di atas baris per baris:
- Baris 17-30 (dataset): dua cluster gaussian dengan sedikit tumpang tindih dan proporsi 950:50;
stratify=ymenjaga proporsi tetap terpelihara di data latih (570:30) maupun uji. - Baris 32-43 (bobot): rumus dibangun eksplisit memakai
np.unique(..., return_counts=True), lalu dinormalisasi agar rata-rata bobot tepat 1 — normalisasi menjaga skala loss sebanding dengan training tanpa bobot sehingga learning rate tidak perlu diubah. - Baris 45-64 (latih_mlp):
torch.manual_seed(42)dipanggil di dalam fungsi supaya kedua run memulai dari bobot awal yang identik; parameterweightpadaCrossEntropyLossmenerima tensor bobot per kelas — inilah satu-satunya perbedaan antar keduanya. - Baris 67-79 (laporan): fungsi
lapormencetak akurasi total, F1 kelas minoritas, presisi, dan recall sekaligus supaya trade-off terlihat dalam satu baris. - Baris 84-95 (pembanding):
LogisticRegression(class_weight="balanced")menerapkan rumus yang sama di ekosistem sklearn — dua ekosistem, satu mekanisme.
Keluaran memperlihatkan pola yang khas. Tanpa bobot, MLP nyaris mengabaikan kelas minoritas: recall hanya 0.100 — dari 20 sampel minoritas pada data uji, dua saja tertangkap. Dengan bobot, recall melompat ke 0.850 dan F1 naik dari 0.167 ke 0.337. Sebaliknya, akurasi total turun dari 0.950 ke 0.833 karena model kini berani menebak kelas minoritas — pertukaran yang tepat bila kelas minoritas justru alasan model dibuat. Presisi bertahan di 1.000 karena kedua cluster memang terpisah cukup jelas.
Class Weighting vs Resampling #
| Aspek | Class weighting | Resampling |
|---|---|---|
| Data diubah | tidak | ya (baris bertambah/berkurang) |
| Biaya training per epoch | setara | naik (oversampling) atau turun (undersampling) |
| Interaksi dengan batch | bobot rata di seluruh batch | komposisi batch berubah |
| Risiko | model terlalu sensitif pada pencilan minoritas | oversampling menghafal, undersampling kehilangan informasi |
Pilihan praktisnya: mulai dari class weighting karena murah dan reversibel; berpindah ke SMOTE atau kombinasi teknik bila hasil validasi belum memadai. Dokumentasi resmi CrossEntropyLoss dengan parameter weight tersedia di PyTorch nn documentation
, sedangkan perilaku class_weight dijelaskan di scikit-learn linear model
.
Baik resampling maupun class weighting mengubah cara model belajar. Masalah berikutnya berada di wilayah yang lebih diam dan lebih berbahaya: data uji yang diam-diam membocorkan jawaban ke proses training, menggelembungkan angka evaluasi tanpa kita sadari.