Kursi estimator pada pipeline Bagian 2 masih diisi model demo tanpa pertimbangan. Bagian ini mengisinya lewat kontes terbuka: regresi logistik sebagai perwakilan model linear, LightGBM dan Random Forest sebagai dua perwakilan model pohon — tiga keluarga yang dicurigai Bagian 1 mampu menangkap kurva durasi dan interaksi segmen×musim yang tidak bisa dibaca model linear murni. Protokolnya kami kunci sebelum melihat satu pun angka: pra-pemrosesan identik, lipatan cross-validation identik, metrik identik, dan keputusan jatuh pada skor CV tertinggi — data uji tidak ikut memilih, ia hanya menjadi saksi di akhir.
Aturan Kontes yang Adil #
Perbandingan model mudah dicemari: kalau satu kandidat memakai pra-pemrosesan lebih baik, atau dilipat dengan fold berbeda, kemenangannya palsu. Karena itu seluruh kandidat memakai objek pra_proses Bagian 2 yang sama di dalam Pipeline masing-masing, dilipat oleh satu objek StratifiedKFold(n_splits=3, shuffle=True, random_state=42) yang sama, dan diberi skor oleh satu metrik yang sama. Metrik kontesnya ROC-AUC: dengan kelas positif 40%, ROC-AUC mengukur mutu peringkat probabilitas — persis kebutuhan tim pemasaran yang ingin mengurutkan kampanye dari paling menjanjikan — dan tidak terpengaruh pilihan ambang. Average precision tetap dilaporkan pada data uji sebagai sudut pandang kedua.
Grid hyperparameter sengaja kecil: tiga nilai C untuk regresi logistik, delapan kombinasi untuk LightGBM, dan dua kombinasi untuk Random Forest. Grid kecil cukup untuk membedakan keluarga model, cepat dieksekusi, dan mengurangi godaan memilih konfigurasi yang unggul karena derau.
| Kandidat | Karakter | Grid yang diuji |
|---|---|---|
LogisticRegression | linear, koefisien mudah dibaca, baseline kuat | C = 0,1 / 1,0 / 10,0 |
LGBMClassifier | gradient boosting, gesit menangkap non-linearitas | 100–200 pohon × lr 0,05/0,10 × 15/31 daun |
RandomForestClassifier | bagging pohon, tahan overfitting | 200 pohon × kedalaman 8 / tak terbatas |
Seluruh estimator diikat determinisme: random_state=42, n_jobs=1, dan LightGBM diberi verbose=-1 agar keluarannya bersih.
Kontes Tiga Keluarga Model #
Skrip 04_model.py menjalankan protokol tersebut: GridSearchCV melatih setiap kombinasi grid pada tiga lipatan berstrata, melaporkan ringkasan cv_results_ per keluarga, lalu memanggil pemilik skor CV terbaik tiap keluarga ke data uji.
# Kontes model: tiga keluarga kandidat (LogisticRegression, LGBMClassifier,
# RandomForestClassifier) bertarung dalam GridSearchCV ber-cross-validation
# berstrata. Semua kandidat memakai pra-pemrosesan Bagian 2 yang identik
# sehingga perbandingan jatuh murni pada model, bukan pada kualitas
# preprocessing. Juara dipilih dari skor CV tertinggi, lalu diukur pada
# data uji yang tak pernah disentuh. Jalankan dari root proyek:
# python3 src/04_model.py
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, average_precision_score, roc_auc_score
from sklearn.model_selection import GridSearchCV, StratifiedKFold, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from lightgbm import LGBMClassifier
FITUR_NUM = ["anggaran_ribu", "durasi_hari", "audiens_ribu", "ctr_persen"]
FITUR_KAT = ["kanal", "segmen", "musim"]
df = pd.read_csv("data/kampanye.csv")
X = df[FITUR_NUM + FITUR_KAT]
y = df["berhasil"].to_numpy()
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)
# Pra-pemrosesan Bagian 2, dibangun ulang persis.
pra_proses = ColumnTransformer(
[
(
"angka",
Pipeline(
[("imputasi", SimpleImputer(strategy="median")), ("skala", StandardScaler())]
),
FITUR_NUM,
),
("kategori", OneHotEncoder(handle_unknown="ignore", sparse_output=False), FITUR_KAT),
]
)
cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
# Tiga kandidat dengan grid kecil (semua estimator n_jobs=1, seed 42).
kandidat = {
"logreg": (
LogisticRegression(max_iter=2000),
{"model__C": [0.1, 1.0, 10.0]},
),
"lgbm": (
LGBMClassifier(verbose=-1, random_state=42, n_jobs=1),
{
"model__n_estimators": [100, 200],
"model__learning_rate": [0.05, 0.10],
"model__num_leaves": [15, 31],
},
),
"rf": (
RandomForestClassifier(random_state=42, n_jobs=1),
{"model__n_estimators": [200], "model__max_depth": [8, None]},
),
}
print("== Kontes model: GridSearchCV cv=3, scoring roc_auc ==")
hasil_cv = {}
for nama, (estimator, grid) in kandidat.items():
pipeline = Pipeline([("pra", pra_proses), ("model", estimator)])
pencarian = GridSearchCV(pipeline, grid, cv=cv, scoring="roc_auc", n_jobs=1)
pencarian.fit(X_train, y_train)
hasil_cv[nama] = pencarian
print(f"\n[{nama}] konfigurasi terbaik: {pencarian.best_params_}")
print(f" CV roc_auc: {pencarian.best_score_:.4f} "
f"+- {pencarian.cv_results_['std_test_score'][pencarian.best_index_]:.4f}")
print(f" {'peringkat':9s} {'roc_auc':>8s} {'std':>7s} parameter")
urutan = np.argsort(pencarian.cv_results_["rank_test_score"])
for i in urutan:
print(
f" {pencarian.cv_results_['rank_test_score'][i]:9d} "
f"{pencarian.cv_results_['mean_test_score'][i]:8.4f} "
f"{pencarian.cv_results_['std_test_score'][i]:7.4f} "
f"{pencarian.cv_results_['params'][i]}"
)
# ---------- Evaluasi pemilik skor CV terbaik di setiap keluarga ----------
print("\n== Evaluasi pemilik CV terbaik tiap keluarga pada data uji ==")
print(f"{'keluarga':9s} {'CV roc_auc':>10s} {'uji AP':>8s} {'uji AUC':>8s} {'uji akurasi':>11s}")
skor_uji = {}
for nama, pencarian in hasil_cv.items():
model_terbaik = pencarian.best_estimator_
proba = model_terbaik.predict_proba(X_test)[:, 1]
ap = average_precision_score(y_test, proba)
auc = roc_auc_score(y_test, proba)
akurasi = accuracy_score(y_test, (proba >= 0.5).astype(int))
skor_uji[nama] = (pencarian.best_score_, ap, auc, akurasi)
print(f"{nama:9s} {pencarian.best_score_:10.4f} {ap:8.4f} {auc:8.4f} {akurasi:11.4f}")
# ---------- Keputusan juara berdasarkan CV ----------
juara = max(hasil_cv, key=lambda n: hasil_cv[n].best_score_)
model_juara = hasil_cv[juara].best_estimator_
print(f"\n== JUARA: {juara} (skor CV roc_auc {hasil_cv[juara].best_score_:.4f}) ==")
print(f"hyperparameter terpilih: {hasil_cv[juara].best_params_}")
print("keputusan diambil dari CV, bukan skor uji - data uji hanya saksi")
# ---------- Fitur terpenting versi juara ----------
nama_fitur = model_juara.named_steps["pra"].get_feature_names_out()
penting = model_juara.named_steps["model"].feature_importances_
urut = np.argsort(-penting, kind="stable")
print("\n== Kontribusi fitur versi juara (feature_importances_) ==")
for i in urut[:8]:
print(f" {nama_fitur[i]:28s} {penting[i]:10.1f}")
print(f" (total {len(nama_fitur)} fitur, 8 teratas ditampilkan)")Output yang Dihasilkan:
== Kontes model: GridSearchCV cv=3, scoring roc_auc ==
[logreg] konfigurasi terbaik: {'model__C': 0.1}
CV roc_auc: 0.7197 +- 0.0047
peringkat roc_auc std parameter
1 0.7197 0.0047 {'model__C': 0.1}
2 0.7189 0.0036 {'model__C': 1.0}
3 0.7188 0.0036 {'model__C': 10.0}
[lgbm] konfigurasi terbaik: {'model__learning_rate': 0.05, 'model__n_estimators': 100, 'model__num_leaves': 15}
CV roc_auc: 0.7719 +- 0.0047
peringkat roc_auc std parameter
1 0.7719 0.0047 {'model__learning_rate': 0.05, 'model__n_estimators': 100, 'model__num_leaves': 15}
2 0.7681 0.0021 {'model__learning_rate': 0.05, 'model__n_estimators': 100, 'model__num_leaves': 31}
3 0.7668 0.0064 {'model__learning_rate': 0.05, 'model__n_estimators': 200, 'model__num_leaves': 15}
4 0.7661 0.0048 {'model__learning_rate': 0.1, 'model__n_estimators': 100, 'model__num_leaves': 15}
5 0.7590 0.0045 {'model__learning_rate': 0.1, 'model__n_estimators': 100, 'model__num_leaves': 31}
6 0.7578 0.0046 {'model__learning_rate': 0.05, 'model__n_estimators': 200, 'model__num_leaves': 31}
7 0.7578 0.0075 {'model__learning_rate': 0.1, 'model__n_estimators': 200, 'model__num_leaves': 15}
8 0.7509 0.0051 {'model__learning_rate': 0.1, 'model__n_estimators': 200, 'model__num_leaves': 31}
[rf] konfigurasi terbaik: {'model__max_depth': 8, 'model__n_estimators': 200}
CV roc_auc: 0.7685 +- 0.0092
peringkat roc_auc std parameter
1 0.7685 0.0092 {'model__max_depth': 8, 'model__n_estimators': 200}
2 0.7527 0.0051 {'model__max_depth': None, 'model__n_estimators': 200}
== Evaluasi pemilik CV terbaik tiap keluarga pada data uji ==
keluarga CV roc_auc uji AP uji AUC uji akurasi
logreg 0.7197 0.6714 0.7527 0.7175
lgbm 0.7719 0.7481 0.8024 0.7375
rf 0.7685 0.7338 0.7908 0.7125
== JUARA: lgbm (skor CV roc_auc 0.7719) ==
hyperparameter terpilih: {'model__learning_rate': 0.05, 'model__n_estimators': 100, 'model__num_leaves': 15}
keputusan diambil dari CV, bukan skor uji - data uji hanya saksi
== Kontribusi fitur versi juara (feature_importances_) ==
angka__ctr_persen 334.0
angka__durasi_hari 253.0
angka__anggaran_ribu 249.0
angka__audiens_ribu 237.0
kategori__musim_promo 82.0
kategori__kanal_tampilan 66.0
kategori__segmen_akuisisi 61.0
kategori__kanal_penelusuran 49.0
(total 13 fitur, 8 teratas ditampilkan)Mari kita bedah kontes tersebut baris demi baris:
- Baris 27–29 (belahan identik):
train_test_splitdengan parameter yang sama seperti Bagian 2 menghasilkan belahan latih-uji yang persis sama — kontes ini benar-benar dilakukan di atas data yang sama dengan bagian-bagian lain. - Baris 31–45 (pra-pemrosesan dan lipatan):
ColumnTransformerdibangun ulang bit demi bit, lalu satu objekStratifiedKFolddibagikan ke semua keluarga — pemeriksaan kelayakan agar tidak ada kandidat berlari di lintasan berbeda. - Baris 48–65 (kandidat dan grid): ketiga estimator dideklarasikan dengan seed dan
n_jobs=1; parameter grid memakai prefiksmodel__karena target pengaturannya adalah langkah bernama"model"di dalamPipeline. - Baris 69–85 (loop GridSearchCV): setiap keluarga di-fit lengkap, lalu
cv_results_dicetak sebagai tabel peringkat — mean dan simpangan baku antar-lipatan tampil berdampingan supaya selisih tipis tidak dibaca berlebihan. - Baris 87–98 (data uji sebagai saksi): pemilik CV terbaik tiap keluarga diukur pada 400 baris uji dengan tiga metrik sekaligus — average precision, ROC-AUC, dan akurasi pada ambang 0,5.
- Baris 101–105 (keputusan juara):
maxatasbest_score_memilih juara dari skor CV, bukan skor uji — satu baris kode yang menegakkan disiplin metodologi. - Baris 108–114 (kontribusi fitur): nama fitur dari
get_feature_names_out()dipasangkan denganfeature_importances_juara sehingga angka kepentingan bisa dibaca namanya, bukan sekadar urutan kolom.
Hasil Kontes dan Maknanya #
Tiga temuan besar muncul dari angka. Pertama, kedua model pohon mengalahkan model linear dengan selisih meyakinkan: LightGBM mencatat CV ROC-AUC 0,7719 dan Random Forest 0,7685, sementara regresi logistik berhenti di 0,7197 — selisih lebih dari lima poin persentase yang bersumber dari kurva durasi non-monoton serta interaksi segmen×musim dan kanal×ctr yang tertanan di Bagian 1. Kedua, konfigurasi juara justru yang paling konservatif: 100 pohon, learning rate 0,05, dan 15 daun — pohon kecil banyak mengalahkan pohon besar sedikit, dan delapan konfigurasi LightGBM hanya tersebar 0,7509 hingga 0,7719 sehingga pilihan tidak sensitif terhadap derau lipatan. Ketiga, keunggulan CV bertahan di data uji: LightGBM mencatat average precision 0,7481, ROC-AUC 0,8024, dan akurasi 0,7375 — ketiganya terbaik di antara ketiga keluarga, sehingga keputusan CV dan kesaksian data uji searah.
Kontribusi fitur versi juara memberi penutup yang rapi: CTR (334), durasi (253), anggaran (249), dan audiens (237) memimpin, disusul blok one-hot musim promo, kanal tampilan, segmen akuisisi, dan kanal penelusuran. Model menemukan kembali persis mekanisme yang kita tanam di Bagian 1 — CTR dan anggaran sebagai penggerak utama, durasi yang non-monoton, serta segmen dan musim yang berinteraksi.
Juara telah berdiri dengan hyperparameter terpasang: LGBMClassifier(learning_rate=0.05, n_estimators=100, num_leaves=15, verbose=-1, random_state=42, n_jobs=1). Namun model yang menang di buku belum menjadi layanan — Bagian 4: Penyimpanan dan Serving membekukan pipeline juara menjadi artefak joblib, membungkusnya dalam API FastAPI dengan validasi Pydantic, dan menguji ketiganya lewat TestClient tanpa server nyata.
Referensi Resmi #
- sklearn.model_selection.GridSearchCV — API
— atribut
cv_results_,best_params_, danbest_estimator_. - LGBMClassifier — LightGBM dokumentasi
— hyperparameter
num_leaves,learning_rate, danfeature_importances_. - sklearn.model_selection.StratifiedKFold — API — lipatan berstrata yang menjaga proporsi kelas.
- sklearn.metrics.roc_auc_score — API — metrik kontes berbasis mutu peringkat.