Chapter ColumnTransformer untuk Preprocessing Terpadu ditutup dengan satu pekerjaan rumah: katalog transformer bawaan berhenti pada operasi umum — menstandardisasi, meng-encode, mengimputasi — sementara data nyata kerap menuntut langkah yang lebih spesifik. Kebutuhan seperti memangkas nilai ekstrem pada batas kuantil yang dipelajari dari data latih, atau menghitung rasio dua kolom sebagai fitur turunan, tidak punya kelas bawaan yang persis cocok. Chapter ini melunasi utang tersebut: kita menulis transformer sendiri bernama PangkasKuantil, dan yang lebih penting — menuliskannya sedemikian rupa agar tetap menjadi warga kelas satu di dalam Pipeline dan GridSearchCV.

Kapan Transformer Bawaan Tidak Cukup #

Tiga situasi paling sering mendorong kita menulis transformer sendiri. Pertama, aturan domain: rumah seluas 340 m² di kawasan dengan mayoritas rumah 60–160 m² hampir pasti salah input sensor atau properti luar biasa; kita ingin memangkasnya ke batas wajar, bukan membiarkannya menarik skala seluruh kolom. Kedua, fitur turunan: rasio pendapatan_per_umur atau selisih tanggal_gadget - tanggal_lahir harus dihitung konsisten saat latih, saat validasi, dan saat inferensi. Ketiga, kebijakan bisnis: skor kredit di atas 850 dipatok ke 850 karena kebijakan underwriting — aturan yang harus melekat pada objek preprocessing, bukan pada ingatan penulis kode.

Semua situasi tersebut berbagi satu pola: transformasi yang membawa status terpelajari (batas, rata-rata, kategori) atau parameter yang ingin di-tuning. Tabel berikut merangkum pilihan jalurnya.

KebutuhanSolusi bawaanTransformer kustom
Memangkas ke rentang tetapMinMaxScaler? Tidak — skala ulangPangkasKuantil belajar batas dari data latih
Fitur turunan dua kolomTidak adaHitung di transform(), dokumen di get_feature_names_out()
Kebijakan domain (patok 850)Tidak adaKonstanta jadi parameter yang bisa di-tuning

Dua Kelas Dasar dan Kontrak Mereka #

Transformer kustom yang kompatibel dengan seluruh ekosistem scikit-learn cukup mewarisi dua kelas dasar. Kombinasinya memberi kita tiga metode gratis yang justru menjadi tiket masuk ke Pipeline dan GridSearchCV.

WarisanMetode yang diberikanDipakai oleh
BaseEstimatorget_params(), set_params()clone(), GridSearchCV, pengaturan dua garis bawah
TransformerMixinfit_transform()Pipeline, ColumnTransformer
Kita sendirifit(X, y), transform(X)Seluruh ekosistem — kontrak inti

Syaratnya cuma satu dan tidak bisa ditawar: konstruktor menyimpan parameter apa adanya. Tidak ada validasi, tidak ada pembulatan, tidak ada konversi tipe di __init__. Alasannya fundamental — clone() membuat salinan estimator dengan membaca parameter lewat get_params() lalu membangun ulang objek lewat konstruktor; jika konstruktor mengubah parameter, salinan tidak lagi identik dan hasil cross-validation menjadi tidak bisa dipercaya.

Membangun PangkasKuantil #

Kelas PangkasKuantil di bawah ini memangkas setiap kolom pada batas kuantil yang dipelajari saat fit(). Contoh pertama memperlihatkan seluruh kontraknya sekaligus: konstruktor polos, fit() yang menghitung batas, transform() yang memangkas, hingga get_feature_names_out() agar transformer tetap ramah terhadap alur kolom-bernama.

python
# Transformer kustom yang patuh kontrak scikit-learn: belajar batas kuantil
# saat fit(), lalu memangkas nilai ekstrem saat transform(). Diturunkan dari
# BaseEstimator + TransformerMixin agar otomatis menerima get_params(),
# set_params(), dan fit_transform() -- tiga hak warga kelas satu di Pipeline.

import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, TransformerMixin


class PangkasKuantil(BaseEstimator, TransformerMixin):
    """Pangkas nilai ekstrem pada batas kuantil yang dipelajari saat fit.

    Parameter
    ---------
    bawah : float
        Kuantil batas bawah (default 0.05).
    atas : float
        Kuantil batas atas (default 0.95).
    """

    def __init__(self, bawah=0.05, atas=0.95):
        # Aturan emas konstruktor: simpan parameter APA ADANYA, tanpa
        # validasi atau transformasi apa pun -- clone() mengandalkan ini.
        self.bawah = bawah
        self.atas = atas

    def fit(self, X, y=None):
        Xa = np.asarray(X, dtype=float)
        if Xa.ndim != 2:
            raise ValueError("X harus berupa tabel dua dimensi")
        self.fitur_masuk_ = (
            list(X.columns) if isinstance(X, pd.DataFrame) else None
        )
        self.n_fitur_ = Xa.shape[1]
        self.batas_bawah_ = np.quantile(Xa, self.bawah, axis=0)
        self.batas_atas_ = np.quantile(Xa, self.atas, axis=0)
        return self

    def transform(self, X):
        Xa = np.asarray(X, dtype=float)
        if Xa.shape[1] != self.n_fitur_:
            raise ValueError(
                f"Jumlah fitur {Xa.shape[1]} != {self.n_fitur_} saat fit"
            )
        return np.clip(Xa, self.batas_bawah_, self.batas_atas_)

    def get_feature_names_out(self, input_features=None):
        if input_features is None:
            if self.fitur_masuk_ is not None:
                input_features = self.fitur_masuk_
            else:
                input_features = [f"x{i}" for i in range(self.n_fitur_)]
        return np.asarray(input_features, dtype=object)


# ---------- Data latih: 200 rumah dengan sebaran wajar ----------
rng = np.random.default_rng(42)
n = 200
rumah = pd.DataFrame(
    {
        "luas_m2": rng.normal(120, 25, size=n).round(1),
        "jarak_pusat_km": rng.uniform(2, 20, size=n).round(1),
    }
)

pangkas = PangkasKuantil(bawah=0.05, atas=0.95)
pangkas.fit(rumah)

print("== Batas kuantil terpelajari dari data latih ==")
for nama, lo, hi in zip(
    rumah.columns, pangkas.batas_bawah_, pangkas.batas_atas_
):
    print(f"{nama:15s}: bawah {lo:7.2f}  atas {hi:7.2f}")

# ---------- Baris baru dengan nilai ekstrem ----------
baru = pd.DataFrame(
    {
        "luas_m2": [340.0, 45.0, 128.0],
        "jarak_pusat_km": [55.0, 0.5, 9.0],
    }
)
hasil = pangkas.transform(baru)
print("\n== Baris baru dipangkas ke batas terpelajari ==")
for i in range(len(baru)):
    print(
        f"baris {i}: luas {baru['luas_m2'][i]:7.1f} -> {hasil[i, 0]:7.2f}"
        f" | jarak {baru['jarak_pusat_km'][i]:5.1f} -> {hasil[i, 1]:5.2f}"
    )

print("\n== Nama fitur keluaran ==")
print(list(pangkas.get_feature_names_out()))

# ---------- Kontrak BaseEstimator + TransformerMixin ----------
print("\n== get_params / set_params (warisan BaseEstimator) ==")
print(pangkas.get_params())
pangkas.set_params(bawah=0.10)
print(f"bawah baru: {pangkas.bawah}")

print("\n== fit_transform (warisan TransformerMixin) ==")
pangkas.set_params(bawah=0.05)
lewat_mixin = pangkas.fit_transform(rumah)
lewat_manual = pangkas.fit(rumah).transform(rumah)
print(f"fit_transform sama dengan fit lalu transform: "
      f"{np.array_equal(lewat_mixin, lewat_manual)}")

print("\n== clone: parameter ikut, status pelatihan tidak ==")
from sklearn.base import clone

kembaran = clone(pangkas)
print(f"parameter kembaran : {kembaran.get_params()}")
print(f"pernah fit         : {hasattr(kembaran, 'batas_bawah_')}")

Output yang Dihasilkan:

text
== Batas kuantil terpelajari dari data latih ==
luas_m2        : bawah   85.51  atas  157.45
jarak_pusat_km : bawah    3.19  atas   18.80

== Baris baru dipangkas ke batas terpelajari ==
baris 0: luas   340.0 ->  157.45 | jarak  55.0 -> 18.80
baris 1: luas    45.0 ->   85.51 | jarak   0.5 ->  3.19
baris 2: luas   128.0 ->  128.00 | jarak   9.0 ->  9.00

== Nama fitur keluaran ==
['luas_m2', 'jarak_pusat_km']

== get_params / set_params (warisan BaseEstimator) ==
{'atas': 0.95, 'bawah': 0.05}
bawah baru: 0.1

== fit_transform (warisan TransformerMixin) ==
fit_transform sama dengan fit lalu transform: True

== clone: parameter ikut, status pelatihan tidak ==
parameter kembaran : {'atas': 0.95, 'bawah': 0.05}
pernah fit         : False

Mari kita bedah bagian-bagian yang memikul kontrak tersebut:

  1. Baris 22–26 (konstruktor): hanya menugaskan self.bawah = bawah dan self.atas = atas, tanpa memeriksa apa pun. Atribut berakhiran _ (status terpelajari) sengaja tidak disentuh di sini — status itu hak fit(), bukan konstruktor.
  2. Baris 28–38 (fit): menghitung np.quantile per kolom lalu menyimpannya sebagai batas_bawah_ dan batas_atas_. Kedua nama berakhiran garis bawah mengikuti kebiasaan scikit-learn: atribut belajar hanya ada setelah fit() dipanggil. fit() mengembalikan self agar bisa dirantai.
  3. Baris 40–46 (transform): murni memakai status yang sudah dipelajari — np.clip terhadap batas dari fit(). Tidak ada perhitungan ulang dari data baru; inilah yang membuat transformer aman terhadap leakage saat dipakai di dalam Pipeline pada chapter sebelumnya.
  4. Baris 48–54 (get_feature_names_out): mengembalikan nama kolom apa adanya karena pemangkasan tidak menambah atau membuang kolom. Nama disimpan saat fit() (baris 32–34) agar transformer tetap bekerja meski diberi DataFrame maupun array polos.
  5. Baris 57–68 dan 76–89 (demonstrasi): batas kuantil 5%–95% dipelajari dari 200 rumah — luas 85,51 sampai 157,45 m². Rumah baru seluas 340 m² dipangkas ke 157,45; rumah 45 m² dinaikkan ke 85,51; rumah 128 m² yang berada di dalam rentang tidak disentuh sama sekali.
  6. Baris 94–110 (uji kontrak): get_params() mengembalikan kedua parameter, fit_transform identik dengan fit lalu transform, dan clone() menyalin parameter tanpa status pelatihan — pernah fit: False.

clone() dan Keamanan Cross-Validation #

Bagian clone() pada contoh di atas bukan hiasan penutup — bagian itulah fondasi keamanan cross-validation yang dibuktikan di Chapter Pipeline scikit-learn: Dasar. Saat cross_val_score menilai sebuah Pipeline, alat tersebut memanggil clone() pada seluruh rantai untuk setiap lipatan. Salinan dibangun dari parameter (get_params() → konstruktor), lalu dilatih dari nol pada data lipatan latih. Konsekuensinya terbagi dua: parameter harus ikut — sehingga hyperparameter kustom otomatis bisa di-tuning; status tidak boleh ikut — sehingga batas kuantil dihitung ulang dari data lipatan masing-masing, tanpa pernah menyentuh baris validasi. Konstruktor yang mengubah parameter akan merusak setengah kontrak ini; atribut status yang dihitung di konstruktor akan merusak setengahnya yang lain.

Hyperparameter Kustom di dalam GridSearchCV #

Sekarang bagian yang paling terasa manfaatnya: bawah dan atas adalah hyperparameter sungguhan yang bisa dialamatkan lewat sintaks dua garis bawah. Contoh kedua menyusun skenario 400 baris data sensor yang 20 di antaranya terkontaminasi pencilan pada kolom pertama — persis 5% dari data. Pemangkasan yang terlalu konservatif membiarkan pencilan merusak StandardScaler; pemangkasan yang terlalu agresif membuang informasi sah. GridSearchCV yang memutuskan.

python
# Transformer kustom di dalam Pipeline + GridSearchCV: hyperparameternya
# di-tuning dengan sintaks dua garis bawah, persis seperti langkah bawaan
# scikit-learn. Skenario: 20 dari 400 baris data sensus terkontaminasi
# pencilan pada kolom pertama; pemangkasan kuantil dipilih lewat CV.

import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.linear_model import Ridge
from sklearn.model_selection import GridSearchCV, KFold
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler


class PangkasKuantil(BaseEstimator, TransformerMixin):
    """Pangkas nilai ekstrem pada batas kuantil yang dipelajari saat fit."""

    def __init__(self, bawah=0.05, atas=0.95):
        self.bawah = bawah
        self.atas = atas

    def fit(self, X, y=None):
        Xa = np.asarray(X, dtype=float)
        self.batas_bawah_ = np.quantile(Xa, self.bawah, axis=0)
        self.batas_atas_ = np.quantile(Xa, self.atas, axis=0)
        self.n_fitur_ = Xa.shape[1]
        return self

    def transform(self, X):
        Xa = np.asarray(X, dtype=float)
        return np.clip(Xa, self.batas_bawah_, self.batas_atas_)


# ---------- Data: y bergantung 3 fitur, kolom pertama terkontaminasi ----------
rng = np.random.default_rng(42)
n = 400
X = rng.normal(0, 1, size=(n, 3))
y = 3.0 * X[:, 0] + 2.0 * X[:, 1] - 1.0 * X[:, 2] + rng.normal(0, 0.5, n)

terkontaminasi = rng.choice(n, size=20, replace=False)
X[terkontaminasi, 0] = rng.uniform(15, 30, size=20)

cv = KFold(n_splits=3, shuffle=True, random_state=42)

# ---------- GridSearchCV: pemangkasan sebagai hyperparameter ----------
pipeline = Pipeline(
    [("pra", PangkasKuantil()), ("skala", StandardScaler()), ("model", Ridge())]
)

kandidat = [0.00, 0.01, 0.02, 0.05, 0.10, 0.15, 0.20]
param_grid = [
    {"pra__bawah": [b], "pra__atas": [round(1.0 - b, 2)]} for b in kandidat
]

cari = GridSearchCV(pipeline, param_grid, cv=cv, scoring="r2")
cari.fit(X, y)

print("== Skor CV tiap kandidat pemangkasan (R2, 3 lipatan) ==")
for b, skor in zip(kandidat, cari.cv_results_["mean_test_score"]):
    label = "tanpa pemangkasan" if b == 0.00 else f"kuantil {b:.2f}"
    print(f"{label:22s}: {skor:.4f}")

print(f"\nkandidat terbaik : pra__bawah={cari.best_params_['pra__bawah']}, "
      f"pra__atas={cari.best_params_['pra__atas']}")
print(f"skor terbaik     : {cari.best_score_:.4f}")

# ---------- Pembanding: pipeline tanpa langkah pemangkasan ----------
tanpa_pangkas = Pipeline(
    [("skala", StandardScaler()), ("model", Ridge())]
)
from sklearn.model_selection import cross_val_score

skor_banding = cross_val_score(
    tanpa_pangkas, X, y, cv=cv, scoring="r2"
).mean()
print(f"\npipeline tanpa pangkas : {skor_banding:.4f}")
print(f"pangkas kuantil 0.00   : "
      f"{cari.cv_results_['mean_test_score'][0]:.4f}")

# ---------- Status terpelajari tetap bisa diakses ----------
pangkas_terbaik = cari.best_estimator_.named_steps["pra"]
print(f"\nbatas bawah terbaik kolom pertama : {pangkas_terbaik.batas_bawah_[0]:.3f}")
print(f"batas atas terbaik kolom pertama  : {pangkas_terbaik.batas_atas_[0]:.3f}")

Output yang Dihasilkan:

text
== Skor CV tiap kandidat pemangkasan (R2, 3 lipatan) ==
tanpa pemangkasan     : 0.3400
kuantil 0.01          : 0.3373
kuantil 0.02          : 0.3379
kuantil 0.05          : 0.4866
kuantil 0.10          : 0.8196
kuantil 0.15          : 0.8103
kuantil 0.20          : 0.7883

kandidat terbaik : pra__bawah=0.1, pra__atas=0.9
skor terbaik     : 0.8196

pipeline tanpa pangkas : 0.3437
pangkas kuantil 0.00   : 0.3400

batas bawah terbaik kolom pertama : -1.248
batas atas terbaik kolom pertama  : 1.720

Hasilnya membentuk kurva dengan puncak yang jelas. Mari kita baca bersama:

  1. Baris 33–40 (skenario): y bergantung linear pada tiga kolom; 20 indeks acak lalu diganti nilai 15–30 pada kolom pertama. Kolom lain dan y tetap bersih — kontaminasi murni di sisi fitur, skenario khas sensor rusak.
  2. Baris 49–52 (pasangan kandidat): param_grid berupa daftar kamus — tiap kamus satu pasangan bawah/atas simetris (bawah=0.05 dengan atas=0.95). Bentuk daftar inilah cara resmi mengikat dua hyperparameter agar bergerak bersama.
  3. Kurva skor: tanpa pemangkasan 0,3400; kuantil 0,01–0,02 nyaris sama (masih menyisakan pencilan); 0,05 melompat ke 0,4866; 0,10 mencapai 0,8196 — persis memangkas 5%–10% ekor tempat 20 pencilan berada; 0,15 dan 0,20 justru turun ke 0,8103 dan 0,7883 karena ikut memangkas data sah. Pola trade-off-nya tegas: terlalu sedikit tidak menolong, terlalu banyak membuang informasi.
  4. Baris 75–77 (dua angka pembanding): pipeline tanpa pangkas mencatat 0,3437, sedangkan kandidat kuantil 0,00 mencatat 0,3400. Keduanya hampir identitas — tetapi tidak persis: batas kuantil 0,00–1,00 dihitung per lipatan latih, dan nilai validasi yang melampaui rentang latih tetap terpangkas. Selisih 0,0037 pada contoh ini adalah efek samping kecil yang layak disadari, bukan galat.
  5. Baris 80–82 (status pasca-tuning): best_estimator_ adalah Pipeline terlatih; batas kolom pertama −1,248 hingga 1,720 bisa dibaca langsung — bukti status terpelajari transformer kustom selamat melewati seluruh mesin GridSearchCV.

Checklist Warga Kelas Satu #

Sebelum transformer kustom kita dianggap selesai, periksa tujuh poin berikut: (1) mewarisi BaseEstimator dan TransformerMixin; (2) konstruktor hanya menyimpan parameter apa adanya; (3) fit() menghitung status berakhiran _ dan mengembalikan self; (4) transform() murni memakai status tanpa belajar apa pun; (5) get_feature_names_out() terdefinisi; (6) clone(objek) mengembalikan salinan berparameter sama tanpa status; (7) objek bekerja di dalam Pipeline dan parameternya bisa di-tuning lewat sintaks dua garis bawah. Contoh pada chapter ini lolos ketujuhnya — dan pola yang sama berlaku untuk transformer apa pun yang akan kita tulis di proyek-proyek akhir nanti.

Dengan transformer kustom di tangan, rantai PangkasKuantil → StandardScaler → Ridge kini menjadi satu artefak yang benar-benar merepresentasikan seluruh keputusan analisis. Pertanyaan berikutnya sudah menunggu di pintu keluar: artefak tersebut hidup sebagai objek di memori — bagaimana kita membawanya keluar dari sesi pelatihan, menyimpannya ke disk, dan memuatnya kembali di mesin lain tanpa kehilangan satu bit pun? Misi tersebut dijawab oleh Chapter Penyimpanan dan Pemuatan Model.

Referensi Resmi #