Notebook Bab 2 - Pipeline, Validasi, dan Data Leakage
Open In Colab
Notebook Bab 2 ini punya dua bagian. Bagian Demo tinggal Anda jalankan lalu amati keluarannya; bagian Mini Project berisi soal dan data yang Anda kerjakan sendiri.
Demo memakai snapshot UCI Online Retail. Target event-nya adalah pembatalan invoice-line. Logistic regression di dalam Pipeline dipakai sebagai penggaris tetap; yang kita ubah hanya batas evaluasinya: random row split, group split, shuffled K-fold, dan TimeSeriesSplit.
Persiapan
Jalankan sel impor di bawah. Di Google Colab seluruh pustaka ini sudah tersedia; di laptop, pasang dulu lewat pip install -r requirements.txt.
from pathlib import Pathimport numpy as npimport pandas as pdfrom sklearn.model_selection import StratifiedKFold, GroupKFold, TimeSeriesSplit, KFold, cross_val_scorefrom sklearn.compose import ColumnTransformerfrom sklearn.preprocessing import OneHotEncoder, StandardScalerfrom sklearn.impute import SimpleImputerfrom sklearn.pipeline import Pipelinefrom sklearn.linear_model import LogisticRegressionRANDOM_STATE =42rng = np.random.default_rng(RANDOM_STATE)from pathlib import Pathimport jsonimport urllib.requestimport urllib.parseDATA_BASE_URL ='https://raw.githubusercontent.com/muhammad-zainal-muttaqin/NulisBuku/main/website/notebooks/data/section1'def section_data_dir(name):"""Folder data Bagian 1: pakai salinan lokal bila ada; jika tidak (mis. di Google Colab), unduh berkas dari repo GitHub sesuai manifest."""for base in (Path('data/section1'), Path('../data/section1')):if (base / name).exists():return base / name cache = Path('_nb_data') / nameifnot (cache /'manifest.json').exists(): cache.mkdir(parents=True, exist_ok=True) base_url = DATA_BASE_URL +'/'+ name manifest = json.loads(urllib.request.urlopen(base_url +'/manifest.json').read().decode('utf-8'))for rel in manifest: dest = cache / rel dest.parent.mkdir(parents=True, exist_ok=True)ifnot dest.exists(): url = base_url +'/'+'/'.join(urllib.parse.quote(seg) for seg in rel.split('/')) urllib.request.urlretrieve(url, dest) (cache /'manifest.json').write_text(json.dumps(manifest), encoding='utf-8')return cachedef make_ohe(**kwargs):try:return OneHotEncoder(handle_unknown='ignore', sparse_output=True, **kwargs)exceptTypeError:return OneHotEncoder(handle_unknown='ignore', **kwargs)DATA_DIR = section_data_dir('ch01_online_retail')print(f'Setup selesai. Data: {DATA_DIR}')
Section 1 - Demo: Pipeline yang Valid pada Data Berkelompok dan Temporal
Kita memakai baris invoice produk dari Online Retail. Target event-nya adalah apakah baris tersebut merupakan pembatalan. Target ini jarang pada snapshot penuh, dan baris-baris dari pelanggan yang sama muncul berulang sepanjang waktu. Karena itu, metrik yang dipakai adalah PR-AUC (average_precision), bukan akurasi.
Muat snapshot Online Retail
Untuk runtime yang ringan, demo model memakai semua baris pembatalan dan sampel baris non-pembatalan. Statistik imbalance tetap dihitung dari snapshot penuh agar denominator-nya jelas.
Baris snapshot dengan CustomerID dan UnitPrice positif: 406,789
Cancellation rate snapshot penuh: 2.19%
Baris demo: 68,905
Cancellation rate demo setelah sampling: 12.92%
Pelanggan unik dalam demo: 4,086
Demo 1: random split tampak nyaman, group split lebih jujur
Di sel ini CustomerID sengaja dimasukkan sebagai fitur kategorikal untuk memperlihatkan bahaya split acak. Jika pelanggan yang sama muncul di train dan validasi, model dapat memanfaatkan jejak identitas pelanggan. Group split memaksa pelanggan validasi belum pernah muncul saat training. Modelnya tetap sama; batas evaluasinya yang berubah.
🔎 Amati (Demo 1). PR-AUC random row CV lebih tinggi karena pelanggan yang sama dapat muncul di dua sisi evaluasi. GroupKFold(CustomerID) menurunkan skor karena pertanyaannya lebih sulit dan lebih jujur: apakah pipeline bekerja pada pelanggan yang belum pernah dilihat? Encoding kategori, imputasi, scaling, dan model semuanya tetap berada di dalam Pipeline.
Demo 2: TimeSeriesSplit bekerja berdasarkan urutan baris
Sekarang kita urutkan invoice secara kronologis. TimeSeriesSplit tidak membaca kolom tanggal; ia memakai posisi baris. Karena itu, pengurutan sebelum split adalah bagian dari kontrak validasi temporal. Lagi-lagi modelnya tetap sama; yang berubah hanya apakah validasi menghormati arah waktu.
print('Contoh awal dan akhir data setelah diurutkan:')print(ordered[['InvoiceNo', 'CustomerID', 'InvoiceDate', 'StockCode', 'is_cancellation']].head(3).to_string(index=False))print('...')print(ordered[['InvoiceNo', 'CustomerID', 'InvoiceDate', 'StockCode', 'is_cancellation']].tail(3).to_string(index=False))
🔎 Amati (Demo 2). Shuffled K-fold pada data temporal masih mencampur masa lalu dan masa depan, sehingga PR-AUC terlihat mirip dengan random row CV. TimeSeriesSplit(gap=500) menjaga train selalu lebih awal daripada test dan menyisipkan buffer di batas fold. Selisih metrik bukan karena model berubah, melainkan karena batas evaluasi berubah.
Section 2 - Mini Project
Soal
Anda menerima data riwayat kunjungan pelanggan sebuah klinik. Tiap pelanggan (id_pelanggan) bisa memiliki beberapa baris kunjungan pada tanggal berbeda. Tujuannya memprediksi tagihan.
Bangun evaluasi yang bebas leakage:
Pilih strategi split yang tepat (perhatikan struktur kelompok pada id_pelanggan).
Susun pipeline (imputasi + scaling + encoding kategorikal + model) yang mem-fit parameter hanya pada data latih.
Laporkan estimasi performa dengan cross-validation yang sesuai.
Luaran: satu sel kode berisi pipeline dan skema validasi, ditambah 2-3 kalimat alasan mengapa strategi split pilihan Anda mencegah leakage.
Kriteria penilaian: (a) tidak ada fit pada data uji; (b) baris dengan id_pelanggan yang sama tidak tersebar ke latih dan uji sekaligus; (c) metrik dilaporkan dari data yang tidak tersentuh saat pelatihan.
# DATA AWAL (jangan diubah) - riwayat kunjungan klinik dengan struktur kelompok per pelanggan.n_baris =600id_unik = np.arange(1000, 1200) # 200 pelangganid_pelanggan = rng.choice(id_unik, size=n_baris) # beberapa kunjungan per pelangganefek_pelanggan =dict(zip(id_unik, rng.normal(0, 400000, id_unik.size)))df = pd.DataFrame({'id_pelanggan': id_pelanggan,'usia': rng.integers(18, 80, n_baris),'lama_kunjungan_menit': rng.normal(35, 12, n_baris).clip(5, None),'jenis_layanan': rng.choice(['umum', 'spesialis', 'darurat'], n_baris),})peta_jenis = {'umum': 0, 'spesialis': 600000, 'darurat': 1200000}df['tagihan'] = (50000+1500* df['usia'] +8000* df['lama_kunjungan_menit']+ df['jenis_layanan'].map(peta_jenis)+ df['id_pelanggan'].map(efek_pelanggan)+ rng.normal(0, 80000, n_baris))df.loc[rng.random(n_baris) <0.10, 'lama_kunjungan_menit'] = np.nanprint('Data awal:', df.shape, '| pelanggan unik:', df['id_pelanggan'].nunique())df.head()
Data awal: (600, 5) | pelanggan unik: 187
id_pelanggan
usia
lama_kunjungan_menit
jenis_layanan
tagihan
0
1144
32
33.800753
darurat
1.411539e+06
1
1019
57
47.413254
umum
1.854435e+05
2
1036
31
23.361600
spesialis
1.278066e+06
3
1078
33
NaN
umum
8.766586e+04
4
1172
75
42.017334
umum
1.498086e+05
# Kerjakan di sini.# Petunjuk: lihat sklearn.model_selection.GroupKFold dan sklearn.compose.ColumnTransformer.