Bagian 4 menutup dengan API yang menjawab 200 dan prediksi yang masuk akal. Tetapi di sanalah bahayanya: sistem yang tidak dipantau akan tetap menjawab 200 bahkan ketika dunia di sekitarnya bergeser — anggaran kampanye merangkak naik, CTR rata-rata menurun, bauran kanal berpindah ke medsos. Bagian penutup capstone memasang alarm untuk itu. Kita membangkitkan data masa kini yang distribusinya sengaja digeser, mengukur pergeserannya terhadap data latih dengan PSI (Population Stability Index) — alat yang sama dengan Chapter Monitoring dan Deteksi Drift — lalu mengukur satu hal yang bahkan lebih dini dari input: drift distribusi skor prediksi, alarm yang bekerja tanpa menunggu label. Seluruh temuan lima bagian akhirnya dirakit menjadi satu laporan proyek yang bisa dibaca pemangku kepentingan dari awal sampai akhir.
Dua Lapis Alarm: Input dan Prediksi #
Monitoring drift yang baik memasang dua lapis alarm pada kedalaman berbeda. Alarm input (PSI per fitur) memberi tahu apa yang bergeser: anggaran? CTR? bauran kanal? — informasi yang menunjuk penyebab. Alarm prediksi (PSI distribusi skor) memberi tahu apakah pergeseran itu sudah menyentuh keluaran model — model bisa menyerap pergeseran input yang besar tanpa berubah perilaku, dan justru itu skenario yang menarik untuk dideteksi dini. Ambang konvensi praktik kita pakai apa adanya: PSI di bawah 0,10 stabil, 0,10–0,25 moderat, di atas 0,25 signifikan.
# Monitoring drift dan laporan akhir capstone. Pipeline juara Bagian 3
# dibangun ulang dan dilatih pada data masa lalu (split latih). Data baru
# dibangkitkan dengan distribusi yang digeser - anggaran median naik,
# CTR menurun, bauran kanal berubah - meniru perilaku pasar yang bergeser.
# PSI (Population Stability Index) dihitung manual per fitur numerik dan
# kategorikal, plus PSI distribusi skor prediksi (drift tanpa label).
# Seluruh temuan proyek dirakit menjadi laporan/laporan-kampanye.txt.
# Jalankan dari root proyek: python3 src/06_monitoring.py
import os
import numpy as np
import pandas as pd
from lightgbm import LGBMClassifier
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
FITUR_NUM = ["anggaran_ribu", "durasi_hari", "audiens_ribu", "ctr_persen"]
FITUR_KAT = ["kanal", "segmen", "musim"]
NAMA_LAPORAN = "laporan/laporan-kampanye.txt"
def psi_kuantil(referensi, baru, n_bin=10):
"""PSI untuk fitur numerik: bin kuantil dibentuk dari data referensi."""
ref = np.asarray(referensi, dtype=float)
brb = np.asarray(baru, dtype=float)
ref = ref[~np.isnan(ref)]
brb = brb[~np.isnan(brb)]
tepi = np.quantile(ref, np.linspace(0, 1, n_bin + 1))
tepi[0], tepi[-1] = -np.inf, np.inf
eps = 1e-6
p_ref = np.clip(np.histogram(ref, bins=tepi)[0] / len(ref), eps, None)
p_baru = np.clip(np.histogram(brb, bins=tepi)[0] / len(brb), eps, None)
return float(np.sum((p_baru - p_ref) * np.log(p_baru / p_ref)))
def psi_kategori(referensi, baru):
"""PSI untuk fitur kategorikal: proporsi per kategori."""
eps = 1e-6
kategori = sorted(set(referensi) | set(baru))
p_ref = np.clip(
np.array([(referensi == k).mean() for k in kategori]), eps, None
)
p_baru = np.clip(
np.array([(baru == k).mean() for k in kategori]), eps, None
)
return float(np.sum((p_baru - p_ref) * np.log(p_baru / p_ref)))
def status_psi(nilai):
if nilai < 0.10:
return "stabil"
if nilai < 0.25:
return "moderat - pantau"
return "signifikan - retrain"
# ---------- Data masa lalu dan model juara ----------
df = pd.read_csv("data/kampanye.csv")
X = df[FITUR_NUM + FITUR_KAT]
y = df["berhasil"].to_numpy()
X_latih, X_uji, y_latih, y_uji = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)
pra_proses = ColumnTransformer(
[
(
"angka",
Pipeline(
[("imputasi", SimpleImputer(strategy="median")), ("skala", StandardScaler())]
),
FITUR_NUM,
),
("kategori", OneHotEncoder(handle_unknown="ignore", sparse_output=False), FITUR_KAT),
]
)
model = Pipeline(
[
("pra", pra_proses),
(
"model",
LGBMClassifier(
learning_rate=0.05,
n_estimators=100,
num_leaves=15,
verbose=-1,
random_state=42,
n_jobs=1,
),
),
]
)
model.fit(X_latih, y_latih)
auc_uji = roc_auc_score(y_uji, model.predict_proba(X_uji)[:, 1])
ap_uji = average_precision_score(y_uji, model.predict_proba(X_uji)[:, 1])
# ---------- Data masa kini: distribusi digeser ----------
rng = np.random.default_rng(7)
n_baru = 500
anggaran_baru = rng.lognormal(np.log(400.0), 0.55, n_baru) # median 250 -> 400
durasi_baru = rng.integers(3, 31, n_baru) # tidak berubah
audiens_baru = anggaran_baru * rng.uniform(1.2, 3.5, n_baru)
ctr_baru = np.clip(rng.lognormal(np.log(1.7), 0.38, n_baru), 0.3, 12.0) # 2.4 -> 1.7
kanal_baru = rng.choice(
["penelusuran", "medsos", "tampilan", "email"], n_baru, p=[0.20, 0.45, 0.20, 0.15]
)
segmen_baru = rng.choice(
["akuisisi", "retensi", "reaktivasi"], n_baru, p=[0.45, 0.35, 0.20]
)
musim_baru = rng.choice(["reguler", "promo"], n_baru, p=[0.60, 0.40])
for pos in rng.choice(n_baru, size=15, replace=False):
anggaran_baru[pos] = np.nan
for pos in rng.choice(n_baru, size=10, replace=False):
ctr_baru[pos] = np.nan
df_baru = pd.DataFrame(
{
"anggaran_ribu": anggaran_baru,
"durasi_hari": durasi_baru,
"audiens_ribu": np.round(audiens_baru, 1),
"ctr_persen": ctr_baru,
"kanal": kanal_baru,
"segmen": segmen_baru,
"musim": musim_baru,
}
)
# ---------- PSI per fitur ----------
hasil_psi = []
for fitur in FITUR_NUM:
nilai = psi_kuantil(X_latih[fitur], df_baru[fitur])
hasil_psi.append((fitur, "numerik", nilai))
for fitur in FITUR_KAT:
nilai = psi_kategori(X_latih[fitur], df_baru[fitur])
hasil_psi.append((fitur, "kategorikal", nilai))
print("== PSI per fitur (referensi: data latih, 1600 baris) ==")
print(f"{'fitur':15s} {'jenis':12s} {'PSI':>7s} status")
for fitur, jenis, nilai in hasil_psi:
print(f"{fitur:15s} {jenis:12s} {nilai:7.3f} {status_psi(nilai)}")
# ---------- Drift prediksi tanpa label ----------
skor_lama = model.predict_proba(X_latih)[:, 1]
skor_baru = model.predict_proba(df_baru)[:, 1]
psi_prediksi = psi_kuantil(skor_lama, skor_baru)
print(f"\n== Drift distribusi skor prediksi ==")
print(f"rata-rata skor masa lalu : {skor_lama.mean():.4f}")
print(f"rata-rata skor masa kini : {skor_baru.mean():.4f}")
print(f"PSI skor prediksi : {psi_prediksi:.3f} ({status_psi(psi_prediksi)})")
print(f"peluang >= 0.5 masa lalu : {(skor_lama >= 0.5).mean():.3f}")
print(f"peluang >= 0.5 masa kini : {(skor_baru >= 0.5).mean():.3f}")
# ---------- Rakit laporan akhir ----------
baris = []
baris.append("=" * 68)
baris.append(" LAPORAN CAPSTONE: PREDIKSI KEBERHASILAN KAMPANYE")
baris.append(" Dataset sintetis deterministik (seed 42) - 2000 kampanye")
baris.append("=" * 68)
baris.append("")
baris.append("1. RINGKASAN EKSEKUTIF")
baris.append(" Pipeline LightGBM (Bagian 2-3) dilatih, dibekukan sebagai")
baris.append(" artefak joblib, disajikan lewat FastAPI (Bagian 4), dan")
baris.append(f" dimonitor dengan PSI (Bagian 5). AUC uji {auc_uji:.4f};")
baris.append(" input bergeser signifikan (anggaran, audiens, CTR - PSI")
baris.append(f" hingga {max(n for _, _, n in hasil_psi):.3f}), namun PSI skor prediksi")
baris.append(f" {psi_prediksi:.3f} masih stabil - model menyerap pergeseran;")
baris.append(" retrain preventif disarankan sebelum skor ikut bergeser.")
baris.append("")
baris.append("2. DATA")
baris.append(" 2000 kampanye digital: 4 fitur numerik + 3 kategorikal;")
baris.append(" target berhasil ~40%; nilai hilang anggaran 3%, CTR 2%;")
baris.append(" mekanisme target: saturasi anggaran (tanh), CTR logaritmik,")
baris.append(" durasi puncak di 15 hari, interaksi segmen x musim dan")
baris.append(" kanal x CTR (lihat src/01_bangun_data.py).")
baris.append("")
baris.append("3. PIPELINE (Bagian 2)")
baris.append(" numerik : SimpleImputer(median) + StandardScaler")
baris.append(" kategorikal: OneHotEncoder(handle_unknown='ignore')")
baris.append(" seluruhnya dalam satu Pipeline - aman leakage, nama")
baris.append(" fitur keluaran via get_feature_names_out (13 fitur).")
baris.append("")
baris.append("4. MODEL TERPILIH (Bagian 3)")
baris.append(" kandidat : LogisticRegression, LGBMClassifier,")
baris.append(" RandomForestClassifier (GridSearchCV cv=3)")
baris.append(" juara : LGBM lr=0.05, n_estimators=100, num_leaves=15")
baris.append(f" uji : AUC {auc_uji:.4f}, average precision {ap_uji:.4f}")
baris.append(" keputusan diambil dari CV; data uji hanya saksi.")
baris.append("")
baris.append("5. SERVING (Bagian 4)")
baris.append(" artefak : model_kampanye.joblib (joblib dump/load)")
baris.append(" API : FastAPI + Pydantic v2 (validasi ge/le)")
baris.append(" uji : /health 200; kampanye kuat peluang 0.9652;")
baris.append(" kampanye lemah 0.0791; payload di luar batas")
baris.append(" ditolak 422. TestClient in-process, deterministik.")
baris.append("")
baris.append("6. MONITORING DRIFT (Bagian 5)")
baris.append(" PSI per fitur (referensi data latih vs 500 kampanye baru):")
for fitur, jenis, nilai in hasil_psi:
baris.append(f" {fitur:15s} {nilai:6.3f} {status_psi(nilai)}")
baris.append(f" PSI skor prediksi : {psi_prediksi:.3f} ({status_psi(psi_prediksi)})")
baris.append(f" rata-rata skor : {skor_lama.mean():.3f} -> {skor_baru.mean():.3f}")
baris.append(" anggaran dan audiens bergeser signifikan (median anggaran")
baris.append(" 250 -> 400 ribu); CTR menurun (2.4 -> 1.7); durasi, segmen,")
baris.append(" dan musim stabil; bauran kanal bergesir ke medsos.")
baris.append("")
baris.append("7. REKOMENDASI DAN BATASAN")
baris.append(" a. Jadwalkan retraining preventif dengan jendela data")
baris.append(" terkini; prioritas fitur anggaran, audiens, dan CTR -")
baris.append(" inputnya paling jauh bergeser walau skor masih stabil.")
baris.append(" b. Pantau PSI skor prediksi mingguan - drift tanpa label")
baris.append(" adalah alarm dini sebelum metrik bisnis tersedia.")
baris.append(" c. Data sintetis dan drift disimulasikan; ambang PSI")
baris.append(" (0.1 / 0.25) adalah konvensi praktik, kalibrasi dengan")
baris.append(" data nyata tetap diperlukan. Stabilitas skor prediksi")
baris.append(" pada contoh ini tidak menjamin mutu model - tanpa")
baris.append(" label segar, mutu sejati tidak terukur.")
baris.append(" d. Capstone menutup siklus: data -> pipeline -> model ->")
baris.append(" serving -> monitoring; kelanjutan belajar di Section")
baris.append(" Sumber Belajar.")
baris.append("")
baris.append("=" * 68)
isi_laporan = "\n".join(baris) + "\n"
os.makedirs("laporan", exist_ok=True)
with open(NAMA_LAPORAN, "w", encoding="utf-8") as f:
f.write(isi_laporan)
print(f"\n== {NAMA_LAPORAN} ditulis ({len(baris)} baris) ==")
print(isi_laporan, end="")Output yang Dihasilkan:
== PSI per fitur (referensi: data latih, 1600 baris) ==
fitur jenis PSI status
anggaran_ribu numerik 0.690 signifikan - retrain
durasi_hari numerik 0.013 stabil
audiens_ribu numerik 0.463 signifikan - retrain
ctr_persen numerik 0.804 signifikan - retrain
kanal kategorikal 0.089 stabil
segmen kategorikal 0.006 stabil
musim kategorikal 0.000 stabil
== Drift distribusi skor prediksi ==
rata-rata skor masa lalu : 0.4014
rata-rata skor masa kini : 0.3709
PSI skor prediksi : 0.083 (stabil)
peluang >= 0.5 masa lalu : 0.333
peluang >= 0.5 masa kini : 0.292
== laporan/laporan-kampanye.txt ditulis (72 baris) ==
====================================================================
LAPORAN CAPSTONE: PREDIKSI KEBERHASILAN KAMPANYE
Dataset sintetis deterministik (seed 42) - 2000 kampanye
====================================================================
1. RINGKASAN EKSEKUTIF
Pipeline LightGBM (Bagian 2-3) dilatih, dibekukan sebagai
artefak joblib, disajikan lewat FastAPI (Bagian 4), dan
dimonitor dengan PSI (Bagian 5). AUC uji 0.8024;
input bergeser signifikan (anggaran, audiens, CTR - PSI
hingga 0.804), namun PSI skor prediksi
0.083 masih stabil - model menyerap pergeseran;
retrain preventif disarankan sebelum skor ikut bergeser.
2. DATA
2000 kampanye digital: 4 fitur numerik + 3 kategorikal;
target berhasil ~40%; nilai hilang anggaran 3%, CTR 2%;
mekanisme target: saturasi anggaran (tanh), CTR logaritmik,
durasi puncak di 15 hari, interaksi segmen x musim dan
kanal x CTR (lihat src/01_bangun_data.py).
3. PIPELINE (Bagian 2)
numerik : SimpleImputer(median) + StandardScaler
kategorikal: OneHotEncoder(handle_unknown='ignore')
seluruhnya dalam satu Pipeline - aman leakage, nama
fitur keluaran via get_feature_names_out (13 fitur).
4. MODEL TERPILIH (Bagian 3)
kandidat : LogisticRegression, LGBMClassifier,
RandomForestClassifier (GridSearchCV cv=3)
juara : LGBM lr=0.05, n_estimators=100, num_leaves=15
uji : AUC 0.8024, average precision 0.7481
keputusan diambil dari CV; data uji hanya saksi.
5. SERVING (Bagian 4)
artefak : model_kampanye.joblib (joblib dump/load)
API : FastAPI + Pydantic v2 (validasi ge/le)
uji : /health 200; kampanye kuat peluang 0.9652;
kampanye lemah 0.0791; payload di luar batas
ditolak 422. TestClient in-process, deterministik.
6. MONITORING DRIFT (Bagian 5)
PSI per fitur (referensi data latih vs 500 kampanye baru):
anggaran_ribu 0.690 signifikan - retrain
durasi_hari 0.013 stabil
audiens_ribu 0.463 signifikan - retrain
ctr_persen 0.804 signifikan - retrain
kanal 0.089 stabil
segmen 0.006 stabil
musim 0.000 stabil
PSI skor prediksi : 0.083 (stabil)
rata-rata skor : 0.401 -> 0.371
anggaran dan audiens bergeser signifikan (median anggaran
250 -> 400 ribu); CTR menurun (2.4 -> 1.7); durasi, segmen,
dan musim stabil; bauran kanal bergesir ke medsos.
7. REKOMENDASI DAN BATASAN
a. Jadwalkan retraining preventif dengan jendela data
terkini; prioritas fitur anggaran, audiens, dan CTR -
inputnya paling jauh bergeser walau skor masih stabil.
b. Pantau PSI skor prediksi mingguan - drift tanpa label
adalah alarm dini sebelum metrik bisnis tersedia.
c. Data sintetis dan drift disimulasikan; ambang PSI
(0.1 / 0.25) adalah konvensi praktik, kalibrasi dengan
data nyata tetap diperlukan. Stabilitas skor prediksi
pada contoh ini tidak menjamin mutu model - tanpa
label segar, mutu sejati tidak terukur.
d. Capstone menutup siklus: data -> pipeline -> model ->
serving -> monitoring; kelanjutan belajar di Section
Sumber Belajar.
====================================================================Mari kita bedah mekanisme monitoring dan laporannya:
- Baris 27–39 (PSI numerik): bin kuantil dibentuk dari data referensi (data latih), bukan dari data baru — referensi adalah “kebenaran” yang drift diukur terhadapnya.
eps=1e-6menjaga logaritma tetap terdefinisi saat sebuah bin kosong; nilai NaN dibuang lebih dulu karena imputasi bukan urusan monitoring. - Baris 41–52 (PSI kategorikal): proporsi per kategori menggantikan histogram — union kategori referensi dan baru menangani kategori yang muncul kemudian.
- Baris 103–132 (skenario drift): data masa kini dibangkitkan dengan tiga pergeseran yang disengaja dan satu kontrol — median anggaran naik 250→400 ribu, CTR turun 2,4→1,7, bauran kanal bergeser ke medsos, sementara durasi, segmen, dan musim sengaja tidak diubah. Nilai hilang disisipkan pada tingkat yang sama dengan data asli.
- Baris 134–157 (dua lapis alarm): tabel PSI per fitur menunjukkan anggaran 0,690, audiens 0,463, dan CTR 0,804 — ketiganya signifikan; durasi 0,013, segmen 0,006, musim 0,000 stabil; kanal 0,089 nyaris menyentuh ambang. Namun lapis kedua menceritakan hal lain: PSI skor prediksi hanya 0,083 — stabil. Rata-rata skor turun tipis dari 0,4014 ke 0,3709 dan proporsi prediksi positif dari 0,333 ke 0,292.
- Baris 159–235 (laporan): tujuh bagian dirakit dari angka yang dihitung saat itu juga — ringkasan eksekutif hingga rekomendasi. Bagian 5 sengaja memuat angka serving Bagian 4 sebagai jejak audit; bagian 7 menutup dengan kejujuran lengkap termasuk batasan terpenting: stabilitas skor bukan jaminan mutu — tanpa label segar, kita tidak tahu apakah prediksi masih benar, hanya bahwa distribusinya belum bergeser.
Membaca Divergensi Dua Alarm #
Hasil paling berharga dari bagian ini justru divergensi antara kedua lapis alarm, dan memahaminya adalah pelajaran monitoring yang sesungguhnya. Input bergeser hebat — PSI CTR 0,804, anggaran 0,690 — tetapi skor prediksi nyaris tidak bergerak. Mengapa? Sebagian karena efek saturasi tanh pada anggaran: model memang dirancang (lewat mekanisme data) untuk berhenti memperbesar peluang setelah anggaran melewati zona jenuh, sehingga pergeseran median 250→400 banyak terserap di dataran saturasi. Sebagian lagi karena dua pergeseran input saling menetralkan pada skor: anggaran naik mendorong skor, CTR turun menekannya.
Keputusan yang diambil laporan — retrain preventif meski alarm prediksi belum berbunyi — adalah keputusan yang bisa dibela: alarm input memberi waktu reaksi sebelum alarm prediksi menyala, dan retraining yang dijadwalkan lebih murah daripada retraining yang panik. Tetapi catatan batasannya sama pentingnya: model yang skornya stabil bisa saja diam-diam salah (concept drift tidak terlihat dari distribusi skor), sehingga label segar tetap menjadi hakim akhir yang tak tergantikan.
Capstone tuntas: dari pembangkitan data yang jujur, pipeline yang aman, pemilihan model berprotokol, serving yang teruji, hingga monitoring dua lapis dan laporan yang merangkai semuanya. Seluruh perjalanan course — dua puluh tiga section sebelum proyek-proyek ini — kini tertutup satu siklus penuh. Perjalanan belajar berikutnya tidak berhenti di sini: Section Sumber Belajar menutup course dengan peta dokumentasi resmi, buku, dataset latihan, komunitas, dan roadmap untuk melanjutkan ke tingkat berikutnya.
Referensi Resmi #
- scikit-learn User Guide: Common pitfalls — distribusi data yang bergeser dan praktik penggunaan yang dianjurkan.
- sklearn.metrics.roc_auc_score — API — metrik mutu model yang dilaporkan ulang di laporan.
- LightGBM Documentation — model juara yang dimonitor di bagian ini.