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 kolomTemuan Bagian 1Perlakuan
luas_m2, usia_bangunan12 dan 8 nilai hilangSimpleImputer(strategy="median")
Empat kolom numerikSkala berbeda jauh (meter, tahun, km)StandardScaler setelah imputasi
kotaKategorikal nominal lima nilaiOneHotEncoder(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.

python
# 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:

text
== 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.0989

Mari kita bedah baris-baris yang memikul kontrak tersebut:

  1. Baris 20–40 (bangun_pra): fungsi ini sengaja mengembalikan objek ColumnTransformer baru setiap dipanggil. Objek sklearn menyimpan status setelah fit(); membagi satu objek ke dua eksperimen berarti saling menimpa status. Fungsi pabrik adalah pola termurah untuk mencegahnya.
  2. Baris 26–31 (sub-pipeline numerik): imputasi diletakkan sebelum scaling, bukan sesudah. StandardScaler menolak kolom yang masih mengandung NaN; urutan yang benar adalah bersihkan dulu, skalakan kemudian.
  3. Baris 36 (encoder): sparse_output=False memaksa keluaran dense agar matriks mudah diperiksa; pada data dengan ratusan kategori, keluaran sparse lebih hemat memori.
  4. Baris 47–53 (pipeline dan split): train_test_split(test_size=0.2, random_state=42) memisahkan 80 baris holdout, dan pipeline.fit(X_latih, y_latih) memicu seluruh langkah belajar hanya dari 320 baris tersebut.
  5. Baris 56–58 (get_feature_names_out): sembilan fitur keluaran bernama dengan prefiks cabangnya — num__luas_m2 sampai kat__kota_Tangerang. Nama ini menjadi alamat resmi saat membaca koefisien model di Bagian 3.
  6. Baris 67–76 (prediksi mentah): rumah_baru memuat usia_bangunan bernilai NaN dan 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 #