Bagian 3 menobatkan RandomForestRegressor(max_depth=8, n_estimators=200) dengan R2 CV 0,8097 — tetapi mahkota itu dianugerahkan oleh data latih yang ikut menyeleksi konfigurasi model. Kini tiba saatnya pengadilan yang belum pernah ikut campur: 80 baris data uji yang disegel sejak Bagian 2, tidak pernah dilatih, tidak pernah di-grid-search. Bagian ini membuka segel itu, menilai tiga finalis (juara per keluarga dari Bagian 3) di atas data uji, membedah sisaan prediksi untuk memahami di mana model keliru, lalu menuangkan seluruh perjalanan proyek menjadi satu laporan teks yang bisa dibaca pemangku kepentingan tanpa membuka satu baris kode Python.

Data Uji sebagai Hakim Tunggal #

Mengapa penilaian akhir harus menunggu sampai titik ini? Karena setiap keputusan yang kita ambil sepanjang proyek — mempertahankan pencilan di Bagian 1, arsitektur preprocessing di Bagian 2, memilih RandomForest di Bagian 3 — semuanya dibuat sambil melihat data latih. Data uji adalah satu-satunya himpunan yang bebas dari kontaminasi keputusan tersebut. Jika model juara tampil baik di sana, kita punya bukti bahwa yang dipelajari adalah pola, bukan hafalan. Jika tampil lebih buruk daripada skor CV, itu sinyal peringatan bahwa optimasi kita menyetel ke derau. Evaluasi holdout bukan formalitas penutup; ia satu-satunya perkiraan jujur untuk performa model saat bertemu listing rumah baru.

Menilai Finalis dan Menulis Laporan #

Skrip bagian ini melakukan dua pekerjaan sekaligus. Pertama, ketiga finalis dilatih ulang pada seluruh data latih dengan konfigurasi juara masing-masing, diprediksi pada data uji, lalu dinilai tiga metrik: MAE (berapa juta rata-rata meleset), RMSE (menghukum galat besar lebih keras — ingat pencilan harga kita), dan R2. Kedua, seluruh temuan proyek dirangkai menjadi laporan/laporan-harga-rumah.txt: enam bagian berurutan dari deskripsi data, preprocessing, pemilihan model, evaluasi holdout, temuan utama, hingga batasan — ditulis ulang dari angka-angka yang dihitung saat itu juga, bukan diketik manual.

python
# Evaluasi holdout dan penyusun laporan akhir proyek harga rumah.
# Tiga model final (konfigurasi terbaik tiap keluarga dari Bagian 3)
# dilatih pada data latih lalu dinilai pada 80 baris data uji yang tidak
# pernah disentuh pelatihan. Hasilnya dirangkum menjadi
# laporan/laporan-harga-rumah.txt lalu dicetak ke layar.

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import RandomForestRegressor
from sklearn.impute import SimpleImputer
from sklearn.linear_model import Lasso, Ridge
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

BERKAS_CSV = "data/rumah.csv"
BERKAS_LAPORAN = "laporan/laporan-harga-rumah.txt"
FITUR_NUM = ["luas_m2", "kamar_tidur", "usia_bangunan", "jarak_pusat_km"]
FITUR_KAT = ["kota"]


def bangun_pra():
    return ColumnTransformer(
        transformers=[
            (
                "num",
                Pipeline(
                    steps=[
                        ("imputasi", SimpleImputer(strategy="median")),
                        ("skala", StandardScaler()),
                    ]
                ),
                FITUR_NUM,
            ),
            (
                "kat",
                OneHotEncoder(handle_unknown="ignore", sparse_output=False),
                FITUR_KAT,
            ),
        ]
    )


def fmt(nilai):
    """Format angka selalu dua desimal; '?' bila hilang."""
    return f"{nilai:.1f}" if pd.notna(nilai) else "?"


df = pd.read_csv(BERKAS_CSV)
X = df.drop(columns=["harga_juta"])
y = df["harga_juta"]
X_latih, X_uji, y_latih, y_uji = train_test_split(
    X, y, test_size=0.2, random_state=42
)
print("== Holdout disiapkan: 80 baris (test_size=0.2, random_state=42) ==")

# Konfigurasi terbaik per keluarga model hasil GridSearchCV Bagian 3.
FINALIS = [
    ("Ridge alpha=10", Ridge(alpha=10.0)),
    (
        "Lasso alpha=10",
        Lasso(alpha=10.0, max_iter=10000, random_state=42),
    ),
    (
        "RandomForest depth=8, 200 pohon",
        RandomForestRegressor(
            max_depth=8, n_estimators=200, random_state=42, n_jobs=1
        ),
    ),
]

pipelines = []
hasil_uji = []
for nama, estimator in FINALIS:
    pipeline = Pipeline(
        steps=[("pra", bangun_pra()), ("model", estimator)]
    )
    pipeline.fit(X_latih, y_latih)
    pred = pipeline.predict(X_uji)
    mae = mean_absolute_error(y_uji, pred)
    rmse = float(np.sqrt(mean_squared_error(y_uji, pred)))
    r2 = r2_score(y_uji, pred)
    pipelines.append(pipeline)
    hasil_uji.append((nama, mae, rmse, r2))
    print(
        f"   {nama:31s} MAE {mae:7.1f}  RMSE {rmse:7.1f}  R2 {r2:.4f}"
    )

i_terbaik = min(range(len(hasil_uji)), key=lambda i: hasil_uji[i][2])
nama_terbaik, mae_t, rmse_t, r2_t = hasil_uji[i_terbaik]
pipeline_terbaik = pipelines[i_terbaik]
mae_relatif = mae_t / y_uji.mean() * 100.0

# ---- Diagnostik sisaan model terbaik -----------------------------------
pred_uji = pipeline_terbaik.predict(X_uji)
pred_uji = pd.Series(pred_uji, index=y_uji.index)
sisa_uji = (y_uji - pred_uji).round(1)
urut_sisa = sisa_uji.abs().sort_values(ascending=False).head(3)
pred_latih = pipeline_terbaik.predict(X_latih)
pred_latih = pd.Series(pred_latih, index=y_latih.index)
sisa_latih = (y_latih - pred_latih).round(1)
i_sisa_maks = sisa_latih.abs().idxmax()

# ---- Susun laporan -----------------------------------------------------
korelasi_luas_kamar = df["luas_m2"].corr(df["kamar_tidur"])
korelasi_jarak = df["jarak_pusat_km"].corr(df["harga_juta"])
kep = pd.Series(
    pipeline_terbaik.named_steps["model"].feature_importances_,
    index=pipeline_terbaik.named_steps["pra"].get_feature_names_out(),
).sort_values(ascending=False)
koef_ridge = pd.Series(
    pipelines[0].named_steps["model"].coef_,
    index=pipelines[0].named_steps["pra"].get_feature_names_out(),
).round(1)

L = []
L.append("=" * 72)
L.append(" LAPORAN PROYEK: PREDIKSI HARGA RUMAH JABODETABEK")
L.append(" Dataset sintetis deterministik (seed 42) - 400 baris")
L.append("=" * 72)
L.append("")
L.append("1. DATA")
L.append(f"   Sumber       : {BERKAS_CSV} (dibangkitkan src/01_bangun_data.py)")
L.append("   Ukuran       : 400 baris x 6 kolom (5 fitur + target harga_juta)")
L.append("   Nilai hilang : luas_m2 = 12 baris, usia_bangunan = 8 baris")
L.append("   Keanealan    : 2 baris salah ketik luas (354-359 m2),")
L.append("                  4 baris ber-premium tinggi tanpa penjelasan fitur")
L.append(
    "   Harga (juta) : min {:.1f} | rata-rata {:.1f} | median {:.1f}".format(
        df["harga_juta"].min(),
        df["harga_juta"].mean(),
        df["harga_juta"].median(),
    )
)
L.append("                  maks {:.1f}".format(df["harga_juta"].max()))
L.append("")
L.append("2. PREPROCESSING")
L.append("   Numerik (4)  : imputasi median + StandardScaler")
L.append("   Kategorikal  : kota -> OneHotEncoder (5 kolom biner)")
L.append("   Arsitektur   : ColumnTransformer di dalam Pipeline;")
L.append("                  statistik dipelajari hanya dari data latih")
L.append("")
L.append("3. PEMILIHAN MODEL (Bagian 3: GridSearchCV, 3 lipatan, data latih)")
L.append("   Kandidat     : Ridge, Lasso, RandomForestRegressor")
L.append("   Juara CV     : RandomForestRegressor(max_depth=8,")
L.append("                  n_estimators=200) - R2 CV 0.8097")
L.append("")
L.append("4. EVALUASI HOLDOUT (80 baris data uji, tak pernah dilatih)")
L.append(
    "   {:31s} {:>9s} {:>11s} {:>8s}".format(
        "model", "MAE juta", "RMSE juta", "R2"
    )
)
for nama, mae, rmse, r2 in hasil_uji:
    L.append(
        "   {:31s} {:>9.1f} {:>11.1f} {:>8.4f}".format(nama, mae, rmse, r2)
    )
L.append(f"   Model final  : {nama_terbaik}")
L.append(
    "   MAE relatif  : {:.1f}% dari rata-rata harga data uji ({:.1f} juta)".format(
        mae_relatif, y_uji.mean()
    )
)
L.append("")
L.append("5. TEMUAN UTAMA")
L.append(
    "   (1) luas_m2 dominan: kepentingan fitur {:.4f}; koefisien Ridge".format(
        kep["num__luas_m2"]
    )
)
L.append(
    "       {:.1f} juta per satu simpangan baku luas (sekitar 59 m2).".format(
        koef_ridge["num__luas_m2"]
    )
)
L.append(
    "   (2) jarak_pusat_km: korelasi marginal {:+.3f} terlihat lemah,".format(
        korelasi_jarak
    )
)
L.append(
    "       tetapi koefisien Ridge {:+.1f} juta per simpangan baku setelah".format(
        koef_ridge["num__jarak_pusat_km"]
    )
)
L.append("       kota dikontrol - efek kota dan jarak saling menutupi.")
L.append(
    "   (3) kamar_tidur dan luas_m2 berkorelasi {:+.3f}; pada model".format(
        korelasi_luas_kamar
    )
)
L.append(
    "       linear keduanya berbagi bobot (kamar {:+.1f}), sedangkan".format(
        koef_ridge["num__kamar_tidur"]
    )
)
L.append("       Lasso memangkas usia_bangunan dan 3 dummy kota menjadi nol.")
L.append(
    "   (4) Salah ketik luas 358.5 m2 (Bagian 1) menghasilkan sisaan latih"
)
L.append(
    "       terbesar model final ({:+.1f} juta): luas tercatat sangat".format(
        sisa_latih[i_sisa_maks]
    )
)
L.append("       besar tetapi harga moderat - kombinasi membingungkan model.")
L.append("   (5) Sisaan data uji terbesar (faktor tak teramati):")
for i in urut_sisa.index:
    baris = df.loc[i]
    L.append(
        "       - {} {} m2: harga {:.1f}, prediksi {:.1f} (sisa {:+.1f})".format(
            baris["kota"],
            fmt(baris["luas_m2"]),
            baris["harga_juta"],
            pred_uji[i],
            sisa_uji[i],
        )
    )
L.append("")
L.append("6. BATASAN")
L.append("   - Data sintetis (fungsi linear + derau Gaussian), bukan pasar nyata.")
L.append("   - 400 baris dan 5 kota Jabodetabek; generalisasi luar wilayah")
L.append("     tidak terjamin.")
L.append("   - Holdout tunggal tanpa interval ketidakpastian pada metrik.")
L.append("   - Pencilan dipertahankan (keputusan eksplisit); alternatifnya")
L.append("     memangkas atau memodelkan segmen listing mewah terpisah.")
L.append("")
L.append("Dibangkitkan ulang secara identik oleh src/05_laporan.py (seed 42).")

isi_laporan = "\n".join(L) + "\n"
with open(BERKAS_LAPORAN, "w", encoding="utf-8") as f:
    f.write(isi_laporan)

print(f"\n== {BERKAS_LAPORAN} ditulis ({len(L)} baris) ==")
print("\n== Isi laporan ==")
print(isi_laporan, end="")

Output yang Dihasilkan:

text
== Holdout disiapkan: 80 baris (test_size=0.2, random_state=42) ==
   Ridge alpha=10                  MAE   240.0  RMSE   309.3  R2 0.8072
   Lasso alpha=10                  MAE   229.2  RMSE   300.9  R2 0.8175
   RandomForest depth=8, 200 pohon MAE   212.6  RMSE   284.1  R2 0.8374

== laporan/laporan-harga-rumah.txt ditulis (59 baris) ==

== Isi laporan ==
========================================================================
 LAPORAN PROYEK: PREDIKSI HARGA RUMAH JABODETABEK
 Dataset sintetis deterministik (seed 42) - 400 baris
========================================================================

1. DATA
   Sumber       : data/rumah.csv (dibangkitkan src/01_bangun_data.py)
   Ukuran       : 400 baris x 6 kolom (5 fitur + target harga_juta)
   Nilai hilang : luas_m2 = 12 baris, usia_bangunan = 8 baris
   Keanealan    : 2 baris salah ketik luas (354-359 m2),
                  4 baris ber-premium tinggi tanpa penjelasan fitur
   Harga (juta) : min 156.4 | rata-rata 1958.6 | median 1949.1
                  maks 3885.1

2. PREPROCESSING
   Numerik (4)  : imputasi median + StandardScaler
   Kategorikal  : kota -> OneHotEncoder (5 kolom biner)
   Arsitektur   : ColumnTransformer di dalam Pipeline;
                  statistik dipelajari hanya dari data latih

3. PEMILIHAN MODEL (Bagian 3: GridSearchCV, 3 lipatan, data latih)
   Kandidat     : Ridge, Lasso, RandomForestRegressor
   Juara CV     : RandomForestRegressor(max_depth=8,
                  n_estimators=200) - R2 CV 0.8097

4. EVALUASI HOLDOUT (80 baris data uji, tak pernah dilatih)
   model                            MAE juta   RMSE juta       R2
   Ridge alpha=10                      240.0       309.3   0.8072
   Lasso alpha=10                      229.2       300.9   0.8175
   RandomForest depth=8, 200 pohon     212.6       284.1   0.8374
   Model final  : RandomForest depth=8, 200 pohon
   MAE relatif  : 11.0% dari rata-rata harga data uji (1932.6 juta)

5. TEMUAN UTAMA
   (1) luas_m2 dominan: kepentingan fitur 0.8665; koefisien Ridge
       393.2 juta per satu simpangan baku luas (sekitar 59 m2).
   (2) jarak_pusat_km: korelasi marginal -0.126 terlihat lemah,
       tetapi koefisien Ridge -101.5 juta per simpangan baku setelah
       kota dikontrol - efek kota dan jarak saling menutupi.
   (3) kamar_tidur dan luas_m2 berkorelasi +0.861; pada model
       linear keduanya berbagi bobot (kamar +332.3), sedangkan
       Lasso memangkas usia_bangunan dan 3 dummy kota menjadi nol.
   (4) Salah ketik luas 358.5 m2 (Bagian 1) menghasilkan sisaan latih
       terbesar model final (-742.4 juta): luas tercatat sangat
       besar tetapi harga moderat - kombinasi membingungkan model.
   (5) Sisaan data uji terbesar (faktor tak teramati):
       - Bekasi 161.7 m2: harga 3195.8, prediksi 2233.4 (sisa +962.4)
       - Jakarta 50.8 m2: harga 413.4, prediksi 1161.9 (sisa -748.5)
       - Tangerang 103.9 m2: harga 2020.1, prediksi 1361.0 (sisa +659.1)

6. BATASAN
   - Data sintetis (fungsi linear + derau Gaussian), bukan pasar nyata.
   - 400 baris dan 5 kota Jabodetabek; generalisasi luar wilayah
     tidak terjamin.
   - Holdout tunggal tanpa interval ketidakpastian pada metrik.
   - Pencilan dipertahankan (keputusan eksplisit); alternatifnya
     memangkas atau memodelkan segmen listing mewah terpisah.

Dibangkitkan ulang secara identik oleh src/05_laporan.py (seed 42).

Mari kita bedah alur evaluasinya:

  1. Baris 51–56 (segel dibuka): train_test_split dengan random_state=42 yang sama dengan Bagian 3 memastikan 80 baris data uji ini persis baris yang tidak pernah dilatih grid search. Konsistensi seed antar-skrip adalah syarat kejujuran holdout.
  2. Baris 60–89 (finalis dinilai): ketiga model dilatih ulang penuh pada 320 baris latih lalu diprediksi pada data uji. Ketiga metrik dihitung: mean_absolute_error, akar mean_squared_error (dihitung via np.sqrt — sklearn 1.5 tidak menyediakan root_mean_squared_error bentuk lama), dan r2_score.
  3. Baris 91–104 (diagnostik sisaan): model terbaik dipilih berdasarkan RMSE terkecil, lalu sisaan (harga nyata dikurangi prediksi) diurutkan — tiga sisaan data uji terbesar dan satu sisaan latih terbesar akan masuk laporan sebagai jendela ke kelemahan model.
  4. Baris 118 dst. (laporan dirakit dari angka hidup): setiap baris laporan diformat dari variabel yang baru dihitung — f-string dan .format() menyisipkan kepentingan fitur, koefisien, korelasi. Laporan tidak bisa basi atau salah ketik karena ditulis ulang dari sumber kebenaran yang sama setiap kali skrip berjalan; ditutup kalimat “Dibangkitkan ulang secara identik oleh src/05_laporan.py (seed 42)”.

Membaca Evaluasi Holdout #

Angka-angka data uji mengabarkan kabar yang sehat. RandomForest menang dengan MAE 212,6 juta, RMSE 284,1 juta, dan R2 0,8374 — lebih tinggi daripada R2 CV 0,8097-nya sendiri. Kenaikan skor pada data uji bukan kebetulan baik semata: grid search memilih konfigurasi dengan bias sedikit lebih tinggi (max_depth=8), dan generalisasi ke data segar justru terbantu. Selisih MAE antar finalis (212,6 vs 229,2 vs 240,0) juga konsisten dengan urutan papan skor CV — keputusan Bagian 3 tahan uji. Yang paling bisa dibaca pemangku kepentingan: MAE relatif 11,0% — rata-rata prediksi meleset 11% dari harga rata-rata; untuk alat bantu penilaian awal listing, tingkat galat semacam itu layak diperhitungkan, dengan batasan di bawah.

Diagnostik sisaan memberi kedalaman tambahan. Sisaan latih terbesar (−742,4 juta) jatuh tepat pada baris salah ketik luas 358,5 m² dari Bagian 1 — luas tercatat raksasa tetapi harga moderat, kombinasi yang membingungkan model; pencilan yang kita sengaja pertahankan kini terbukti harganya, dan laporan mencatatnya sebagai temuan, bukan menyembunyikannya. Sementara tiga sisaan data uji terbesar (hingga +962,4 juta) terjadi pada listing dengan harga jauh di atas pola fiturnya — faktor tak teramati seperti kondisi interior atau posisi strategis mikro, pengingat jujur bahwa lima fitur belum menceritakan seluruh kisah pasar.

Anatomi Laporan untuk Pemangku Kepentingan #

Struktur enam bagian pada laporan-harga-rumah.txt mengikuti alur pertanggungjawaban analisis. Bagian 1 (DATA) memaparkan sumber, ukuran, kekusaran — pemangku kepentingan perlu tahu dasar bukti sebelum memercayai angka apa pun. Bagian 2 (PREPROCESSING) merangkum arsitektur tanpa jargon kode. Bagian 3 (PEMILIHAN MODEL) mencatat protokol perbandingan dan juara CV. Bagian 4 (EVALUASI HOLDOUT) adalah inti klaim performa — tabel tiga model lengkap dengan metrik dan MAE relatif. Bagian 5 (TEMUAN UTAMA) menerjemahkan model ke bahasa bisnis: luas menguasai harga, jarak berpengaruh setelah kota dikontrol, korelasi luas-kamar berbagi bobot. Bagian 6 (BATASAN) menutup dengan kejujuran: data sintetis, 400 baris, lima kota, holdout tunggal, pencilan dipertahankan — batas-batas yang menentukan seberapa jauh klaim boleh dibawa.

Proyek pertama tuntas: dari pembangkitan data, eksplorasi, pipeline aman leakage, pemilihan model berprotokol, hingga evaluasi holdout dan laporan yang menyusun semuanya. Pola yang barusan kita jalankan — data deterministik, keputusan berbasis bukti, evaluasi jujur — akan terpakai lagi, tetapi masalah berikutnya membawa satu tantangan yang mengubah medan: kelas target yang hanya muncul pada sebagian kecil pelanggan. Tanjungan berikutnya menanti di Proyek: Klasifikasi Churn Pelanggan.

Referensi Resmi #