Bagian 3 menutup dengan juara yang berdiri di atas podium GridSearchCV: LGBMClassifier(learning_rate=0.05, n_estimators=100, num_leaves=15) dengan AUC uji 0,8024. Namun model yang menang di buku belum menjadi layanan — model itu masih objek di memori sesi Python, dan sesi Python akan berakhir. Bagian ini menuntaskan lompatan terakhir menuju sistem yang hidup: pipeline juara dibekukan menjadi artefak joblib, artefak dimuat ulang untuk membuktikan kesetiaannya, lalu dibungkus dalam API FastAPI dengan validasi Pydantic v2 di garis depan. Seluruhnya diuji lewat TestClient in-process — tanpa server nyata, tanpa jaringan, tetap deterministik penuh.

Dari Objek ke Artefak ke Layanan #

Tiga langkah penyimpanan dan serving yang kita bangun meniru praktik produksi dalam skala kecil. Pertama, pembekuan: joblib.dump() menuliskan seluruh pipeline — ColumnTransformer beserta seluruh status terpelajarinya dan model LGBM — ke satu berkas; materi Chapter Penyimpanan dan Pemuatan Model menjadi prosedur resmi di sini. Kedua, pemuatan kembali: API sengaja memakai objek hasil joblib.load(), bukan objek asli hasil pelatihan — meniru kenyataan bahwa proses serving dan proses pelatihan tidak pernah berbagi memori. Ketiga, validasi di gerbang: skema Pydantic menolak payload di luar batas wajar sebelum menyentuh model, karena model yang menerima masukan mustahil akan menghasilkan prediksi yang berarti.

python
# Serving model juara: bekukan pipeline LightGBM Bagian 3 menjadi artefak
# joblib, muat ulang artefak tersebut, bungkus dalam API FastAPI dengan
# validasi Pydantic v2, lalu uji seluruhnya lewat TestClient in-process
# (tanpa server nyata, tanpa jaringan). Tiga uji: /health, happy path,
# dan penolakan payload di luar batas (422). Artefak sengaja dihapus di
# akhir skrip agar setiap eksekusi dimulai dari keadaan bersih dan
# deterministik. Jalankan dari root proyek: python3 src/05_serving.py

import os

import joblib
import numpy as np
import pandas as pd
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.model_selection import 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"]
NAMA_ARTEFAK = "model_kampanye.joblib"

# ---------- Latih ulang pipeline juara (konfigurasi Bagian 3) ----------
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_proses = ColumnTransformer(
    [
        (
            "angka",
            Pipeline(
                [("imputasi", SimpleImputer(strategy="median")), ("skala", StandardScaler())]
            ),
            FITUR_NUM,
        ),
        ("kategori", OneHotEncoder(handle_unknown="ignore", sparse_output=False), FITUR_KAT),
    ]
)
pipeline_juara = Pipeline(
    [
        ("pra", pra_proses),
        (
            "model",
            LGBMClassifier(
                learning_rate=0.05,
                n_estimators=100,
                num_leaves=15,
                verbose=-1,
                random_state=42,
                n_jobs=1,
            ),
        ),
    ]
)
pipeline_juara.fit(X_train, y_train)
print("== Pelatihan artefak ==")
print(f"pipeline juara dilatih pada {len(X_train)} baris (positif {y_train.mean():.1%})")

# ---------- Bekukan dan muat ulang artefak ----------
joblib.dump(pipeline_juara, NAMA_ARTEFAK)
print(f"artefak ditulis : {NAMA_ARTEFAK}")
model_api = joblib.load(NAMA_ARTEFAK)
print(f"artefak dimuat  : {NAMA_ARTEFAK} (API memakai objek hasil joblib.load)")


# ---------- Skema permintaan dengan validasi Pydantic v2 ----------
class KampanyeInput(BaseModel):
    anggaran_ribu: float = Field(ge=0.0, le=50000.0)
    durasi_hari: int = Field(ge=1, le=365)
    audiens_ribu: float = Field(ge=0.0, le=100000.0)
    ctr_persen: float = Field(ge=0.0, le=100.0)
    kanal: str
    segmen: str
    musim: str


app = FastAPI(title="Prediksi Keberhasilan Kampanye")


@app.get("/health")
def health():
    return {"status": "ok"}


@app.post("/predict")
def predict(kampanye: KampanyeInput):
    data = pd.DataFrame([kampanye.model_dump()])
    peluang = float(model_api.predict_proba(data)[0, 1])
    return {"prediksi": int(peluang >= 0.5), "probabilitas": round(peluang, 4)}


# ---------- Uji in-process dengan TestClient ----------
client = TestClient(app)

print("\n== Uji API dengan TestClient in-process ==")
r = client.get("/health")
print(f"GET  /health                       -> {r.status_code} {r.json()}")

payload_kuat = {
    "anggaran_ribu": 900.0,
    "durasi_hari": 14,
    "audiens_ribu": 2400.0,
    "ctr_persen": 6.5,
    "kanal": "penelusuran",
    "segmen": "akuisisi",
    "musim": "promo",
}
r = client.post("/predict", json=payload_kuat)
print(f"POST /predict kampanye kuat        -> {r.status_code} {r.json()}")

payload_lemah = {
    "anggaran_ribu": 90.0,
    "durasi_hari": 28,
    "audiens_ribu": 150.0,
    "ctr_persen": 1.1,
    "kanal": "tampilan",
    "segmen": "retensi",
    "musim": "reguler",
}
r = client.post("/predict", json=payload_lemah)
print(f"POST /predict kampanye lemah       -> {r.status_code} {r.json()}")

r = client.post("/predict", json={**payload_kuat, "ctr_persen": -1.0})
print(f"POST /predict ctr_persen = -1.0    -> {r.status_code} "
      f"({len(r.json()['detail'])} galat validasi)")

r = client.post("/predict", json={**payload_kuat, "anggaran_ribu": -10.0})
print(f"POST /predict anggaran_ribu = -10  -> {r.status_code} "
      f"({len(r.json()['detail'])} galat validasi)")

# ---------- Bersihkan artefak agar eksekusi ulang selalu deterministik ----------
os.remove(NAMA_ARTEFAK)
print(f"\nartefak {NAMA_ARTEFAK} dihapus - eksekusi ulang selalu mulai bersih")

Output yang Dihasilkan:

text
== Pelatihan artefak ==
pipeline juara dilatih pada 1600 baris (positif 40.1%)
artefak ditulis : model_kampanye.joblib
artefak dimuat  : model_kampanye.joblib (API memakai objek hasil joblib.load)

== Uji API dengan TestClient in-process ==
GET  /health                       -> 200 {'status': 'ok'}
POST /predict kampanye kuat        -> 200 {'prediksi': 1, 'probabilitas': 0.9652}
POST /predict kampanye lemah       -> 200 {'prediksi': 0, 'probabilitas': 0.0791}
POST /predict ctr_persen = -1.0    -> 422 (1 galat validasi)
POST /predict anggaran_ribu = -10  -> 422 (1 galat validasi)

artefak model_kampanye.joblib dihapus - eksekusi ulang selalu mulai bersih

Mari kita bedah jalur dari pelatihan ke layanan:

  1. Baris 29–64 (pelatihan ulang konfigurasi juara): pipeline dibangun persis dengan konfigurasi Bagian 3 — split yang sama (random_state=42, stratify=y), arsitektur yang sama, hyperparameter juara yang sama. Serving yang tidak mereproduksi pelatihan dengan setia adalah sumber silent failure klasik.
  2. Baris 69–72 (bekukan, muat, pakai hasil muat): joblib.dump menulis artefak; joblib.load membangun ulang objek; dan model_api — objek hasil muat — yang dipakai endpoint. Baris ini meniru arsitektur produksi: pelatihan terjadi sekali, serving memuat artefak.
  3. Baris 76–83 (skema validasi): KampanyeInput memakai Pydantic v2 dengan Field(ge=..., le=...) — anggaran wajar 0–50.000 ribu, durasi 1–365 hari, CTR 0–100 persen. Payload di luar batas ditolak sebelum mencapai model, dengan galat 422 yang otomatis terdokumentasi di skema OpenAPI.
  4. Baris 94–98 (endpoint prediksi): kampanye.model_dump() mengubah payload tervalidasi menjadi satu baris DataFrame; predict_proba menghasilkan peluang; respons memuat label biner (ambang 0,5) dan peluang mentah yang dibulatkan empat desimal — konsumen API dapat menyesuaikan ambangnya sendiri.
  5. Baris 105–138 (empat uji): /health menjawab 200 sebagai pemeriksaan kesiapan; kampanye kuat (anggaran besar, CTR 6,5%, penelusuran×akuisisi×promo) diprediksi berhasil dengan peluang 0,9652; kampanye lemah ditolak dengan 0,0791; dua payload tak wajar ditolak 422 dengan tepat satu galat validasi masing-masing — bukti gerbang Pydantic bekerja lebih dulu daripada model.
  6. Baris 141–142 (kebersihan deterministik): artefak dihapus di akhir skrip. Setiap eksekusi — termasuk eksekusi oleh alat uji otomatis — memulai dari keadaan bersih, melatih, membekukan, melayankan, lalu membersihkan; hasil cetak selalu identik.

Membaca Prediksi yang Dihasilkan #

Dua prediksi happy path pantas dibaca ulang karena keduanya menjawab desain data Bagian 1. Kampanye kuat menggabungkan empat penggerak yang kita tanam ke dalam proses logistik data: anggaran tinggi pada zona saturasi, CTR 6,5% di kanal penelusuran (tempat interaksi kanal×CTR menguatkan peluang), durasi 14 hari dekat puncak efektivitas 15 hari, dan kombinasi akuisisi×promo yang menerima dorongan interaksi terbesar. Peluang 0,9652 adalah model membaca pola persis seperti mekanisme yang menumbuhkannya. Sebaliknya, kampanye lemah menumpuk segala hal yang melemahkan peluang — anggaran kecil, CTR 1,1% di kanal tampilan (interaksi kanal×CTR justru menekan), durasi 28 hari yang jauh dari puncak. Prediksi 0,0791 bukan tebakan gelap; model menunjuk arah yang benar.

Model kini hidup sebagai layanan yang teruji. Tetapi sistem yang tidak dipantau adalah sistem yang diam-diam memburuk: pasar bergeser, bauran kanal berubah, CTR rata-rata menurun — dan API akan tetap menjawab 200 dengan senyum yang sama. Bagian terakhir capstone memasang alarm itu: Bagian 5: Monitoring dan Laporan menghitung PSI per fitur terhadap data masa kini yang bergeser, mengukur drift distribusi skor prediksi, lalu merangkai seluruh perjalanan proyek menjadi satu laporan akhir.

Referensi Resmi #