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 Path
import numpy as np
import pandas as pd
from sklearn.model_selection import StratifiedKFold, GroupKFold, TimeSeriesSplit, KFold, cross_val_score
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression

RANDOM_STATE = 42
rng = np.random.default_rng(RANDOM_STATE)


from pathlib import Path
import json
import urllib.request
import urllib.parse

DATA_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') / name
    if not (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)
            if not 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 cache


def make_ohe(**kwargs):
    try:
        return OneHotEncoder(handle_unknown='ignore', sparse_output=True, **kwargs)
    except TypeError:
        return OneHotEncoder(handle_unknown='ignore', **kwargs)

DATA_DIR = section_data_dir('ch01_online_retail')
print(f'Setup selesai. Data: {DATA_DIR}')
Setup selesai. Snapshot: data\section1\ch01_online_retail\online_retail.parquet

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.

retail = pd.read_parquet(DATA_DIR / 'online_retail.parquet')
retail['InvoiceDate'] = pd.to_datetime(retail['InvoiceDate'])
retail = retail.dropna(subset=['CustomerID']).copy()
retail = retail[retail['UnitPrice'] > 0].copy()
retail['CustomerID'] = retail['CustomerID'].astype(int).astype(str)
retail['month'] = retail['InvoiceDate'].dt.month
retail['hour'] = retail['InvoiceDate'].dt.hour

full_rate = retail['is_cancellation'].mean()
cancel = retail[retail['is_cancellation']]
non_cancel = retail[~retail['is_cancellation']].sample(n=60000, random_state=RANDOM_STATE)
demo = pd.concat([cancel, non_cancel]).sample(frac=1, random_state=RANDOM_STATE).reset_index(drop=True)

print(f'Baris snapshot dengan CustomerID dan UnitPrice positif: {len(retail):,}')
print(f'Cancellation rate snapshot penuh: {full_rate:.2%}')
print(f'Baris demo: {len(demo):,}')
print(f'Cancellation rate demo setelah sampling: {demo["is_cancellation"].mean():.2%}')
print(f'Pelanggan unik dalam demo: {demo["CustomerID"].nunique():,}')
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.

features = ['CustomerID', 'StockCode', 'Country', 'UnitPrice', 'month', 'hour']
X = demo[features]
y = demo['is_cancellation'].astype(int)
groups = demo['CustomerID']

categorical = ['CustomerID', 'StockCode', 'Country']
numeric = ['UnitPrice', 'month', 'hour']

preprocess = ColumnTransformer([
    ('cat', make_ohe(min_frequency=20), categorical),
    ('num', Pipeline([
        ('imputer', SimpleImputer(strategy='median')),
        ('scaler', StandardScaler()),
    ]), numeric),
])

clf = Pipeline([
    ('preprocess', preprocess),
    ('model', LogisticRegression(max_iter=500, class_weight='balanced', solver='liblinear')),
])

random_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=RANDOM_STATE)
group_cv = GroupKFold(n_splits=5)

random_scores = cross_val_score(clf, X, y, cv=random_cv, scoring='average_precision')
group_scores = cross_val_score(clf, X, y, cv=group_cv, groups=groups, scoring='average_precision')

hasil_group = pd.DataFrame({
    'batas_evaluasi': ['Random row CV', 'GroupKFold(CustomerID)'],
    'pr_auc_mean': [random_scores.mean(), group_scores.mean()],
    'pr_auc_std': [random_scores.std(), group_scores.std()],
})
print(hasil_group.round(3).to_string(index=False))
        batas_evaluasi  pr_auc_mean  pr_auc_std
         Random row CV        0.464       0.007
GroupKFold(CustomerID)        0.302       0.028

🔎 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.

ordered = demo.sort_values('InvoiceDate').reset_index(drop=True)
X_ordered = ordered[features]
y_ordered = ordered['is_cancellation'].astype(int)

shuffled_kfold = KFold(n_splits=5, shuffle=True, random_state=RANDOM_STATE)
time_split = TimeSeriesSplit(n_splits=5, gap=500)

kfold_scores = cross_val_score(clf, X_ordered, y_ordered, cv=shuffled_kfold, scoring='average_precision')
time_scores = cross_val_score(clf, X_ordered, y_ordered, cv=time_split, scoring='average_precision')

hasil_time = pd.DataFrame({
    'batas_evaluasi': ['Shuffled KFold pada baris temporal', 'TimeSeriesSplit(gap=500)'],
    'pr_auc_mean': [kfold_scores.mean(), time_scores.mean()],
    'pr_auc_std': [kfold_scores.std(), time_scores.std()],
})
print(hasil_time.round(3).to_string(index=False))

rows = []
for fold, (train_idx, test_idx) in enumerate(time_split.split(X_ordered), start=1):
    rows.append({
        'fold': fold,
        'train_max': ordered.loc[train_idx, 'InvoiceDate'].max().date(),
        'test_min': ordered.loc[test_idx, 'InvoiceDate'].min().date(),
        'test_positive_rate': y_ordered.iloc[test_idx].mean(),
    })
print('\nRingkasan fold temporal:')
print(pd.DataFrame(rows).to_string(index=False))
                    batas_evaluasi  pr_auc_mean  pr_auc_std
Shuffled KFold pada baris temporal        0.462       0.014
          TimeSeriesSplit(gap=500)        0.302       0.021

Ringkasan fold temporal:
 fold  train_max   test_min  test_positive_rate
    1 2011-02-21 2011-02-24            0.127482
    2 2011-05-12 2011-05-15            0.139586
    3 2011-07-24 2011-07-27            0.122605
    4 2011-09-27 2011-09-28            0.130181
    5 2011-11-06 2011-11-08            0.099356
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))
Contoh awal dan akhir data setelah diurutkan:
InvoiceNo CustomerID         InvoiceDate StockCode  is_cancellation
   536366      17850 2010-12-01 08:28:00     22633            False
   536368      13047 2010-12-01 08:34:00     22914            False
   536367      13047 2010-12-01 08:34:00     22748            False
...
InvoiceNo CustomerID         InvoiceDate StockCode  is_cancellation
   581587      12680 2011-12-09 12:50:00     23254            False
   581587      12680 2011-12-09 12:50:00     22613            False
   581587      12680 2011-12-09 12:50:00     22555            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:

  1. Pilih strategi split yang tepat (perhatikan struktur kelompok pada id_pelanggan).
  2. Susun pipeline (imputasi + scaling + encoding kategorikal + model) yang mem-fit parameter hanya pada data latih.
  3. 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 = 600
id_unik = np.arange(1000, 1200)                      # 200 pelanggan
id_pelanggan = rng.choice(id_unik, size=n_baris)     # beberapa kunjungan per pelanggan
efek_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.nan

print('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.