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:

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

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

Mari kita telaah hasilnya:

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

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

text
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 = 2

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

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

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

Mari kita telaah tiga keputusan pada resep ini:

  1. Baris 16-17 (stratify=member, random_state=42): data uji kini dijamin memuat member (1 dari 15 baris, mendekati proporsi 10%), dan random_state membuat pembagian ini dapat direproduksi.
  2. Baris 22 (StratifiedKFold(n_splits=5, shuffle=True, random_state=42)): shuffle=True mengacak urutan baris sebelum lipatan disusun — penting bila data tersimpan berurutan menurut kelas atau waktu. random_state wajib menyertainya agar hasilnya tetap deterministik.
  3. 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=y pada train_test_split mengunci proporsi kelas di kedua sisi pembagian seakurat mungkin.
  • StratifiedKFold menyusun lipatan per kelas sehingga setiap lipatan mengandung kelas minoritas; KFold biasa bisa meninggalkan lipatan kosong dari kelas tersebut.
  • Resep aman untuk klasifikasi timpang: stratify pada split awal, lalu cv=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.

Referensi #

  1. scikit-learn — train_test_split
  2. scikit-learn — Model selection: cross-validation