Sepanjang Section ini kita mengukur performa model dengan satu angka dari satu pembagian data: pisahkan data uji, latih model, lalu hitung metriknya pada data uji tersebut. Masalahnya, angka itu ikut berganti setiap kali pembagian berganti — model yang sama bisa tampak bagus pada satu pembagian dan mengecewakan pada pembagian lain. Cross-validation menjawab kerapuhan itu dengan memutar peran data: setiap baris mendapat giliran menjadi data validasi, lalu seluruh skor dirangkum menjadi satu estimasi yang jauh lebih stabil. Pada chapter ini kita bedah cara kerja k-fold cross-validation, mempraktikkannya lewat cross_val_score, lalu membahas pemilihan nilai k.

Satu Pembagian Menghasilkan Satu Angka yang Rapuh #

Ingat kembali cara kerja train_test_split: fungsi tersebut memilih sebagian baris secara acak sebagai data uji, sisanya menjadi data latih. Bagian mana yang terpilih ditentukan oleh random_state — ganti seed, berganti pula susunan data uji, dan berganti pula skor yang kita laporkan. Pada data besar efek ini mengecil, tetapi pada dataset kecil (puluhan sampai ratusan baris) satu-dua baris yang pindah kelompok sudah cukup menggeser akurasi bermakna. Mari kita buktikan dengan data churn pelanggan Toko Kopi Kita: 60 pelanggan dengan fitur lama keanggotaan (bulan) dan belanja bulanan (ribu rupiah).

python
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression

rng = np.random.default_rng(42)
n = 60
lama_member = rng.integers(1, 36, n)
belanja_bulanan = rng.integers(50, 500, n)
skor_churn = 0.08 * (36 - lama_member) + 0.03 * belanja_bulanan + rng.normal(0, 4, n)
churn = (skor_churn > np.quantile(skor_churn, 0.65)).astype(int)

X = np.column_stack([lama_member, belanja_bulanan])
print("jumlah pelanggan:", n, "| berhenti:", churn.sum(), "| lanjut:", n - churn.sum())

hasil = []
for seed in range(5):
    X_latih, X_uji, y_latih, y_uji = train_test_split(
        X, churn, test_size=0.25, random_state=seed)
    model = LogisticRegression().fit(X_latih, y_latih)
    hasil.append(model.score(X_uji, y_uji))

print("akurasi per seed :", np.round(hasil, 3).tolist())
print("selisih terbesar :", round(max(hasil) - min(hasil), 3))

Output yang Dihasilkan:

text
jumlah pelanggan: 60 | berhenti: 21 | lanjut: 39
akurasi per seed : [0.933, 0.933, 0.667, 0.8, 0.933]
selisih terbesar : 0.267

Mari kita telaah baris-baris kode di atas:

  1. Baris 6-9 (pembuatan data): rng.integers menghasilkan dua fitur, lalu skor_churn menggabungkannya menjadi kecenderungan berhenti — pelanggan baru dengan belanja besar condong berhenti. Ambang np.quantile(..., 0.65) menandai 35% teratas sebagai churn, mendekati proporsi churn nyata yang biasanya minoritas.
  2. Baris 13 (for seed in range(5)): kita mengulang eksperimen yang sama lima kali; satu-satunya yang berbeda adalah random_state pada train_test_split. Model, data, dan metrik tidak berubah.
  3. Baris 17 (model.score(...)): score() pada classifier mengembalikan akurasi di data uji. Nilainya kita kumpulkan ke list hasil.

Hasilnya tegas: model yang identik mencetak akurasi 0,933 pada tiga pembagian, tetapi anjlok ke 0,667 pada pembagian ketiga — selisih 0,267. Jika kita kebetulan memakai seed ketiga dan melaporkan 66,7% sebagai performa model, keputusan yang menyusul (misalnya menolak model ini) jadi keliru. Kita butuh mekanisme yang tidak menyerahkan nasib estimasi pada satu undian pembagian.

Cara Kerja k-Fold Cross-Validation #

k-fold cross-validation membagi data menjadi k bagian sama besar yang disebut fold atau lipatan. Eksperimen diulang k putaran: pada setiap putaran, satu lipatan berperan sebagai data validasi dan sisanya (k-1 lipatan) sebagai data latih. Setelah k putaran, setiap baris telah menjadi data validasi tepat satu kali, sehingga kita memperoleh k skor — bukan satu. Ilustrasi paling jelas datang dari KFold.split() pada sepuluh baris:

python
import numpy as np
from sklearn.model_selection import KFold

kf = KFold(n_splits=5)
baris_data = np.arange(10)

for i, (idx_latih, idx_validasi) in enumerate(kf.split(baris_data.reshape(-1, 1)), start=1):
    print(f"lipatan {i}: validasi={idx_validasi.tolist()}"
          f"  latih={idx_latih.tolist()}")

Output yang Dihasilkan:

text
lipatan 1: validasi=[0, 1]  latih=[2, 3, 4, 5, 6, 7, 8, 9]
lipatan 2: validasi=[2, 3]  latih=[0, 1, 4, 5, 6, 7, 8, 9]
lipatan 3: validasi=[4, 5]  latih=[0, 1, 2, 3, 6, 7, 8, 9]
lipatan 4: validasi=[6, 7]  latih=[0, 1, 2, 3, 4, 5, 8, 9]
lipatan 5: validasi=[8, 9]  latih=[0, 1, 2, 3, 4, 5, 6, 7]

Tiga baris kode ini memperlihatkan seluruh mekanismenya: KFold(n_splits=5) menyiapkan lima lipatan, split() menghasilkan pasangan indeks (latih, validasi) per putaran, dan setiap baris 0-9 muncul di sisi validasi tepat satu kali. Pada data nyata, model dilatih dari awal pada setiap putaran — inilah sebabnya cross-validation melatih model sebanyak k kali. Skor validasi dari kelima putaran kemudian dirangkum, biasanya dengan rata-rata sebagai estimasi performa dan simpangan baku sebagai ukuran ketidakpastiannya.

Menghitung Skor per Lipatan dengan cross_val_score #

Kita tidak perlu menulis perulangan manual di atas setiap kali butuh validasi silang. Fungsi cross_val_score() merangkum seluruh proses — membagi lipatan, melatih, menilai — dan mengembalikan array berisi k skor. Kita terapkan pada data churn yang sama:

python
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

rng = np.random.default_rng(42)
n = 60
lama_member = rng.integers(1, 36, n)
belanja_bulanan = rng.integers(50, 500, n)
skor_churn = 0.08 * (36 - lama_member) + 0.03 * belanja_bulanan + rng.normal(0, 4, n)
churn = (skor_churn > np.quantile(skor_churn, 0.65)).astype(int)
X = np.column_stack([lama_member, belanja_bulanan])

skor_lipatan = cross_val_score(LogisticRegression(), X, churn, cv=5)

tabel = pd.DataFrame({
    "lipatan": range(1, 6),
    "skor_validasi": skor_lipatan.round(3),
})
print(tabel.to_string(index=False))
print()
print("rata-rata      :", round(skor_lipatan.mean(), 3))
print("simpangan baku :", round(skor_lipatan.std(), 3))

Output yang Dihasilkan:

text
 lipatan  skor_validasi
       1          0.917
       2          0.750
       3          0.917
       4          0.750
       5          0.750

rata-rata      : 0.817
simpangan baku : 0.082

Mari kita telaah bagian-bagian pentingnya:

  1. Baris 13 (cross_val_score(...)): tiga argumen utamanya adalah model (belum dilatih), fitur X, dan target churn. Parameter cv=5 meminta lima lipatan; untuk masalah klasifikasi, scikit-learn otomatis memakai StratifiedKFold agar proporsi kelas tiap lipatan terjaga — topik yang kita bedah penuh pada chapter berikutnya.
  2. Baris 15-18 (pd.DataFrame): array skor diubah menjadi tabel satu baris per lipatan supaya mudah dipindai; round(3) menjaga tampilan tetap ringkas.
  3. Baris 21-22 (rangkuman): rata-rata 0,817 menjadi estimasi performa, dan simpangan baku 0,082 memberi tahu seberapa jauh skor bisa bergeser antar lipatan.

Bandingkan dengan hasil sebelumnya: lima pembagian acak menghasilkan akurasi 0,667-0,933 tanpa ada petunjuk angka mana yang benar, sedangkan cross-validation memberi satu estimasi rata-rata (0,817) beserta ukuran ketidakpastiannya. Rata-rata lima lipatan menempatkan diri di tengah rentang tersebut — persis perilaku yang kita harapkan dari estimasi yang lebih stabil. Inilah alasan cross-validation menjadi standar saat membandingkan model atau melaporkan performa pada dataset kecil.

Memilih Nilai k #

Berapa lipatan yang sebaiknya dipakai? Pilihan k adalah trade-off antara bias dan variansi estimasi, plus biaya komputasi:

Nilai kData latih per putaranKecenderungan biasKecenderungan variansiBiaya
k=250%Skor cenderung pesimistik (data latih sedikit)Rendah2x pelatihan
k=580%RinganModerat5x pelatihan
k=1090%Hampir netralAgak tinggi10x pelatihan
k=n (LOOCV)(n-1)/n dataPaling netralTinggi (lipatan tumpang tindih)n x pelatihan

Dua ujung tabel ini menjelaskan trade-off-nya. Nilai k kecil membuat tiap model belajar dari setengah data saja, sehingga skor cenderung merendahkan kemampuan sesungguhnya — bias pesimistik. Nilai k=n (leave-one-out cross-validation, LOOCV) membuat tiap model belajar dari hampir seluruh data, tetapi n model yang saling tumpang tindih menghasilkan skor-skor yang berkorelasi kuat; rata-ratanya gampang bergeser mengikuti data (variansi tinggi), dan biayanya melonjak karena kita melatih n model. Nilai k=5 atau k=10 berada di titik tengah yang lazim di praktik dan direkomendasikan literatur; scikit-learn memilih k=5 sebagai bawaannya.

Rangkuman #

  • Skor dari satu pembagian train_test_split bergantung pada random_state; pada data 60 baris kita menyaksikan akurasi bergerak 0,667-0,933 untuk model yang sama.
  • k-fold cross-validation membagi data menjadi k lipatan, menggilirkan peran validasi, dan menghasilkan k skor yang dirangkum menjadi rata-rata plus simpangan baku.
  • cross_val_score(model, X, y, cv=5) merangkum seluruh proses tersebut; untuk klasifikasi, pembagian lipatan otomatis memakai StratifiedKFold.
  • Pemilihan k adalah trade-off: k kecil condong pesimistik, k=n (LOOCV) bervarian tinggi dan mahal; k=5 atau k=10 adalah pilihan standar.
  • Skor cross-validation mengestimasi prosedur pelatihan, bukan performa final model di lapangan.

Estimasi yang stabil kini kita miliki, tetapi masih ada satu kelemahan senyap pada semua pembagian yang telah kita lakukan: keberuntungan bisa membuat kelas minoritas tercecer — bahkan kosong — pada data uji atau lipatan tertentu. Chapter berikutnya, Stratified Sampling, memastikan proporsi kelas tetap terjaga di setiap pembagian.

Referensi #

  1. scikit-learn — Model selection: cross-validation
  2. scikit-learn — Metrics and scoring: model evaluation
  3. scikit-learn — train_test_split