Cross-validation pada chapter sebelumnya membuat estimasi performa lebih stabil, tetapi masih ada satu jebakan senyap: pembagian acak tidak menjamin kelas minoritas terwakili. Pada data timpang — misalnya 10% pelanggan member dan 90% pelanggan biasa — data uji berisi 15 baris hanya “menunggangi keberuntungan” untuk memuat satu-dua member; bisa juga tidak memuat satu pun. Stratified sampling mengunci proporsi kelas di setiap pembagian supaya evaluasi tidak berjalan dengan sampel minoritas yang melayang atau bahkan kosong. Pada chapter ini kita buktikan risikonya lewat kode, lalu terapkan stratify pada train_test_split dan StratifiedKFold pada cross-validation.
Ketika Kelas Minoritas Tertinggal dari Pembagian #
Mari kita hitung peluangnya secara konkret. Data kita berisi 60 pelanggan Toko Kopi Kita dengan dua fitur — frekuensi kunjungan per bulan dan rata-rata belanja (ribu rupiah) — serta target member: enam pelanggan (10%) terdaftar program member, sisanya tidak. Jika train_test_split mengambil 15 baris secara acak tanpa stratifikasi, jumlah member di data uji mengikuti peluang murni: baris member bisa terpilih banyak, sedikit, atau nol. Kita jalankan pembagian yang sama dengan enam seed berbeda dan hitung isinya:
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(42)
n = 60
minat = rng.normal(0, 1, n)
frekuensi_kunjungan = (12 + 8 * minat + rng.normal(0, 3, n)).clip(0, 40).astype(int)
nilai_belanja = (90 + 25 * minat + rng.normal(0, 20, n)).clip(10, 300).astype(int)
member = np.zeros(n, dtype=int)
member[np.argsort(minat)[-6:]] = 1 # 6 pelanggan paling berminat (10%)
X = np.column_stack([frekuensi_kunjungan, nilai_belanja])
print("dataset: member =", member.sum(), "dari", n,
"| proporsi:", round(member.mean(), 3))
baris = []
for seed in range(6):
_, _, _, y_uji_tanpa = train_test_split(
X, member, test_size=0.25, random_state=seed)
_, _, _, y_uji_strat = train_test_split(
X, member, test_size=0.25, random_state=seed, stratify=member)
baris.append({
"seed": seed,
"member_tanpa_stratify": int(y_uji_tanpa.sum()),
"member_dengan_stratify": int(y_uji_strat.sum()),
})
print()
print(pd.DataFrame(baris).to_string(index=False))Output yang Dihasilkan:
dataset: member = 6 dari 60 | proporsi: 0.1
seed member_tanpa_stratify member_dengan_stratify
0 4 1
1 1 2
2 2 1
3 2 1
4 1 1
5 0 2Mari kita telaah hasilnya:
- Kolom tanpa stratify melompat lebar: jumlah member di data uji bergerak dari 0 (seed 5) sampai 4 (seed 0) — padahal nilai harapannya hanya 6 × 25% = 1,5. Data uji tanpa member membuat recall dan precision kelas member tak bisa dihitung sama sekali; data uji berisi 4 member justru menimbang kelas minoritas berlebihan.
- Kolom dengan stratify terkunci di 1-2: stratifikasi mengalokasikan anggota tiap kelas seakurat mungkin terhadap proporsinya. Karena 1,5 bukan bilangan bulat, hasil terdekatnya bergantian 1 atau 2 — tetap berdekatan dengan harapan, tidak pernah melompat ke 0 atau 4.
- Baris 18-19 (
stratify=member): satu argumen ini saja yang membedakan kedua pembagian; model dan metrik yang kita rencanakan tidak perlu berubah.
Kesimpulannya tegas: pada data timpang berukuran kecil, pembagian acak polos menyerahkan kualitas evaluasi pada kebetulan, sedangkan stratifikasi membuat setiap potongan data mencerminkan keseluruhan.
StratifiedKFold untuk Cross-Validation #
Masalah yang sama menimpa lipatan cross-validation. KFold biasa membelah data menjadi k bagian tanpa melihat kelas, sehingga satu lipatan bisa saja kosong dari kelas minoritas — dan skor recall pada lipatan tersebut menjadi tak berarti. StratifiedKFold membagi data per kelas terlebih dahulu, lalu menyusun lipatan yang komposisinya meniru data asli:
import numpy as np
from sklearn.model_selection import KFold, StratifiedKFold
rng = np.random.default_rng(42)
n = 60
minat = rng.normal(0, 1, n)
frekuensi_kunjungan = (12 + 8 * minat + rng.normal(0, 3, n)).clip(0, 40).astype(int)
nilai_belanja = (90 + 25 * minat + rng.normal(0, 20, n)).clip(10, 300).astype(int)
member = np.zeros(n, dtype=int)
member[np.argsort(minat)[-6:]] = 1
X = np.column_stack([frekuensi_kunjungan, nilai_belanja])
print("KFold biasa (tanpa stratifikasi):")
kf = KFold(n_splits=5)
for i, (_, idx_validasi) in enumerate(kf.split(X), start=1):
print(f" lipatan {i}: member di validasi = {member[idx_validasi].sum()}")
print("StratifiedKFold:")
skf = StratifiedKFold(n_splits=5)
for i, (_, idx_validasi) in enumerate(skf.split(X, member), start=1):
print(f" lipatan {i}: member di validasi = {member[idx_validasi].sum()}")Output yang Dihasilkan:
KFold biasa (tanpa stratifikasi):
lipatan 1: member di validasi = 0
lipatan 2: member di validasi = 2
lipatan 3: member di validasi = 2
lipatan 4: member di validasi = 0
lipatan 5: member di validasi = 2
StratifiedKFold:
lipatan 1: member di validasi = 1
lipatan 2: member di validasi = 1
lipatan 3: member di validasi = 1
lipatan 4: member di validasi = 1
lipatan 5: member di validasi = 2Dua detail penting terlihat di sini. Pertama, KFold biasa menghasilkan dua lipatan tanpa member sama sekali (lipatan 1 dan 4) — model yang divalidasi di sana tidak pernah diuji mendeteksi kelas minoritas, sehingga rata-rata skor cross-validation menjadi bias. Kedua, StratifiedKFold membagi enam member hampir merata (1, 1, 1, 1, 2) — jumlah yang tidak habis dibagi lima membuat satu lipatan menerima anggota ekstra. Perhatikan juga bahwa skf.split() menerima member sebagai argumen kedua, karena penyusunan lipatan perlu tahu label kelasnya; kf.split() hanya menerima fitur.
Resep Praktis: stratify pada Split dan Lipatan #
Sekarang kita susun pola lengkap yang dipakai di proyek nyata: data uji dijaga proporsinya dengan stratify, lalu cross-validation di atas data latih memakai StratifiedKFold dengan shuffle=True:
import numpy as np
from sklearn.model_selection import (StratifiedKFold, train_test_split,
cross_val_score)
from sklearn.linear_model import LogisticRegression
rng = np.random.default_rng(42)
n = 60
minat = rng.normal(0, 1, n)
frekuensi_kunjungan = (12 + 8 * minat + rng.normal(0, 3, n)).clip(0, 40).astype(int)
nilai_belanja = (90 + 25 * minat + rng.normal(0, 20, n)).clip(10, 300).astype(int)
member = np.zeros(n, dtype=int)
member[np.argsort(minat)[-6:]] = 1
X = np.column_stack([frekuensi_kunjungan, nilai_belanja])
X_latih, X_uji, y_latih, y_uji = train_test_split(
X, member, test_size=0.25, stratify=member, random_state=42)
print("data latih:", len(y_latih), "baris, member:", y_latih.sum())
print("data uji :", len(y_uji), "baris, member:", y_uji.sum())
cv_strat = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
skor = cross_val_score(LogisticRegression(max_iter=1000), X_latih, y_latih,
cv=cv_strat)
print("skor CV per lipatan:", np.round(skor, 3).tolist())
print("rata-rata CV :", round(skor.mean(), 3))
model = LogisticRegression(max_iter=1000).fit(X_latih, y_latih)
print("akurasi data uji :", round(model.score(X_uji, y_uji), 3))Output yang Dihasilkan:
data latih: 45 baris, member: 5
data uji : 15 baris, member: 1
skor CV per lipatan: [1.0, 1.0, 1.0, 0.889, 0.778]
rata-rata CV : 0.933
akurasi data uji : 0.933Mari kita telaah tiga keputusan pada resep ini:
- Baris 16-17 (
stratify=member, random_state=42): data uji kini dijamin memuat member (1 dari 15 baris, mendekati proporsi 10%), danrandom_statemembuat pembagian ini dapat direproduksi. - Baris 22 (
StratifiedKFold(n_splits=5, shuffle=True, random_state=42)):shuffle=Truemengacak urutan baris sebelum lipatan disusun — penting bila data tersimpan berurutan menurut kelas atau waktu.random_statewajib menyertainya agar hasilnya tetap deterministik. - Baris 23-25 (cross_val_score di data latih): validasi silang hanya berjalan pada 45 baris data latih; 15 baris data uji menyisakan pemeriksaan terakhir yang bersih. Urutannya — stratified split dulu, cross-validation kemudian, data uji paling akhir.
Hasilnya menunjukkan protokol yang sehat: rata-rata CV 0,933 dan akurasi data uji 0,933 saling menguatkan, dengan skor per lipatan 0,778-1,0 memberi gambaran keragamannya.
Rangkuman #
- Pembagian acak pada data timpang bisa menghasilkan data uji tanpa satu pun kelas minoritas — kita menyaksikan data uji berisi 0 sampai 4 member untuk proporsi yang sama.
stratify=ypadatrain_test_splitmengunci proporsi kelas di kedua sisi pembagian seakurat mungkin.StratifiedKFoldmenyusun lipatan per kelas sehingga setiap lipatan mengandung kelas minoritas;KFoldbiasa bisa meninggalkan lipatan kosong dari kelas tersebut.- Resep aman untuk klasifikasi timpang:
stratifypada split awal, lalucv=StratifiedKFold(shuffle=True, random_state=...)untuk cross-validation di data latih. - Kelas minoritas butuh cukup anggota (minimal sejumlah lipatan) agar stratifikasi berjalan; untuk regresi, stratifikasi kelas tidak berlaku.
Dengan proporsi kelas yang terjaga, estimasi performa kita kini jujur terhadap data. Pertanyaan berikutnya berpindah ke modelnya sendiri: pohon keputusan yang kita latih punya tombol-tombol pengatur — kedalaman, kriteria pemisahan — yang nilainya kita pilih sebelum pelatihan. Chapter Hyperparameter Tuning membahas cara mencari kombinasi terbaiknya secara sistematis.