Chapter penutup Section Pengantar Deep Learning meninggalkan satu utang: rantai StandardScaler + model yang dirangkai sementara dengan make_pipeline belum menjamin apa pun — scaling masih bisa bocor ke fold validasi, hyperparameter preprocessing belum bisa di-tuning bersama model, dan seluruh rantai belum bisa diperlakukan sebagai satu artefak. Kelas Pipeline dari scikit-learn membayar utang tersebut. Pipeline merangkai langkah preprocessing dan model menjadi satu estimator: satu fit(), satu predict(), satu objek yang bisa di-cross-validasi, di-tuning, dan disimpan utuh. Chapter ini membedah anatomi Pipeline, membuktikan mekanisme yang membuat Pipeline aman selama cross-validation, lalu mengintegrasikannya dengan GridSearchCV.

Masalah yang Dijawab Pipeline #

Tanpa Pipeline, alur kerja machine learning terserak menjadi variabel-variabel lepas: scaler, encoder, matriks X_transformed, lalu model. Pola tersebut menimbulkan tiga risiko nyata. Pertama, kesalahan urutan fit/transform: Chapter Data Leakage dan Cara Mencegahnya telah membuktikan bahwa scaler yang di-fit pada seluruh data menghasilkan skor evaluasi yang tidak lagi bisa dipercaya — chapter tersebut menjelaskan mengapa kebocoran terjadi; chapter ini membangun bagaimana mencegahnya secara otomatis. Kedua, duplikasi kode antar konteks: preprocessing yang sama harus ditulis ulang saat tuning, saat evaluasi, dan saat inferensi data baru — satu perbedaan kecil saja menciptakan model latih dan model saji yang tidak sinkron. Ketiga, tidak bisa di-tuning sebagai satu kesatuan: hyperparameter preprocessing (misalnya k pada seleksi fitur) dan hyperparameter model harus dioptimalkan bersama, bukan terpisah.

Pipeline menjawab ketiganya dengan satu kontrak sederhana: seluruh langkah menjadi tanggung jawab satu objek. Tabel berikut merangkum pergeseran pola kerjanya.

AspekRantai langkah lepasRangkaian di dalam Pipeline
Keamanan leakageBergantung disiplin manual tiap sel kodeTransformer di-refit otomatis pada data latih saja
Tuning hyperparameterPreprocessing dan model di-tuning terpisahSatu param_grid menembus semua langkah
Prediksi data baruKode preprocessing ditulis ulangpipeline.predict(X_baru)
Artefak akhirBanyak objek terpisahSatu objek siap disimpan

Anatomi Pipeline: Rangkaian Langkah Bernama #

Objek Pipeline dibangun dari daftar pasangan (nama, objek). Setiap langkah perantara harus memiliki metode fit() dan transform() (misalnya StandardScaler, OneHotEncoder, SelectKBest); langkah terakhir boleh berupa estimator apa pun. Nama langkah bebas kita pilih — asalkan tanpa karakter __ — dan menjadi alamat resmi untuk mengakses maupun men-tuning langkah tersebut.

python
from sklearn.pipeline import Pipeline

pipeline = Pipeline([
    ("skala", StandardScaler()),          # langkah 1: transformer
    ("seleksi", SelectKBest(k=4)),        # langkah 2: transformer
    ("model", LogisticRegression()),      # langkah terakhir: estimator
])
pipeline.fit(X_latih, y_latih)
pipeline.predict(X_baru)

Saat fit() dipanggil, Pipeline menjalankan fit_transform() pada setiap langkah perantara secara berurutan, lalu fit() pada langkah terakhir. Saat predict() dipanggil, Pipeline menjalankan transform() saja pada semua langkah perantara — statistik yang sudah dipelajari dipakai, tidak dihitung ulang dari data baru.

Setelah pipeline dilatih, langkah di dalamnya dapat diakses lewat atribut named_steps — misalnya pipeline.named_steps["skala"].mean_ — tanpa membongkar rangkaian. Pipeline juga mendukung pengirisan: pipeline[:-1] mengembalikan sub-pipeline tanpa langkah terakhir, berguna untuk memeriksa fitur keluaran preprocessing secara terpisah.

Mengapa Pipeline Aman Selama Cross-Validation #

Inilah bagian terpenting chapter ini. cross_val_score bekerja dengan meng-clone estimator lalu melatihnya dari nol pada tiap lipatan. Ketika estimator yang dinilai adalah sebuah Pipeline, yang di-clone dan dilatih ulang adalah seluruh rantai — setiap transformer di-refit hanya pada data latih lipatan tersebut, dan fold validasi hanya dilewatkan transform(). Statistik scaler, skor seleksi fitur, kategori encoder: semuanya dihitung ulang per lipatan tanpa pernah menyentuh baris validasi. Kebocoran tidak sekadar “kita hindari”; kebocoran menjadi tidak mungkin secara struktural.

Demonstrasi berikut memakai tabel 100 baris dengan 22 kolom: umur dan pendapatan_juta yang lemah informatif, plus 20 kolom survei_XX berisi derau murni — meniru tabel nyata yang kaya kolom tetapi miskin pola. Jalur pertama melakukan preprocessing manual pada seluruh data sebelum cross-validation; jalur kedua memasukkan preprocessing yang persis sama ke dalam Pipeline.

python
# Demonstrasi inti chapter ini: mengapa Pipeline mencegah data leakage
# SELAMA cross-validation. Dua jalur memakai kode preprocessing yang sama
# (StandardScaler + SelectKBest + LogisticRegression); satu dikerjakan
# manual pada seluruh data sebelum cross-validation, satu dibungkus
# Pipeline. Selisih skornya adalah harga kebocoran.

import numpy as np
import pandas as pd
from sklearn.base import clone
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

# ---------- Data sintetis: 100 nasabah, target berlangganan ----------
# Dua fitur lemah informatif (umur, pendapatan) + 20 kolom survei yang
# isinya derau murni - meniru tabel nyata yang kaya kolom tapi miskin pola.
rng = np.random.default_rng(42)
n = 100

umur = rng.normal(40, 8, size=n)
pendapatan = rng.normal(10, 2, size=n)
logit = 0.25 * (umur - 40) / 8 - 0.25 * (pendapatan - 10) / 2
logit = logit + 0.9 * rng.normal(0, 1, size=n)
y = pd.Series(
    ((1 / (1 + np.exp(-logit))) > 0.5).astype(int), name="berlangganan"
)

data = {"umur": umur, "pendapatan_juta": pendapatan}
for i in range(1, 21):
    data[f"survei_{i:02d}"] = rng.normal(0, 1, size=n)
X = pd.DataFrame(data)

print(f"== Data: {X.shape[0]} baris x {X.shape[1]} kolom, "
      f"proporsi kelas 1 = {y.mean():.3f} ==")

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# ---------- Jalur 1: preprocessing manual pada SELURUH data ----------
# Pola yang sering muncul di notebook: scaling dan seleksi fitur
# di-fit ke seluruh dataset, lalu cross_val_score hanya menilai modelnya.
skala_penuh = StandardScaler().fit(X)
X_terskala = pd.DataFrame(
    skala_penuh.transform(X), columns=X.columns, index=X.index
)
seleksi_penuh = SelectKBest(f_classif, k=4).fit(X_terskala, y)
X_terpilih = seleksi_penuh.transform(X_terskala)

skor_bocor = cross_val_score(
    LogisticRegression(max_iter=1000), X_terpilih, y, cv=cv
)
print("\n== Jalur manual: preprocessing di-fit pada seluruh data ==")
print("fitur terpilih:", X.columns[seleksi_penuh.get_support()].tolist())
print("skor per lipatan:", np.round(skor_bocor, 3).tolist())
print(f"rerata CV       : {skor_bocor.mean():.3f}")

# ---------- Jalur 2: preprocessing DI DALAM Pipeline ----------
# Transformer tidak pernah menyentuh data di luar lipatan latihnya
# karena cross_val_score memanggil fit() ulang seluruh pipeline tiap lipatan.
pipeline = Pipeline([
    ("skala", StandardScaler()),
    ("seleksi", SelectKBest(f_classif, k=4)),
    ("model", LogisticRegression(max_iter=1000)),
])
skor_pipeline = cross_val_score(pipeline, X, y, cv=cv)
print("\n== Jalur Pipeline: preprocessing ikut di-refit tiap lipatan ==")
print("skor per lipatan:", np.round(skor_pipeline, 3).tolist())
print(f"rerata CV       : {skor_pipeline.mean():.3f}")

# ---------- Bukti mekanisme: statistik transformer berubah tiap lipatan ----------
# Perhatikan rata-rata 'umur' dan fitur terpilih berbeda pada tiap lipatan:
# itulah bukti scaler dan seleksi benar-benar di-fit ulang dari 80 baris
# data latih fold tersebut - bukan memakai statistik 100 baris penuh.
print("\n== Bukti refit per lipatan (dari perulangan manual) ==")
skor_manual = []
for fold, (idx_latih, idx_uji) in enumerate(cv.split(X, y), start=1):
    p = clone(pipeline)
    p.fit(X.iloc[idx_latih], y.iloc[idx_latih])
    skor_manual.append(p.score(X.iloc[idx_uji], y.iloc[idx_uji]))
    fitur_fold = X.columns[p.named_steps["seleksi"].get_support()].tolist()
    rata_umur = p.named_steps["skala"].mean_[0]
    print(f"lipatan {fold}: rata-rata umur latih = {rata_umur:.2f} | "
          f"fitur: {fitur_fold}")

# ---------- Verifikasi: hasil manual identik dengan cross_val_score ----------
skor_manual = np.array(skor_manual)
identik = np.array_equal(skor_manual, skor_pipeline)
print("\n== Verifikasi: perulangan manual vs cross_val_score ==")
print("skor manual   :", np.round(skor_manual, 3).tolist())
print("skor pipeline :", np.round(skor_pipeline, 3).tolist())
print("IDENTIK       :", identik)
print(f"\nselisih rerata jalur manual - jalur Pipeline: "
      f"{skor_bocor.mean() - skor_pipeline.mean():+.3f}")

Output yang Dihasilkan:

text
== Data: 100 baris x 22 kolom, proporsi kelas 1 = 0.450 ==

== Jalur manual: preprocessing di-fit pada seluruh data ==
fitur terpilih: ['survei_02', 'survei_05', 'survei_18', 'survei_20']
skor per lipatan: [0.65, 0.65, 0.75, 0.7, 0.45]
rerata CV       : 0.640

== Jalur Pipeline: preprocessing ikut di-refit tiap lipatan ==
skor per lipatan: [0.55, 0.45, 0.5, 0.5, 0.45]
rerata CV       : 0.490

== Bukti refit per lipatan (dari perulangan manual) ==
lipatan 1: rata-rata umur latih = 39.40 | fitur: ['umur', 'survei_02', 'survei_06', 'survei_18']
lipatan 2: rata-rata umur latih = 39.51 | fitur: ['survei_02', 'survei_03', 'survei_05', 'survei_18']
lipatan 3: rata-rata umur latih = 39.90 | fitur: ['umur', 'pendapatan_juta', 'survei_02', 'survei_20']
lipatan 4: rata-rata umur latih = 39.62 | fitur: ['pendapatan_juta', 'survei_02', 'survei_17', 'survei_20']
lipatan 5: rata-rata umur latih = 39.56 | fitur: ['survei_02', 'survei_05', 'survei_18', 'survei_20']

== Verifikasi: perulangan manual vs cross_val_score ==
skor manual   : [0.55, 0.45, 0.5, 0.5, 0.45]
skor pipeline : [0.55, 0.45, 0.5, 0.5, 0.45]
IDENTIK       : True

selisih rerata jalur manual - jalur Pipeline: +0.150

Mari kita telaah baris-baris kode yang berperan penting:

  1. Baris 19–33 (data sintetis): rng berseed 42 membangkitkan 100 baris. Label dihitung dari umur dan pendapatan dengan koefisien lemah (0,25) ditambah derau besar, sehingga pola sungguhan nyaris tertutup — kejujuran yang akan dibongkar oleh kedua jalur.
  2. Baris 38 (cv): StratifiedKFold lima lipatan dengan shuffle=True, random_state=42 agar pembagian lipatan reproducible dan proporsi kelas terjaga.
  3. Baris 43–52 (jalur manual): StandardScaler().fit(X) dan SelectKBest(...).fit(X_terskala, y) membaca seluruh 100 baris — termasuk 20 baris yang kelak menjadi fold validasi. cross_val_score kemudian hanya menilai LogisticRegression pada matriks yang sudah terlanjur terkontaminasi.
  4. Baris 61–66 (jalur Pipeline): tiga langkah yang sama dibungkus Pipeline; cross_val_score kini menilai seluruh rantai, sehingga scaler dan seleksi di-refit per lipatan.
  5. Baris 77–84 (bukti refit): perulangan manual meng-clone pipeline, melatihnya pada 80 baris latih tiap lipatan, lalu membaca named_steps["skala"].mean_[0] dan fitur terpilih named_steps["seleksi"]. Kedua nilai berubah antar lipatan — bukti konkret statistik dihitung ulang, bukan dibawa-bawa dari data penuh.
  6. Baris 87–94 (verifikasi): skor perulangan manual dibandingkan element-wise dengan np.array_equal terhadap cross_val_score — hasilnya identik, membuktikan bahwa Pipeline tidak menambah mekanisme baru, melainkan mengotomatiskan persis perulangan yang benar.

Cara membaca hasilnya: jalur manual melaporkan rerata 0,640 dan memilih empat kolom survei_02, survei_05, survei_18, survei_20 — semuanya derau. Skor ANOVA pada 100 baris membuat kolom derau tampak informatif karena fold validasi ikut menentukan pemilihannya; model kemudian “menghafal” kebetulan tersebut. Jalur Pipeline melaporkan 0,490 — dekat dengan lempar koin, dan itulah kebenarannya pada data dengan pola lemah. Selisih +0,150 adalah harga kebocoran: angka yang terlihat seperti model siap pakai, padahal tidak ada pola yang bisa digeneralisasi.

Pipeline di Dalam GridSearchCV #

Karena Pipeline adalah estimator biasa, Pipeline bisa langsung dimasukkan ke GridSearchCV. Aturan penulisannya satu: parameter milik langkah dituju dengan nama langkah + dua garis bawah + nama parameter. Parameter C milik langkah "model" menjadi "model__C"; parameter k milik langkah "seleksi" menjadi "seleksi__k". Section Optimasi dan Hyperparameter menunjukkan GridSearchCV pada estimator tunggal; pola yang sama kini menjangkau seluruh rantai.

Contoh kedua memakai tabel iklan yang seluruh kolomnya kategorikal — OneHotEncoder (materi Chapter Encoding Variabel Kategorikal) dan LogisticRegression dirangkai, di-tuning, lalu dipakai memprediksi data baru.

python
# Pipeline untuk data kategorikal murni + integrasi GridSearchCV.
# Setiap langkah Pipeline bernama; nama tersebut menjadi prefiks parameter
# saat tuning: "model__C" berarti parameter C milik langkah "model".

import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, StratifiedKFold
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder

# ---------- Data kampanye iklan: seluruh kolom kategorikal ----------
rng = np.random.default_rng(42)
n = 120

kota = rng.choice(["Bandung", "Jakarta", "Surabaya"], size=n)
perangkat = rng.choice(["ponsel", "desktop"], size=n)
jenis_iklan = rng.choice(["edukasi", "promosi", "hiburan"], size=n)

# Pola sungguhan: ponsel dan iklan promosi mendorong klik, hiburan menahan.
logit = (
    1.6 * (perangkat == "ponsel")
    + 1.1 * (jenis_iklan == "promosi")
    - 1.1 * (jenis_iklan == "hiburan")
    - 0.8
    + rng.normal(0, 0.5, size=n)
)
klik = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)

X = pd.DataFrame({"kota": kota, "perangkat": perangkat, "jenis_iklan": jenis_iklan})
y = pd.Series(klik, name="klik")
print(f"== Data: {X.shape[0]} baris, proporsi klik = {y.mean():.3f} ==")

# ---------- Pipeline: OneHotEncoder -> LogisticRegression ----------
pipeline = Pipeline([
    ("encoder", OneHotEncoder(handle_unknown="ignore")),
    ("model", LogisticRegression(max_iter=1000)),
])
pipeline.fit(X, y)

# Mengakses langkah lewat named_steps tanpa melepas rangkaian.
kategori_terpelajari = [
    daftar.tolist() for daftar in pipeline.named_steps["encoder"].categories_
]
print("kategori per kolom:", kategori_terpelajari)

# Irisan pipeline[:-1] adalah sub-pipeline preprocessing; berguna untuk
# memeriksa fitur keluaran tanpa melibatkan model.
print("fitur hasil encoding:", pipeline[:-1].get_feature_names_out().tolist())

# ---------- GridSearchCV menembus pipeline lewat nama langkah ----------
cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
grid = GridSearchCV(pipeline, {"model__C": [0.01, 0.1, 1.0, 10.0]}, cv=cv)
grid.fit(X, y)

print("\n== Tuning hyperparameter lewat sintaks dua garis bawah ==")
for c, skor in zip(
    grid.cv_results_["param_model__C"], grid.cv_results_["mean_test_score"]
):
    print(f"model__C = {float(c):5.2f} : rerata CV {skor:.3f}")
print("konfigurasi juara:", grid.best_params_)
print(f"skor juara       : {grid.best_score_:.3f}")

# ---------- Prediksi data baru, termasuk kota yang tak pernah dilihat ----------
# handle_unknown="ignore" membuat kota "Medan" dipetakan ke vektor nol,
# sehingga pipeline tetap memberi jawaban tanpa galat runtime.
baru = pd.DataFrame({
    "kota": ["Medan", "Bandung"],
    "perangkat": ["ponsel", "desktop"],
    "jenis_iklan": ["promosi", "hiburan"],
})
prediksi = grid.predict(baru)
peluang = grid.predict_proba(baru)[:, 1]
print("\n== Prediksi data baru ==")
for i in range(len(baru)):
    print(f"{baru['perangkat'][i]:8s} {baru['jenis_iklan'][i]:8s} "
          f"{baru['kota'][i]:8s} -> klik={prediksi[i]} "
          f"p={peluang[i]:.3f}")

Output yang Dihasilkan:

text
== Data: 120 baris, proporsi klik = 0.508 ==
kategori per kolom: [['Bandung', 'Jakarta', 'Surabaya'], ['desktop', 'ponsel'], ['edukasi', 'hiburan', 'promosi']]
fitur hasil encoding: ['kota_Bandung', 'kota_Jakarta', 'kota_Surabaya', 'perangkat_desktop', 'perangkat_ponsel', 'jenis_iklan_edukasi', 'jenis_iklan_hiburan', 'jenis_iklan_promosi']

== Tuning hyperparameter lewat sintaks dua garis bawah ==
model__C =  0.01 : rerata CV 0.683
model__C =  0.10 : rerata CV 0.725
model__C =  1.00 : rerata CV 0.750
model__C = 10.00 : rerata CV 0.767
konfigurasi juara: {'model__C': 10.0}
skor juara       : 0.767

== Prediksi data baru ==
ponsel   promosi  Medan    -> klik=1 p=0.910
desktop  hiburan  Bandung  -> klik=0 p=0.088

Bedah baris-per-baris untuk contoh kedua:

  1. Baris 13–31 (data kategorikal murni): tiga kolom teks dibangkitkan dari rng berseed; pola klik ditentukan perangkat dan jenis_iklan — kota sengaja tidak berpengaruh.
  2. Baris 35–39 (pipeline dua langkah): OneHotEncoder(handle_unknown="ignore") menjadi langkah pertama; pipeline.fit(X, y) melatih encoder dan model sekaligus, dan y diteruskan ke langkah yang membutuhkannya (encoder mengabaikannya).
  3. Baris 41–49 (akses langkah): named_steps["encoder"].categories_ membaca kategori yang terpelajari; pipeline[:-1].get_feature_names_out() menampilkan delapan fitur keluaran dari sub-pipeline preprocessing.
  4. Baris 52–54 (param_grid): kunci "model__C" menembus pipeline hingga parameter C milik LogisticRegression; grid empat nilai dievaluasi dengan tiga lipatan terstratifikasi.
  5. Baris 57–62 (membaca hasil): cv_results_["param_model__C"] dan mean_test_score dicetak per konfigurasi, lalu best_params_ dan best_score_ menutup laporan — pola yang sama dengan Chapter Grid Search.
  6. Baris 67–78 (data baru): baris pertama memakai kota Medan yang tidak pernah dilihat encoder; handle_unknown="ignore" memetakan seluruh kolom kota Medan ke nol sehingga prediksi tetap keluar (peluang 0,910 dipicu ponsel + promosi), bukan galat runtime.

Hasil tuning memperlihatkan monoton yang rapi: C=0,01 (regularisasi kuat) menahan model di 0,683, lalu naik bertahap hingga C=10,0 mencapai 0,767. Seluruh selisih tersebut murni berasal dari kekuatan regularisasi — encoder dan model mengalami kombinasi yang sama di setiap konfigurasi karena keduanya berada dalam satu Pipeline.

Keterbatasan Pipeline Polos #

Pipeline polos menerapkan satu transformer pada seluruh kolom. Contoh pertama bekerja karena semua 22 kolomnya numerik; contoh kedua karena semua kolomnya kategorikal. Tabel dunia nyata hampir selalu campuran — umur (numerik) berdampingan dengan kota (teks) — dan tidak ada satu transformer pun yang tepat untuk keduanya sekaligus. Solusinya adalah perutean per kolom: satu jalur untuk kolom numerik, satu jalur untuk kolom kategorikal, digabung kembali sebelum masuk model. scikit-learn menyediakan kelas ColumnTransformer untuk tugas itu — beserta pemilih kolom otomatis, opsi passthrough, dan pelacakan nama fitur — materi penuh Chapter ColumnTransformer untuk Preprocessing Terpadu, tujuan kita berikutnya.

Referensi Resmi #