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:
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:
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:
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:
Akurasi pipeline: 100.00%
Skor cross-validation: [1. 1. 1. 1. 1.]
Rata-rata: 1.000Setiap 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:
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:
[[-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:
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:
Parameter terbaik: {'model__C': 10}
Skor terbaik: 0.973
Prediksi identik: TrueGrid 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:
| Aspek | Tanpa Pipeline | Dengan Pipeline |
|---|---|---|
| Scaling data baru | Ingat memanggil scaler.transform() manual | Otomatis di dalam predict() |
| Cross-validation | Risiko scaler bocor antar lipatan | Scaler di-refit per lipatan, aman |
| Tuning | Grid terpisah per tahap | Satu grid, format tahap__param |
| Penyimpanan | Simpan scaler dan model terpisah | Satu file berisi semuanya |
| Deploy | Replikasi preprocessing di server | Muat satu objek, langsung prediksi |