Chapter sebelumnya menutup dengan satu keterbatasan Pipeline polos: satu transformer diterapkan ke seluruh kolom, sedangkan tabel nyata mencampur kolom numerik dan kategorikal yang menuntut perlakuan berbeda. StandardScaler menolak teks; OneHotEncoder akan mengubah angka menjadi barisan kolom tanpa makna. Kelas ColumnTransformer menyelesaikan kebuntuan tersebut: satu objek yang merutekan setiap kelompok kolom ke transformer masing-masing, menggabungkan hasilnya, dan — karena ColumnTransformer sendiri adalah transformer — tetap bisa ditaruh di dalam Pipeline. Chapter ini membedah anatomi peruteannya, pemilih kolom otomatis, penamaan fitur keluaran, opsi remainder, hingga perilaku keluaran sparse saat kategori membengkak banyaknya.

Masalah Perutean Kolom #

Tanpa ColumnTransformer, tabel campuran diproses secara manual: skalakan kolom numerik, encode kolom kategorikal, lalu gabungkan kedua hasilnya dengan np.concatenate atau pd.concat. Pola ini berjalan, tetapi menyimpan tiga jebakan. Pertama, urutan kolom hasil penggabungan harus dijaga tangan — tertukar satu posisi pun, model membaca fitur yang keliru tanpa galat apa pun. Kedua, kode preprocessing harus ditulis ulang saat inferensi; satu kolom yang lupa digabung saat predict() menghasilkan dimensi yang tidak cocok, dan yang lebih halus: kolom tersedia tetapi urutannya berbeda. Ketiga, nama fitur hilang — matriks gabungan tidak lagi tahu bahwa kolom ke-5 adalah kota_Jakarta, sehingga interpretasi koefisien dan debugging menjadi sulit.

ColumnTransformer menghapus ketiga jebakan sekaligus karena definisi perutean hidup di satu tempat dan dieksekusi ulang secara identik setiap kali transform() dipanggil — di dalam maupun di luar cross-validation.

Anatomi ColumnTransformer #

ColumnTransformer dibangun dari daftar tripel (nama, transformer, kolom). Parameter kolom menentukan kolom mana yang dialirkan ke transformer tersebut; sisanya diatur parameter remainder. Bentuk spesifikasi kolom sendiri punya beberapa alternatif:

Bentuk spesifikasiContohKolom yang dirutekan
Daftar nama kolom["umur", "pendapatan_juta"]Tepat dua kolom tersebut
Pemilih tipe datamake_column_selector(dtype_include=np.number)Semua kolom numerik saat fit()
Pemilih pengecualianmake_column_selector(dtype_exclude=object)Semua kolom non-teks
Daftar posisi integer[0, 2]Kolom indeks 0 dan 2
String "passthrough" sebagai transformer("asli", "passthrough", ["umur"])Kolom dilewatkan tanpa diubah

Parameter remainder mengurus kolom yang tidak disebut tripel mana pun:

Nilai remainderPerilaku
"drop" (bawaan)Kolom tak dirutekan dibuang dari keluaran
"passthrough"Kolom tak dirutekan ikut apa adanya
Objek transformerTransformer tersebut diterapkan ke semua kolom sisanya

Demonstrasi Terpadu: Tabel Nasabah Campuran #

Demonstrasi berikut memakai tabel 120 nasabah dengan kolom umur, kota, pendapatan_juta, dan pendidikan — dua numerik, dua kategorikal. Pemilih kolom otomatis mengelompokkannya berdasarkan tipe data; StandardScaler menangani jalur numerik, OneHotEncoder jalur kategorikal, lalu LogisticRegression menutup rangkaian. Empat bagian output memeriksa bentuk keluaran, opsi remainder, keluaran sparse, dan prediksi data baru.

python
# ColumnTransformer: satu tempat untuk merutekan kolom numerik dan
# kategorikal sebelum seluruhnya masuk model. Demonstrasi mencakup
# pemilih kolom otomatis, nama fitur keluaran, remainder passthrough,
# keluaran sparse untuk kategori banyak, dan prediksi data baru.

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer, make_column_selector
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

# ---------- Data campuran: dua kolom numerik + dua kolom kategorikal ----------
rng = np.random.default_rng(42)
n = 120

umur = np.clip(rng.normal(39, 9, size=n), 18, 65)
pendapatan = np.clip(rng.normal(11, 3, size=n), 3, 25)
kota = rng.choice(["Bandung", "Jakarta", "Surabaya"], size=n)
pendidikan = rng.choice(["SMA", "S1", "S2"], size=n)

# Pola: pendapatan rendah dan pendidikan tinggi mendorong berlangganan.
bonus_sarjana = (pendidikan == "S1") * 0.7 + (pendidikan == "S2") * 1.2
logit = (
    0.5 * (umur - 39) / 9
    - 0.9 * (pendapatan - 11) / 3
    + bonus_sarjana
    + rng.normal(0, 0.55, size=n)
)
y = pd.Series(
    ((1 / (1 + np.exp(-logit))) > 0.5).astype(int), name="berlangganan"
)
df = pd.DataFrame({
    "umur": umur,
    "kota": kota,
    "pendapatan_juta": pendapatan,
    "pendidikan": pendidikan,
})

# ---------- Pemilih kolom otomatis berbasis tipe data ----------
pilih_angka = make_column_selector(dtype_include=np.number)
pilih_teks = make_column_selector(dtype_include=object)
print("== Bagian 1: perutean kolom otomatis ==")
print("kolom numerik    :", pilih_angka(df))
print("kolom kategorikal:", pilih_teks(df))

# ---------- ColumnTransformer di dalam Pipeline ----------
pra = ColumnTransformer([
    ("skala", StandardScaler(), pilih_angka),
    ("satu_pan", OneHotEncoder(handle_unknown="ignore"), pilih_teks),
])
pipeline = Pipeline([
    ("pra", pra),
    ("model", LogisticRegression(max_iter=1000)),
])

X_transform = pra.fit_transform(df)
print(f"bentuk keluaran  : {X_transform.shape} "
      f"(tipe {type(X_transform).__name__})")
print("nama fitur       :", pra.get_feature_names_out().tolist())
print("baris pertama    :", np.round(X_transform[0], 3).tolist())

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
skor = cross_val_score(pipeline, df, y, cv=cv)
print("skor CV          :", np.round(skor, 3).tolist(),
      f"rerata {skor.mean():.3f}")

# ---------- remainder="passthrough": kolom tak dirutekan tetap ikut ----------
# Hanya "umur" yang diskalakan; tiga kolom lain lewat apa adanya dan
# diberi prefiks "remainder__" pada nama fiturnya.
pra_lewat = ColumnTransformer(
    [("skala", StandardScaler(), ["umur"])], remainder="passthrough"
)
keluar_lewat = pra_lewat.fit_transform(df)
print("\n== Bagian 2: remainder passthrough ==")
print("nama fitur       :", pra_lewat.get_feature_names_out().tolist())
baris0 = [
    round(float(v), 3) if isinstance(v, (int, float, np.floating)) else v
    for v in keluar_lewat[0]
]
print("baris pertama    :", baris0)

# ---------- Kategori banyak menghasilkan keluaran sparse ----------
# Menambahkan kode_pos (25 kategori) membuat blok satu-panas sangat jarang
# terisi; kepadatan total jatuh di bawah 0.3 sehingga ColumnTransformer
# mengembalikan matriks sparse csr secara otomatis.
df_sparse = df.copy()
df_sparse["kode_pos"] = rng.choice(
    [f"10{i:03d}" for i in range(25)], size=n
)
pra_sparse = ColumnTransformer([
    ("skala", StandardScaler(), pilih_angka),
    ("satu_pan", OneHotEncoder(handle_unknown="ignore"), pilih_teks),
])
X_sparse = pra_sparse.fit_transform(df_sparse)
kepadatan = X_sparse.nnz / (X_sparse.shape[0] * X_sparse.shape[1])
print("\n== Bagian 3: kategori banyak -> keluaran sparse ==")
print(f"bentuk keluaran  : {X_sparse.shape} "
      f"(tipe {type(X_sparse).__name__})")
print(f"kepadatan        : {kepadatan:.3f}")

pipeline_sparse = Pipeline([
    ("pra", pra_sparse),
    ("model", LogisticRegression(max_iter=1000)),
])
skor_sparse = cross_val_score(pipeline_sparse, df_sparse, y, cv=cv)
print("skor CV          :", np.round(skor_sparse, 3).tolist(),
      f"rerata {skor_sparse.mean():.3f}")

# ---------- Prediksi data baru, termasuk kota yang tak pernah dilihat ----------
pipeline.fit(df, y)
baru = pd.DataFrame({
    "umur": [28, 55],
    "kota": ["Medan", "Bandung"],
    "pendapatan_juta": [6.0, 20.0],
    "pendidikan": ["S1", "SMA"],
})
prediksi = pipeline.predict(baru)
peluang = pipeline.predict_proba(baru)[:, 1]
print("\n== Bagian 4: prediksi data baru ==")
for i in range(len(baru)):
    print(f"umur={baru['umur'][i]:3d} pendapatan={baru['pendapatan_juta'][i]:5.1f} "
          f"kota={baru['kota'][i]:8s} pendidikan={baru['pendidikan'][i]:4s} "
          f"-> berlangganan={prediksi[i]} p={peluang[i]:.3f}")

Output yang Dihasilkan:

text
== Bagian 1: perutean kolom otomatis ==
kolom numerik    : ['umur', 'pendapatan_juta']
kolom kategorikal: ['kota', 'pendidikan']
bentuk keluaran  : (120, 8) (tipe ndarray)
nama fitur       : ['skala__umur', 'skala__pendapatan_juta', 'satu_pan__kota_Bandung', 'satu_pan__kota_Jakarta', 'satu_pan__kota_Surabaya', 'satu_pan__pendidikan_S1', 'satu_pan__pendidikan_S2', 'satu_pan__pendidikan_SMA']
baris pertama    : [0.467, -0.988, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]
skor CV          : [0.958, 0.958, 0.833, 0.917, 0.917] rerata 0.917

== Bagian 2: remainder passthrough ==
nama fitur       : ['skala__umur', 'remainder__kota', 'remainder__pendapatan_juta', 'remainder__pendidikan']
baris pertama    : [0.467, 'Bandung', 7.93, 'SMA']

== Bagian 3: kategori banyak -> keluaran sparse ==
bentuk keluaran  : (120, 33) (tipe csr_matrix)
kepadatan        : 0.152
skor CV          : [0.958, 0.958, 0.833, 0.875, 0.958] rerata 0.917

== Bagian 4: prediksi data baru ==
umur= 28 pendapatan=  6.0 kota=Medan    pendidikan=S1   -> berlangganan=1 p=0.988
umur= 55 pendapatan= 20.0 kota=Bandung  pendidikan=SMA  -> berlangganan=0 p=0.002

Mari kita bedah baris-baris yang berperan penting:

  1. Baris 15–39 (data campuran): dua kolom numerik dan dua kolom kategorikal dibangkitkan dari rng berseed; label memuat pola pendapatan dan pendidikan sehingga model punya sinyal sungguhan untuk dipelajari.
  2. Baris 42–46 (pemilih kolom): pilih_angka dan pilih_teks adalah objek pemilih, bukan daftar kolom — pemilihan baru dievaluasi saat fit() melihat DataFrame. Baris print membuktikan pengelompokannya tepat.
  3. Baris 49–56 (rangkaian utama): ColumnTransformer menjadi langkah "pra" di dalam Pipeline; dari sudut pandang LogisticRegression, seluruh perutean hanyalah satu transformer biasa.
  4. Baris 58–67 (bentuk, nama, skor): keluaran berbentuk (120, 8) — dua kolom terstandarkan + enam kolom satu-panas; get_feature_names_out() mencantumkan kedelapan nama fitur dengan prefiks nama tripel; lima lipatan CV menutup Bagian 1 dengan rerata 0,917.
  5. Baris 72–82 (remainder passthrough): hanya umur yang diskalakan; pendapatan_juta, kota, dan pendidikan lewat apa adanya dengan prefiks remainder__ — terlihat pada baris pertama [0.467, 'Bandung', 7.93, 'SMA'].
  6. Baris 88–109 (kategori banyak): kolom kode_pos dengan 25 kategori membuat 30 kolom satu-panas; kepadatan total jatuh ke 0,152 — di bawah ambang 0,3 — sehingga keluaran otomatis menjadi csr_matrix. Skor CV pipeline sparse tetap terhitung normal.
  7. Baris 112–125 (prediksi baru): baris berisi kota Medan yang tidak pernah dilihat encoder tetap diprediksi (peluang 0,988) karena handle_unknown="ignore" melapisinya dengan vektor nol.

Membaca Nama Fitur Keluaran #

Metode get_feature_names_out() menyusun nama fitur sebagai nama tripel + nama kolom asli (skala__umur), lalu menambahkan kategori untuk transformer yang memekarkannya (satu_pan__kota_Bandung), dan prefiks remainder__ untuk kolom yang dilewatkan. Nama-nama tersebut bukan hiasan: koefisien model dapat dibaca ulang sebagai pengaruh per fitur (materi Section Regresi dan Interpretabilitas), dan nama yang ambigu antar tripel menjadi jelas berkat prefiksnya. Ketika nama seperti itu diperlukan tanpa prefiks, parameter verbose_feature_names_out=False pada ColumnTransformer mematikannya — dengan syarat nama fitur hasilnya tetap unik, karena tabrakan nama antar tripel memicu ValueError saat get_feature_names_out() dipanggil.

Keluaran Sparse dan Ambang 0,3 #

Blok satu-panas berisi hampir seluruh nol: satu baris hanya mengisi satu kolom per kategori asal. ColumnTransformer menghitung kepadatan total keluaran setelah semua tripel digabung; jika kepadatan berada di bawah 0,3 (parameter sparse_threshold), keluaran dikembalikan sebagai matriks sparse csr_matrix — pada Bagian 3, 25 kategori kode pos menjatuhkan kepadatan ke 0,152 sehingga aturan tersebut terpicu. Matriks sparse memori-efisien untuk ratusan hingga ribuan kategori, dan model linear seperti LogisticRegression menerima input tersebut secara langsung. Model berbasis jarak seperti k-NN juga dapat memakainya, tetapi model pohon umumnya lebih suka array padat; parameter sparse_threshold=0.0 memaksa keluaran selalu padat bila diperlukan.

Dengan ColumnTransformer, tabel campuran selesai dirutekan dalam satu objek yang aman dibawa ke cross-validation dan GridSearchCV — parameter preprocessing pun bisa dituning dengan sintaks dua garis bawah, misalnya "pra__satu_pan__min_frequency". Namun katalog transformer bawaan tetap berhenti pada operasi umum: menstandardisasi, meng-encode, mengimputasi. Ketika domain kita menuntut fitur seperti pendapatan per tahun usia atau pemangkasan pencilan pada kuantil terpelajari, kita harus menulis transformer sendiri — dan menuliskannya sedemikian rupa agar tetap menjadi warga kelas satu di dalam Pipeline. Misi tersebut dibuka pada Chapter Transformer Kustom.

Referensi Resmi #