Bagian 1 menutup dengan resep tertulis: median untuk imputasi, StandardScaler untuk numerik, dan OneHotEncoder untuk kota. Bagian ini mengubah resep tersebut menjadi objek kode yang sulit dilanggar secara tidak sengaja — seluruh langkah pembersihan dirangkai dalam satu ColumnTransformer, dibungkus Pipeline bersama estimator, sehingga statistik hanya boleh dipelajari dari data latih. Kita juga menguji ketahanan desain itu terhadap tiga skenario kebocoran: preprocessing yang di-fit ke seluruh data, dan fitur turunan target yang menyusup ke daftar kolom.
Resep Preprocessing dari Temuan Eksplorasi #
Setiap perlakuan pada tabel berikut dapat dilacak balik ke temuan Bagian 1 — tidak ada keputusan preprocessing yang berdiri tanpa bukti.
| Kelompok kolom | Temuan Bagian 1 | Perlakuan |
|---|---|---|
luas_m2, usia_bangunan | 12 dan 8 nilai hilang | SimpleImputer(strategy="median") |
| Empat kolom numerik | Skala berbeda jauh (meter, tahun, km) | StandardScaler setelah imputasi |
kota | Kategorikal nominal lima nilai | OneHotEncoder(handle_unknown="ignore") |
Imputasi memakai median, bukan rata-rata, karena dua salah ketik luas menggeser rata-rata dari 140,6 menjadi 141,7 m², sementara median praktis tak berubah (141,3). Penanda handle_unknown="ignore" menjaga pipeline tetap hidup saat inferensi menemui kota yang tidak ada di data latih — misalnya “Bandung” — dengan mengisi seluruh kolom dummy bernilai nol alih-alih menolak baris tersebut.
ColumnTransformer di Dalam Pipeline #
Skrip berikut membangun arsitektur lengkapnya, melatihnya pada 320 baris data latih, lalu membuktikan empat hal: nama fitur hasil transformasi, isi matriks setelah transformasi, prediksi langsung dari input mentah yang masih mengandung nilai hilang, dan perbandingan statistik yang dipelajari dari data latih versus seluruh data.
# Preprocessing terpadu yang aman dari data leakage.
# Fitur numerik: imputasi median + StandardScaler. Fitur kategorikal:
# OneHotEncoder. Semua langkah dibungkus ColumnTransformer di dalam
# Pipeline sehingga statistik hanya dipelajari dari data latih.
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import Ridge
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
BERKAS_CSV = "data/rumah.csv"
FITUR_NUM = ["luas_m2", "kamar_tidur", "usia_bangunan", "jarak_pusat_km"]
FITUR_KAT = ["kota"]
def bangun_pra(fitur_num=FITUR_NUM):
"""Bangun ColumnTransformer baru agar tiap eksperimen bersih."""
return ColumnTransformer(
transformers=[
(
"num",
Pipeline(
steps=[
("imputasi", SimpleImputer(strategy="median")),
("skala", StandardScaler()),
]
),
fitur_num,
),
(
"kat",
OneHotEncoder(handle_unknown="ignore", sparse_output=False),
FITUR_KAT,
),
]
)
df = pd.read_csv(BERKAS_CSV)
X = df.drop(columns=["harga_juta"])
y = df["harga_juta"]
pipeline = Pipeline(
steps=[("pra", bangun_pra()), ("regresi", Ridge(alpha=1.0))]
)
X_latih, X_uji, y_latih, y_uji = train_test_split(
X, y, test_size=0.2, random_state=42
)
pipeline.fit(X_latih, y_latih)
# ---- 1. Nama fitur hasil transformasi ----------------------------------
nama_fitur = pipeline.named_steps["pra"].get_feature_names_out()
print("== 1. Nama fitur hasil ColumnTransformer ==")
print(f"{len(nama_fitur)} fitur: " + ", ".join(nama_fitur))
# ---- 2. Matriks fitur setelah transformasi -----------------------------
X_uji_transform = pipeline.named_steps["pra"].transform(X_uji)
matriks = pd.DataFrame(X_uji_transform[:3, :], columns=nama_fitur).round(3)
print("\n== 2. Tiga baris pertama X_uji setelah transformasi ==")
print(matriks.to_string(index=False))
# ---- 3. Prediksi langsung dari input mentah ----------------------------
rumah_baru = pd.DataFrame(
{
"luas_m2": [95.0, 180.0],
"kamar_tidur": [3, 4],
"usia_bangunan": [np.nan, 2.0],
"jarak_pusat_km": [12.0, 5.0],
"kota": ["Depok", "Jakarta"],
}
)
prediksi = pipeline.predict(rumah_baru)
print("\n== 3. Prediksi langsung dari input mentah ==")
print(
"rumah 1: Depok 95 m2, 3 kamar, usia tidak diketahui"
f" -> {prediksi[0]:.1f} juta"
)
print(
f"rumah 2: Jakarta 180 m2, 4 kamar, usia 2 tahun"
f" -> {prediksi[1]:.1f} juta"
)
# ---- 4. Statistik yang dipelajari pipeline vs seluruh data -------------
pra_penuh = bangun_pra().fit(X, y)
imputer_latih = (
pipeline.named_steps["pra"].named_transformers_["num"].named_steps["imputasi"]
)
skala_latih = (
pipeline.named_steps["pra"].named_transformers_["num"].named_steps["skala"]
)
imputer_penuh = pra_penuh.named_transformers_["num"].named_steps["imputasi"]
skala_penuh = pra_penuh.named_transformers_["num"].named_steps["skala"]
print("\n== 4. Statistik terpelajari: data latih vs seluruh data ==")
print("median imputer (latih) : " + ", ".join(
f"{k}={v:.2f}" for k, v in zip(FITUR_NUM, imputer_latih.statistics_)
))
print("median imputer (penuh) : " + ", ".join(
f"{k}={v:.2f}" for k, v in zip(FITUR_NUM, imputer_penuh.statistics_)
))
print("mean scaler (latih) : " + ", ".join(
f"{k}={v:.2f}" for k, v in zip(FITUR_NUM, skala_latih.mean_)
))
print("mean scaler (penuh) : " + ", ".join(
f"{k}={v:.2f}" for k, v in zip(FITUR_NUM, skala_penuh.mean_)
))
# ---- 5. CV: pipeline leakage-safe vs preprocessing pra-transform -------
cv = KFold(n_splits=3, shuffle=True, random_state=42)
skor_aman = cross_val_score(pipeline, X, y, cv=cv, scoring="r2")
X_penuh_transform = bangun_pra().fit_transform(X)
skor_bocor = cross_val_score(
Ridge(alpha=1.0), X_penuh_transform, y, cv=cv, scoring="r2"
)
print("\n== 5. R2 CV: pipeline vs preprocessing pra-transform ==")
print(
"pipeline (aman) : "
+ " ".join(f"{v:.4f}" for v in skor_aman)
+ f" | rata-rata {skor_aman.mean():.4f}"
)
print(
"pra-transform bocor : "
+ " ".join(f"{v:.4f}" for v in skor_bocor)
+ f" | rata-rata {skor_bocor.mean():.4f}"
)
# ---- 6. Eksperimen: kolom turunan target ikut sebagai fitur ------------
X_bocor_target = X.copy()
X_bocor_target["harga_per_m2"] = (y / X["luas_m2"]).round(3)
pipeline_bocor = Pipeline(
steps=[
("pra", bangun_pra(FITUR_NUM + ["harga_per_m2"])),
("regresi", Ridge(alpha=1.0)),
]
)
skor_target = cross_val_score(
pipeline_bocor, X_bocor_target, y, cv=cv, scoring="r2"
)
print("\n== 6. Eksperimen leakage: kolom harga_per_m2 dari target ==")
print(
"R2 CV dengan kolom bocor : "
+ " ".join(f"{v:.4f}" for v in skor_target)
+ f" | rata-rata {skor_target.mean():.4f}"
)
print(
f"kenaikan dibanding pipeline bersih : "
f"+{skor_target.mean() - skor_aman.mean():.4f}"
)Output yang Dihasilkan:
== 1. Nama fitur hasil ColumnTransformer ==
9 fitur: num__luas_m2, num__kamar_tidur, num__usia_bangunan, num__jarak_pusat_km, kat__kota_Bekasi, kat__kota_Bogor, kat__kota_Depok, kat__kota_Jakarta, kat__kota_Tangerang
== 2. Tiga baris pertama X_uji setelah transformasi ==
num__luas_m2 num__kamar_tidur num__usia_bangunan num__jarak_pusat_km kat__kota_Bekasi kat__kota_Bogor kat__kota_Depok kat__kota_Jakarta kat__kota_Tangerang
-0.181 -0.058 1.018 -0.087 0.0 0.0 1.0 0.0 0.0
-1.411 -1.540 1.641 -1.647 0.0 0.0 0.0 1.0 0.0
1.131 1.424 -1.243 2.192 0.0 1.0 0.0 0.0 0.0
== 3. Prediksi langsung dari input mentah ==
rumah 1: Depok 95 m2, 3 kamar, usia tidak diketahui -> 1698.6 juta
rumah 2: Jakarta 180 m2, 4 kamar, usia 2 tahun -> 2703.7 juta
== 4. Statistik terpelajari: data latih vs seluruh data ==
median imputer (latih) : luas_m2=141.30, kamar_tidur=3.00, usia_bangunan=16.70, jarak_pusat_km=24.40
median imputer (penuh) : luas_m2=141.30, kamar_tidur=3.00, usia_bangunan=16.85, jarak_pusat_km=24.85
mean scaler (latih) : luas_m2=141.53, kamar_tidur=3.08, usia_bangunan=17.46, jarak_pusat_km=24.85
mean scaler (penuh) : luas_m2=141.70, kamar_tidur=3.11, usia_bangunan=17.58, jarak_pusat_km=25.32
== 5. R2 CV: pipeline vs preprocessing pra-transform ==
pipeline (aman) : 0.8158 0.8090 0.7985 | rata-rata 0.8078
pra-transform bocor : 0.8160 0.8089 0.7985 | rata-rata 0.8078
== 6. Eksperimen leakage: kolom harga_per_m2 dari target ==
R2 CV dengan kolom bocor : 0.9387 0.9042 0.8771 | rata-rata 0.9067
kenaikan dibanding pipeline bersih : +0.0989Mari kita bedah baris-baris yang memikul kontrak tersebut:
- Baris 20–40 (
bangun_pra): fungsi ini sengaja mengembalikan objekColumnTransformerbaru setiap dipanggil. Objek sklearn menyimpan status setelahfit(); membagi satu objek ke dua eksperimen berarti saling menimpa status. Fungsi pabrik adalah pola termurah untuk mencegahnya. - Baris 26–31 (sub-pipeline numerik): imputasi diletakkan sebelum scaling, bukan sesudah.
StandardScalermenolak kolom yang masih mengandungNaN; urutan yang benar adalah bersihkan dulu, skalakan kemudian. - Baris 36 (encoder):
sparse_output=Falsememaksa keluaran dense agar matriks mudah diperiksa; pada data dengan ratusan kategori, keluaran sparse lebih hemat memori. - Baris 47–53 (pipeline dan split):
train_test_split(test_size=0.2, random_state=42)memisahkan 80 baris holdout, danpipeline.fit(X_latih, y_latih)memicu seluruh langkah belajar hanya dari 320 baris tersebut. - Baris 56–58 (
get_feature_names_out): sembilan fitur keluaran bernama dengan prefiks cabangnya —num__luas_m2sampaikat__kota_Tangerang. Nama ini menjadi alamat resmi saat membaca koefisien model di Bagian 3. - Baris 67–76 (prediksi mentah):
rumah_barumemuatusia_bangunanbernilaiNaNdan tetap berhasil diprediksi (1.698,6 juta) karena imputasi bekerja otomatis di dalam pipeline — pengguna tidak perlu tahu detail pembersihannya.
Statistik Hanya Boleh Dipelajari dari Data Latih #
Data leakage preprocessing terjadi ketika transformer di-fit ke seluruh data sebelum pemisahan: median, rata-rata, dan simpangan yang dihitung ikut menyerap informasi baris uji, sehingga skor validasi tidak lagi murni out-of-sample. Bagian 4 skrip membandingkan kedua dunia tersebut. Median usia_bangunan yang dipelajari pipeline dari data latih adalah 16,70, sedangkan versi yang di-fit ke seluruh data menjadi 16,85; mean scaler jarak_pusat_km bergeser dari 24,85 menjadi 25,32. Selisihnya memang kecil pada 400 baris — pada dataset ratusan baris atau fitur yang lebih peka, selisih semacam ini cukup untuk mengubah pilihan model.
Bagian 5 skrip menagih konsekuensinya lewat cross-validation: pipeline leakage-safe mencatat R2 per lipatan 0,8158 / 0,8090 / 0,7985, sementara versi pra-transform mencatat 0,8160 / 0,8089 / 0,7985 dengan rata-rata yang sama hingga empat desimal (0,8078). Pada dataset ini efek numeriknya nyaris nol karena median kebal pencilan dan jumlah baris cukup besar — namun pelanggarannya tetap ada secara prosedur, dan kita tidak bisa tahu sebelumnya kapan efeknya menjadi besar.
Bagian 6 skrip mendemonstrasikan kebocoran yang tidak bisa ditangani pipeline mana pun: kolom harga_per_m2 dihitung dari target, lalu dimasukkan sebagai fitur. R2 CV melonjak dari 0,8078 ke 0,9067 — kenaikan 0,0989 tanpa satu pun informasi sah yang baru. Jebakannya makin halus karena kolom tersebut juga mengandung 12 nilai hilang (pembagian oleh luas_m2 NaN) dan dengan tenang diimputasi pipeline seolah-olah kolom biasa.
Arsitektur kini lengkap dan terbukti aman: 400 baris mentah masuk, sembilan fitur bersih keluar, dan model Ridge sementara mencatat R2 CV 0,8078. Pertanyaan berikutnya adalah pilihan estimator — apakah model linear cukup, atau pohon keputusan lebih tahan terhadap salah ketik luas yang kita pertahankan? Pertanyaan tersebut dijawab dengan GridSearchCV di Bagian 3: Pelatihan dan Optimasi Model.
Referensi Resmi #
- scikit-learn User Guide: ColumnTransformer — pemrosesan kolom heterogen dalam satu estimator.
- scikit-learn API: pipeline.Pipeline
— perantai langkah dengan
fit/predicttunggal. - scikit-learn API: preprocessing.OneHotEncoder
— encoding kategorikal dan perilaku
handle_unknown. - scikit-learn API: impute.SimpleImputer — strategi imputasi median dan mean.