Alur kerja machine learning nyata jarang berhenti pada satu model: data harus diskalakan, kolom kategori harus di-encode, baru model dilatih. Ketika tahapan-tahapan itu kita tulis manual sebagai kode terpisah, satu kesalahan urutan kecil langsung merusak evaluasi: scaler yang belajar dari data uji, atau encoder yang menghasilkan kolom berbeda di data latih dan data baru. Class Pipeline dari scikit-learn menyatukan seluruh tahapan menjadi satu objek yang tidak mungkin salah urutan. Pada chapter ini kita lihat dulu betapa liciknya data leakage, lalu menanganinya dengan Pipeline dan ColumnTransformer.

Bahaya Data Leakage #

Data leakage terjadi ketika informasi dari data uji (atau data masa depan) bocor ke proses training, sehingga evaluasi terlalu optimistis. Bentuk paling halusnya: menjalankan StandardScaler().fit() pada seluruh data sebelum di-split. Simulasi berikut memakai data uji yang sebarannya bergeser dari data latih, situasi umum pada data dunia nyata:

python
import numpy as np
from sklearn.preprocessing import StandardScaler

rng = np.random.RandomState(42)
X_latih = rng.normal(100, 10, (80, 2))
X_uji = rng.normal(140, 10, (20, 2))

# CARA SALAH: scaler belajar dari data latih + data uji sekaligus
skala_bocor = StandardScaler().fit(np.vstack([X_latih, X_uji]))
X_latih_bocor = skala_bocor.transform(X_latih)
print("Mean yang dipelajari scaler (bocor) :", skala_bocor.mean_.round(1))
print("Std yang dipelajari scaler (bocor)  :", skala_bocor.scale_.round(1))
print("Data latih setelah scaling (bocor)  :",
      f"mean={X_latih_bocor.mean(axis=0).round(3)}, std={X_latih_bocor.std(axis=0).round(3)}")

# CARA BENAR: scaler hanya belajar dari data latih
skala = StandardScaler().fit(X_latih)
X_latih_benar = skala.transform(X_latih)
X_uji_benar = skala.transform(X_uji)
print("Mean yang dipelajari scaler (benar) :", skala.mean_.round(1))
print("Data latih setelah scaling (benar)  :",
      f"mean={X_latih_benar.mean(axis=0).round(3)}, std={X_latih_benar.std(axis=0).round(3)}")
print("Data uji setelah scaling (benar)    :",
      f"mean={X_uji_benar.mean(axis=0).round(3)}")

Output yang Dihasilkan:

text
Mean yang dipelajari scaler (bocor) : [106.8 108.3]
Std yang dipelajari scaler (bocor)  : [18.4 19.5]
Data latih setelah scaling (bocor)  : mean=[-0.443 -0.431], std=[0.487 0.496]
Mean yang dipelajari scaler (benar) : [98.7 99.9]
Data latih setelah scaling (benar)  : mean=[ 0. -0.], std=[1. 1.]
Data uji setelah scaling (benar)    : mean=[4.551 4.341]

Perhatikan hasil keduanya. Scaler yang benar mempelajari mean sekitar 98.7-99.9 (statistik data latih) dan menghasilkan data latih ber-rerata 0 bersimpangan 1. Scaler yang bocor justru mempelajari mean 106.8-108.3 dan std 18.4-19.5, angka-angka yang terkontaminasi 20 baris data uji bergeser; akibatnya data latih hasil transformasinya tidak lagi ber-rerata 0. Baris terakhir juga jujur: setelah scaling yang benar, mean data uji menjadi 4.5, pertanda data uji memang berbeda dari data latih, informasi yang semestinya baru ditemukan model saat evaluasi, bukan dinikmati sejak preprocessing.

Pipeline: Satu Objek untuk Semua Tahap #

Class Pipeline merangkai transformer dan model menjadi satu kesatuan dengan satu fit() dan satu predict(). Operator Pipeline menjamin urutan dan kebersihan: saat fit() dipanggil, setiap tahapan hanya melihat data yang dilewatkan tahapan sebelumnya, dan saat predict() dipanggil, transformer memakai parameter yang dipelajari saat training:

python
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline

rng = np.random.RandomState(42)
X = np.vstack([rng.normal([0, 0], 1, (60, 2)), rng.normal([4, 4], 1, (60, 2))])
y = np.array([0] * 60 + [1] * 60)
Xl, Xu, yl, yu = train_test_split(X, y, test_size=0.3, random_state=42)

pipeline = Pipeline([
    ("scaler", StandardScaler()),
    ("model", KNeighborsClassifier(n_neighbors=5)),
])
pipeline.fit(Xl, yl)
print(f"Akurasi pipeline: {pipeline.score(Xu, yu):.2%}")

skor = cross_val_score(pipeline, X, y, cv=5)
print("Skor cross-validation:", skor.round(3))
print(f"Rata-rata: {skor.mean():.3f}")

Output yang Dihasilkan:

text
Akurasi pipeline: 100.00%
Skor cross-validation: [1. 1. 1. 1. 1.]
Rata-rata: 1.000

Setiap elemen pipeline adalah pasangan ("nama", objek). Saat pipeline.fit(Xl, yl) berjalan, StandardScaler memanggil fit() lalu transform(), dan outputnya mengalir menjadi input KNeighborsClassifier.fit(). Kekuatan sesungguhnya terlihat di cross_val_score(): karena pipeline adalah satu objek, scaler di-refit ulang dari nol di tiap lipatan menggunakan hanya data latih lipatan itu. Menulis hal yang sama secara manual memerlukan perulangan yang panjang dan rawan salah.

ColumnTransformer untuk Data Campuran #

Dataset nyata mencampur kolom numerik dan kategorik yang butuh perlakuan berbeda. Class ColumnTransformer menerapkan transformer berbeda ke kolom berbeda dalam satu pernyataan:

python
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder

df = pd.DataFrame({
    "usia": [22, 25, 30, 35, 40, 45, 50, 55],
    "kota": ["Bandung", "Bandung", "Jakarta", "Jakarta",
             "Bandung", "Surabaya", "Jakarta", "Surabaya"],
    "berlangganan": [0, 0, 1, 1, 0, 1, 1, 1],
})
pra_pemroses = ColumnTransformer([
    ("angka", StandardScaler(), ["usia"]),
    ("kategori", OneHotEncoder(), ["kota"]),
])
X_terproses = pra_pemroses.fit_transform(df[["usia", "kota"]])
print(X_terproses.round(2))

Output yang Dihasilkan:

text
[[-1.42  1.    0.    0.  ]
 [-1.15  1.    0.    0.  ]
 [-0.7   0.    1.    0.  ]
 [-0.25  0.    1.    0.  ]
 [ 0.2   1.    0.    0.  ]
 [ 0.65  0.    0.    1.  ]
 [ 1.1   0.    1.    0.  ]
 [ 1.56  0.    0.    1.  ]]

Kolom usia diskalakan dengan StandardScaler (kolom pertama output), sementara kota dipecah OneHotEncoder menjadi tiga kolom biner (tiga kolom berikutnya), semuanya dalam satu panggilan fit_transform(). ColumnTransformer inilah cara standar menggabungkan kedua teknik dari chapter Feature Engineering tanpa menyentuh DataFrame secara manual.

Tuning dan Penyimpanan Pipeline #

Pipeline berintegrasi mulus dengan GridSearchCV dan penyimpanan model. Nama hyperparameter ditulis dengan format nama_tahap__nama_parameter, sehingga scikit-learn tahu parameter mana yang dituju:

python
import joblib
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV

iris = load_iris()
pipeline_iris = Pipeline([
    ("scaler", StandardScaler()),
    ("model", LogisticRegression(max_iter=200)),
])
param = {"model__C": [0.1, 1, 10]}
pencarian = GridSearchCV(pipeline_iris, param, cv=5)
pencarian.fit(iris.data, iris.target)
print(f"Parameter terbaik: {pencarian.best_params_}")
print(f"Skor terbaik: {pencarian.best_score_:.3f}")

joblib.dump(pipeline, "pipeline_knn.joblib")
pipeline_dimuat = joblib.load("pipeline_knn.joblib")
print("Prediksi identik:", np.array_equal(
    pipeline.predict(Xu), pipeline_dimuat.predict(Xu)
))

Output yang Dihasilkan:

text
Parameter terbaik: {'model__C': 10}
Skor terbaik: 0.973
Prediksi identik: True

Grid search menguji tiga nilai C (kekuatan regularisasi logistic regression) pada pipeline lengkap, dan setiap kombinasi dievaluasi dengan scaler yang di-refit per lipatan. Baris joblib.dump() menyimpan pipeline utuh, scaler beserta model, sebagai satu file; saat dimuat kembali, prediksinya identik bit demi bit. Detail penyimpanan dan risiko versinya kita bedah pada chapter Model Serialization.

Sebagai penutup, berikut perbandingan pengorganisasian kode sebelum dan sesudah pipeline:

AspekTanpa PipelineDengan Pipeline
Scaling data baruIngat memanggil scaler.transform() manualOtomatis di dalam predict()
Cross-validationRisiko scaler bocor antar lipatanScaler di-refit per lipatan, aman
TuningGrid terpisah per tahapSatu grid, format tahap__param
PenyimpananSimpan scaler dan model terpisahSatu file berisi semuanya
DeployReplikasi preprocessing di serverMuat satu objek, langsung prediksi