Membawa Model ke Produksi (MLOps): Panduan Machine Learning untuk Pemula #18
Baca Bagian Ini Dulu
Ada kesenjangan besar antara dua hal yang terlihat mirip:
Notebook yang berhasil. Skornya bagus, grafiknya rapi, kesimpulannya jelas. Setelah presentasi selesai, file-nya tersimpan di folder Downloads dan tidak pernah dibuka lagi.
Sistem yang jalan. Ada aplikasi lain yang memanggilnya ribuan kali sehari, hasilnya dipakai untuk keputusan nyata, dan kalau rusak ada orang yang menerima notifikasi.
Menurut berbagai laporan industri, sebagian besar model machine learning tidak pernah sampai ke tahap kedua. Bukan karena modelnya jelek, melainkan karena tidak ada yang tahu cara membawanya ke sana.
Artikel ini membahas jalan dari notebook ke sistem yang jalan, langkah demi langkah.
Kenapa Ini Penting untuk Karier
Kemampuan membuat model yang akurat sekarang relatif mudah didapat. Pustaka seperti scikit-learn sudah menyediakan hampir segalanya.
Yang langka adalah orang yang bisa membuat model itu berjalan terus-menerus dengan andal. Di situlah nilai tambahnya.
Yang Akan Dibahas
Perjalanannya bertahap, dan Anda tidak perlu menguasai semuanya sekaligus.
| Tahap | Kemampuan | Tingkat kepentingan |
|---|---|---|
| 0 | Git | Wajib, mulai dari sini |
| 1 | Struktur proyek yang rapi | Wajib |
| 2 | Menyimpan model dengan benar | Wajib |
| 3 | Mencatat percobaan (MLflow) | Sangat berguna |
| 4 | Membungkus jadi API | Wajib kalau ingin dipakai |
| 5 | Docker | Sangat berguna |
| 6 | Pemantauan dan deteksi drift | Wajib untuk sistem nyata |
| 7 | Pipeline pelatihan ulang | Tahap lanjut |
Daftar Isi
- Git: Keahlian Dasar yang Wajib
- Menyusun Proyek dengan Rapi
- Menyimpan dan Memberi Versi pada Model
- Mencatat Percobaan dengan MLflow
- Membungkus Model Menjadi API
- Docker
- Pemantauan dan Deteksi Data Drift
- Pipeline Pelatihan Ulang
- Peta Jalan Belajar
- Kesalahan Pemula yang Sering Terjadi
Git: Keahlian Dasar yang Wajib
Kenapa Ini Nomor Satu
Tanpa Git, semua hal lain di artikel ini jadi jauh lebih sulit.
Anda tidak bisa bekerja dalam tim, tidak bisa kembali ke versi kode yang tadinya berhasil, tidak bisa memakai layanan deployment otomatis, dan tidak bisa melacak perubahan apa yang menyebabkan model mendadak rusak.
Banyak praktisi data melewatkan Git karena merasa "cuma bikin notebook". Ini kesalahan yang mahal.
Empat Konsep Dasar
Repository (repo) adalah folder proyek yang riwayatnya dilacak Git.
Commit adalah simpanan keadaan proyek pada satu titik waktu, beserta catatan apa yang diubah.
Branch adalah jalur pengembangan terpisah, sehingga Anda bisa bereksperimen tanpa merusak versi yang sudah berjalan.
Remote adalah salinan repo yang tersimpan di server seperti GitHub atau GitLab.
Perintah yang Cukup untuk Memulai
Anda tidak perlu menghafal puluhan perintah. Sepuluh perintah berikut sudah mencakup pekerjaan sehari-hari.
# Sekali saja, mengatur identitas Anda
git config --global user.name "Nama Anda"
git config --global user.email "email@anda.com"
# Memulai proyek baru
git init
git add .
git commit -m "Commit pertama: struktur proyek awal"
# Menghubungkan ke GitHub
git remote add origin https://github.com/pengguna/nama-proyek.git
git push -u origin main
# Alur kerja harian
git status # lihat apa yang berubah
git diff # lihat detail perubahannya
git add nama_file.py # pilih file yang mau disimpan
git commit -m "Menambahkan fitur rasio utang"
git push # kirim ke server
# Mengambil perubahan dari orang lain
git pull
# Bekerja di jalur terpisah
git checkout -b coba-model-baru
# ... kerjakan sesuatu ...
git checkout main # kembali ke jalur utama
git merge coba-model-baru # gabungkan hasilnya
Menulis Pesan Commit yang Berguna
Pesan commit adalah catatan untuk diri Anda tiga bulan lagi.
Buruk: "update", "fix", "asdf", "coba lagi"
Baik: "Menambahkan fitur rasio utang terhadap pendapatan", "Memperbaiki kebocoran data pada agregasi transaksi", "Menaikkan recall dari 0,72 ke 0,81 dengan class_weight"
Aturan praktisnya: tulis apa yang berubah dan kenapa, bukan sekadar bahwa sesuatu berubah.
File .gitignore untuk Proyek Machine Learning
Ini penting. Tanpa .gitignore, Anda akan tidak sengaja mengunggah data rahasia atau file berukuran gigabita.
# Lingkungan Python
venv/
__pycache__/
*.pyc
.ipynb_checkpoints/
# Data mentah dan hasil olahan
data/raw/
data/processed/
*.csv
*.parquet
*.xlsx
# Model tersimpan
models/*.joblib
models/*.pkl
models/*.pt
# Rahasia
.env
*.key
credentials.json
# Hasil eksperimen
mlruns/
logs/
*.log
# Editor
.vscode/
.idea/
.DS_Store
Kenapa Data dan Model Tidak Disimpan di Git
Git dirancang untuk file teks berukuran kecil. Data berukuran gigabita akan membuat repo membengkak dan lambat, dan riwayatnya tidak bisa dihapus dengan mudah.
Lebih berbahaya lagi: kalau data berisi informasi pribadi pelanggan dan repo-nya dibuat publik, itu masalah hukum.
Solusi untuk file besar:
Git LFS menyimpan file besar terpisah, sementara Git hanya menyimpan penunjuknya.
DVC dirancang khusus untuk data machine learning, dengan kemampuan melacak versi data dan pipeline.
Penyimpanan awan seperti S3 atau Google Cloud Storage, dengan alamatnya dicatat di file konfigurasi.
Untuk pemula, cukup pakai penyimpanan awan atau folder bersama, dan catat lokasinya di README.md.
Rahasia Tidak Boleh Masuk ke Kode
Ini kesalahan yang sering terjadi dan akibatnya serius.
# JANGAN PERNAH seperti ini
API_KEY = "sk-abc123rahasia456"
PASSWORD_DATABASE = "passwordsaya"
# LAKUKAN seperti ini
import os
from dotenv import load_dotenv
load_dotenv() # baca dari file .env
API_KEY = os.getenv("API_KEY")
PASSWORD_DATABASE = os.getenv("DB_PASSWORD")
File .env berisi nilai rahasianya, dan file itu wajib masuk .gitignore.
Kalau rahasia terlanjur ter-commit, mengubahnya saja tidak cukup. Riwayat Git masih menyimpannya. Rahasianya harus diganti dengan yang baru.
Menyusun Proyek dengan Rapi
Masalah dengan Notebook
Notebook luar biasa untuk eksplorasi, tapi buruk untuk produksi.
Urutan sel bisa dijalankan sembarang, sehingga hasilnya tidak bisa diulang dengan pasti.
Kode tidak bisa dipakai ulang. Anda tidak bisa memanggil fungsi dari notebook di tempat lain.
Sulit diuji. Tidak ada cara mudah memastikan tiap bagian bekerja benar.
Sulit dibandingkan di Git. Perubahan kecil menghasilkan selisih yang tidak terbaca.
Struktur Folder Standar
proyek-prediksi-churn/
├── README.md # penjelasan proyek dan cara menjalankannya
├── requirements.txt # daftar pustaka yang dibutuhkan
├── .gitignore
├── .env.example # contoh isi .env, tanpa nilai rahasia
├── config.yaml # pengaturan yang bisa diubah
│
├── data/
│ ├── raw/ # data asli, tidak pernah diubah
│ └── processed/ # hasil olahan
│
├── notebooks/
│ └── 01_eksplorasi.ipynb # notebook hanya untuk eksplorasi
│
├── src/ # kode yang sesungguhnya
│ ├── __init__.py
│ ├── data.py # memuat dan membersihkan data
│ ├── features.py # membuat fitur
│ ├── train.py # melatih model
│ ├── predict.py # memakai model
│ └── monitoring.py # memantau model
│
├── models/ # model tersimpan
├── tests/ # pengujian kode
│ └── test_features.py
│
└── api/
├── main.py # aplikasi API
└── Dockerfile
Aturan Penting: data/raw Tidak Pernah Diubah
Data asli disimpan apa adanya dan tidak pernah ditimpa. Semua pengolahan menghasilkan file baru di data/processed.
Kalau ada kesalahan pengolahan, Anda selalu bisa mengulang dari awal.
Memindahkan Kode dari Notebook ke Modul
Ini langkah nyata yang membedakan eksplorasi dari sistem.
# src/features.py
import pandas as pd
import numpy as np
def buat_fitur_dasar(df: pd.DataFrame) -> pd.DataFrame:
"""Membuat fitur turunan dari data mentah pelanggan."""
hasil = df.copy()
hasil["rasio_utang"] = hasil["utang"] / (hasil["pendapatan"] + 1)
hasil["umur_akun_hari"] = (
pd.Timestamp.now() - pd.to_datetime(hasil["tanggal_daftar"])
).dt.days
hasil["transaksi_per_bulan"] = (
hasil["total_transaksi"] / (hasil["umur_akun_hari"] / 30 + 1)
)
return hasil
KOLOM_NUMERIK = ["umur", "pendapatan", "rasio_utang",
"umur_akun_hari", "transaksi_per_bulan"]
KOLOM_KATEGORI = ["kota", "paket"]
Sekarang fungsi ini bisa dipakai di notebook, di skrip pelatihan, dan di API. Kalau logikanya diperbaiki, semuanya ikut terperbaiki.
Ini menyelesaikan bug yang sangat sering terjadi: perhitungan fitur di pelatihan berbeda dengan di produksi.
Jangan Menanam Nilai Langsung di Kode
# config.yaml
data:
path_latih: "data/processed/latih.parquet"
kolom_target: "berhenti"
model:
jenis: "hist_gradient_boosting"
learning_rate: 0.05
max_iter: 500
random_state: 42
pelatihan:
ukuran_uji: 0.2
jumlah_fold: 5
metrik_utama: "f1"
ambang:
keputusan: 0.35
minimal_f1_untuk_deploy: 0.75
# src/train.py
import yaml
with open("config.yaml") as f:
cfg = yaml.safe_load(f)
model = HistGradientBoostingClassifier(
learning_rate=cfg["model"]["learning_rate"],
max_iter=cfg["model"]["max_iter"],
random_state=cfg["model"]["random_state"],
)
Dengan cara ini, mengubah pengaturan tidak perlu menyentuh kode, dan riwayat perubahannya tercatat rapi di Git.
Mencatat Lingkungan
# Membuat lingkungan terpisah untuk proyek ini
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install pandas scikit-learn fastapi uvicorn mlflow
# Menyimpan daftar pustaka beserta versinya
pip freeze > requirements.txt
Versi pustaka itu penting. Model yang dilatih dengan scikit-learn 1.3 kadang tidak bisa dimuat oleh versi 1.5.
Menyimpan dan Memberi Versi pada Model
Yang Harus Disimpan Bukan Hanya Modelnya
Ini kesalahan paling umum. Orang menyimpan objek modelnya saja, lalu bingung saat model dipakai dan hasilnya berbeda.
Yang harus disimpan bersama-sama:
Seluruh pipeline, termasuk penskalaan dan encoding, bukan hanya estimatornya.
Daftar nama kolom dan urutannya.
Versi pustaka yang dipakai saat melatih.
Metrik hasil evaluasi, supaya bisa dibandingkan nanti.
Ambang keputusan, kalau Anda tidak memakai 0,5.
Keterangan data latih, misalnya rentang tanggalnya.
Menyimpan dengan Benar
# src/train.py
import joblib
import json
import hashlib
from datetime import datetime
from pathlib import Path
import sklearn
import pandas as pd
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import f1_score, recall_score, precision_score
def bangun_pipeline():
alur_numerik = Pipeline([
("isi", SimpleImputer(strategy="median", add_indicator=True)),
("skala", StandardScaler()),
])
alur_kategori = Pipeline([
("isi", SimpleImputer(strategy="constant", fill_value="tidak_diketahui")),
("encode", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
])
persiapan = ColumnTransformer([
("num", alur_numerik, KOLOM_NUMERIK),
("cat", alur_kategori, KOLOM_KATEGORI),
])
return Pipeline([
("persiapan", persiapan),
("model", HistGradientBoostingClassifier(random_state=42)),
])
def simpan_model(pipeline, X_uji, y_uji, ambang, folder="models"):
Path(folder).mkdir(exist_ok=True)
versi = datetime.now().strftime("%Y%m%d_%H%M%S")
proba = pipeline.predict_proba(X_uji)[:, 1]
tebakan = (proba >= ambang).astype(int)
paket = {
"pipeline": pipeline,
"kolom_numerik": KOLOM_NUMERIK,
"kolom_kategori": KOLOM_KATEGORI,
"urutan_kolom": list(X_uji.columns),
"ambang": ambang,
"versi": versi,
"metrik": {
"f1": float(f1_score(y_uji, tebakan)),
"precision": float(precision_score(y_uji, tebakan)),
"recall": float(recall_score(y_uji, tebakan)),
},
"lingkungan": {
"sklearn": sklearn.__version__,
"pandas": pd.__version__,
},
"dilatih_pada": datetime.now().isoformat(),
"jumlah_data_latih": len(X_uji),
}
path = f"{folder}/model_{versi}.joblib"
joblib.dump(paket, path)
# Simpan juga metadata sebagai JSON agar mudah dibaca manusia
metadata = {k: v for k, v in paket.items() if k != "pipeline"}
with open(f"{folder}/model_{versi}_info.json", "w") as f:
json.dump(metadata, f, indent=2)
# Buat penunjuk ke versi terbaru
joblib.dump(paket, f"{folder}/model_terbaru.joblib")
print(f"Model tersimpan: {path}")
print(f"Metrik: {paket['metrik']}")
return path
Memuat dan Memakainya
# src/predict.py
import joblib
import pandas as pd
import sklearn
class PelayanModel:
def __init__(self, path="models/model_terbaru.joblib"):
paket = joblib.load(path)
self.pipeline = paket["pipeline"]
self.urutan_kolom = paket["urutan_kolom"]
self.ambang = paket["ambang"]
self.versi = paket["versi"]
# Peringatkan kalau versi pustaka berbeda
versi_latih = paket["lingkungan"]["sklearn"]
if versi_latih != sklearn.__version__:
print(f"PERINGATAN: model dilatih dengan sklearn {versi_latih}, "
f"sekarang memakai {sklearn.__version__}")
def prediksi(self, data: pd.DataFrame):
# Pastikan urutan kolom sama seperti saat pelatihan
data = data[self.urutan_kolom]
proba = self.pipeline.predict_proba(data)[:, 1]
return {
"probabilitas": proba.tolist(),
"keputusan": (proba >= self.ambang).astype(int).tolist(),
"versi_model": self.versi,
}
Penamaan Versi
Dua pendekatan yang umum dipakai:
Berdasarkan waktu: model_20260315_143022.joblib. Sederhana dan otomatis urut.
Berdasarkan nomor semantik: model_v2.1.0.joblib. Angka pertama naik kalau ada perubahan besar seperti penambahan fitur, angka kedua kalau ada perbaikan, angka ketiga untuk perbaikan kecil.
Untuk pemula, penamaan berdasarkan waktu sudah cukup dan lebih sulit keliru.
Jangan Pakai Pickle Mentah untuk File dari Luar
joblib dan pickle bisa menjalankan kode apa pun saat memuat file. Jangan pernah memuat file model dari sumber yang tidak Anda percayai.
Mencatat Percobaan dengan MLflow
Masalah yang Diselesaikan
Setelah 30 kali percobaan, Anda menemukan kombinasi terbaik. Seminggu kemudian, Anda tidak ingat lagi kombinasi mana itu, berapa skornya, dan data mana yang dipakai.
Mencatat manual di spreadsheet bisa dilakukan, tapi mudah terlewat dan tidak menyimpan modelnya.
Konsep Dasar MLflow
Experiment adalah wadah untuk satu proyek atau satu tujuan.
Run adalah satu kali percobaan pelatihan.
Parameter adalah pengaturan yang dipakai, misalnya learning rate.
Metrik adalah hasil pengukuran, misalnya F1.
Artifact adalah file yang dihasilkan, misalnya model dan grafik.
Memasang dan Memakainya
pip install mlflow
# src/train.py
import mlflow
import mlflow.sklearn
from sklearn.model_selection import cross_val_score, StratifiedKFold
mlflow.set_experiment("prediksi-churn")
def latih_dan_catat(X_train, y_train, X_test, y_test, parameter, ambang=0.5):
with mlflow.start_run(run_name=f"hgb_lr{parameter['learning_rate']}"):
# Catat parameter
mlflow.log_params(parameter)
mlflow.log_param("jumlah_data_latih", len(X_train))
mlflow.log_param("jumlah_fitur", X_train.shape[1])
mlflow.log_param("ambang", ambang)
# Latih
pipeline = bangun_pipeline()
pipeline.set_params(**{f"model__{k}": v for k, v in parameter.items()})
pipeline.fit(X_train, y_train)
# Evaluasi
proba = pipeline.predict_proba(X_test)[:, 1]
tebakan = (proba >= ambang).astype(int)
mlflow.log_metric("f1", f1_score(y_test, tebakan))
mlflow.log_metric("precision", precision_score(y_test, tebakan))
mlflow.log_metric("recall", recall_score(y_test, tebakan))
# Catat hasil validasi silang beserta kestabilannya
cv = StratifiedKFold(5, shuffle=True, random_state=42)
skor_cv = cross_val_score(pipeline, X_train, y_train, cv=cv, scoring="f1")
mlflow.log_metric("cv_f1_rata", skor_cv.mean())
mlflow.log_metric("cv_f1_std", skor_cv.std())
# Simpan model dan file pendukung
mlflow.sklearn.log_model(pipeline, "model")
mlflow.log_artifact("config.yaml")
# Beri tanda agar mudah dicari
mlflow.set_tag("jenis_model", "gradient_boosting")
mlflow.set_tag("tujuan", "baseline")
print(f"F1: {f1_score(y_test, tebakan):.4f}")
return pipeline
# Menjalankan beberapa percobaan
for lr in [0.01, 0.05, 0.1]:
for iterasi in [200, 500]:
latih_dan_catat(X_train, y_train, X_test, y_test,
{"learning_rate": lr, "max_iter": iterasi})
Melihat Hasilnya
mlflow ui
Buka http://localhost:5000 di peramban. Anda akan melihat tabel berisi semua percobaan, bisa diurutkan berdasarkan metrik, dan bisa dibandingkan berdampingan.
Mencari Percobaan Terbaik lewat Kode
from mlflow.tracking import MlflowClient
client = MlflowClient()
eksperimen = client.get_experiment_by_name("prediksi-churn")
daftar_run = client.search_runs(
experiment_ids=[eksperimen.experiment_id],
order_by=["metrics.f1 DESC"],
max_results=5,
)
for run in daftar_run:
print(f"F1={run.data.metrics['f1']:.4f} "
f"lr={run.data.params.get('learning_rate')} "
f"iter={run.data.params.get('max_iter')}")
Kebiasaan yang Berguna
Catat setiap percobaan, termasuk yang gagal. Mengetahui bahwa suatu pendekatan sudah dicoba dan tidak berhasil sama berharganya dengan mengetahui yang berhasil.
Membungkus Model Menjadi API
Kenapa Perlu API
Model di dalam file .joblib hanya bisa dipakai oleh orang yang punya Python, punya pustaka yang sama, dan tahu cara memuatnya.
Dengan API, aplikasi apa pun bisa memakainya: aplikasi ponsel, situs web, sistem lain, bahkan spreadsheet.
FastAPI atau Flask
Keduanya bisa. Untuk proyek baru, pilih FastAPI.
Alasannya: validasi masukan otomatis, dokumentasi interaktif yang dibuat sendiri, lebih cepat, dan kode lebih ringkas.
pip install fastapi uvicorn pydantic
API Paling Sederhana
# api/main.py
from fastapi import FastAPI
app = FastAPI(title="API Prediksi Churn")
@app.get("/")
def sapa():
return {"pesan": "API berjalan"}
uvicorn api.main:app --reload
Buka http://localhost:8000/docs untuk melihat dokumentasi interaktif yang dibuat otomatis.
API yang Layak Produksi
# api/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import List, Optional
import pandas as pd
import logging
import time
from datetime import datetime
from src.predict import PelayanModel
from src.features import buat_fitur_dasar
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(
title="API Prediksi Churn",
description="Memprediksi kemungkinan pelanggan berhenti berlangganan",
version="1.0.0",
)
pelayan = PelayanModel("models/model_terbaru.joblib")
# === Bentuk data masukan ===
class DataPelanggan(BaseModel):
umur: int = Field(..., ge=17, le=120, description="Umur dalam tahun")
pendapatan: float = Field(..., gt=0, description="Pendapatan bulanan")
utang: float = Field(..., ge=0)
total_transaksi: int = Field(..., ge=0)
tanggal_daftar: str
kota: str
paket: str
class Config:
json_schema_extra = {
"example": {
"umur": 35,
"pendapatan": 12_000_000,
"utang": 3_000_000,
"total_transaksi": 42,
"tanggal_daftar": "2023-05-14",
"kota": "Palu",
"paket": "premium",
}
}
class HasilPrediksi(BaseModel):
probabilitas: float
keputusan: int
tingkat_risiko: str
versi_model: str
waktu_proses_ms: float
# === Titik akhir ===
@app.get("/health")
def periksa_kesehatan():
"""Dipakai sistem pemantau untuk memastikan API hidup."""
return {
"status": "sehat",
"versi_model": pelayan.versi,
"waktu": datetime.now().isoformat(),
}
@app.post("/prediksi", response_model=HasilPrediksi)
def prediksi(data: DataPelanggan):
mulai = time.time()
try:
df = pd.DataFrame([data.model_dump()])
df = buat_fitur_dasar(df) # fungsi yang SAMA dengan saat pelatihan
hasil = pelayan.prediksi(df)
prob = hasil["probabilitas"][0]
if prob >= 0.7:
risiko = "tinggi"
elif prob >= 0.4:
risiko = "sedang"
else:
risiko = "rendah"
durasi = (time.time() - mulai) * 1000
logger.info(f"Prediksi selesai: prob={prob:.4f}, waktu={durasi:.1f}ms")
return HasilPrediksi(
probabilitas=round(prob, 4),
keputusan=hasil["keputusan"][0],
tingkat_risiko=risiko,
versi_model=hasil["versi_model"],
waktu_proses_ms=round(durasi, 2),
)
except Exception as e:
logger.error(f"Prediksi gagal: {e}")
raise HTTPException(status_code=500, detail="Terjadi kesalahan internal")
@app.post("/prediksi-banyak")
def prediksi_banyak(daftar: List[DataPelanggan]):
"""Memproses banyak data sekaligus, jauh lebih efisien."""
if len(daftar) > 1000:
raise HTTPException(status_code=400,
detail="Maksimal 1000 data per permintaan")
df = pd.DataFrame([d.model_dump() for d in daftar])
df = buat_fitur_dasar(df)
hasil = pelayan.prediksi(df)
return {"jumlah": len(daftar), "hasil": hasil}
Tiga Hal yang Membuat API Ini Layak Produksi
Validasi masukan otomatis. Bagian Field(..., ge=17, le=120) membuat FastAPI menolak umur 500 sebelum data sampai ke model, beserta pesan kesalahan yang jelas.
Titik akhir kesehatan. Sistem pemantau memanggil /health setiap beberapa detik. Kalau tidak menjawab, tim langsung diberi tahu.
Pencatatan log. Tiap permintaan dicatat, sehingga saat ada masalah Anda punya jejak untuk ditelusuri.
Perhatikan juga bahwa fungsi buat_fitur_dasar yang dipakai di sini persis sama dengan yang dipakai saat pelatihan. Ini mencegah bug paling umum di produksi: perhitungan fitur yang berbeda antara pelatihan dan penggunaan.
Mengujinya
# tests/test_api.py
import requests
data = {
"umur": 35,
"pendapatan": 12_000_000,
"utang": 3_000_000,
"total_transaksi": 42,
"tanggal_daftar": "2023-05-14",
"kota": "Palu",
"paket": "premium",
}
respons = requests.post("http://localhost:8000/prediksi", json=data)
print(respons.status_code)
print(respons.json())
# Uji juga masukan yang salah
data_salah = {**data, "umur": 500}
print(requests.post("http://localhost:8000/prediksi", json=data_salah).status_code)
Docker
Masalah yang Diselesaikan
Kalimat "tapi di komputer saya jalan" adalah masalah klasik.
Penyebabnya bisa bermacam-macam: versi Python berbeda, pustaka berbeda, sistem operasi berbeda, atau ada pustaka sistem yang terpasang di satu komputer tapi tidak di komputer lain.
Docker menyelesaikannya dengan membungkus aplikasi beserta seluruh lingkungannya menjadi satu paket yang berjalan sama di mana pun.
Analogi
Bayangkan mengirim masakan ke kota lain. Anda tidak cuma mengirim resepnya, tapi mengirim dapur lengkap beserta kompor, bumbu, dan semua peralatannya.
Apa pun kondisi tempat tujuan, hasilnya akan sama.
Dua Konsep
Image adalah cetak birunya, berisi sistem operasi, pustaka, dan kode aplikasi.
Container adalah image yang sedang berjalan. Satu image bisa menjalankan banyak container.
Dockerfile untuk Proyek ML
# api/Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Pasang pustaka sistem yang dibutuhkan
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Salin daftar pustaka lebih dulu agar cache Docker bekerja
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Baru salin kode dan model
COPY src/ ./src/
COPY api/ ./api/
COPY models/model_terbaru.joblib ./models/
COPY config.yaml .
# Jalankan sebagai pengguna biasa, bukan root
RUN useradd -m pengguna && chown -R pengguna:pengguna /app
USER pengguna
EXPOSE 8000
HEALTHCHECK \
CMD python -c "import requests; requests.get('http://localhost:8000/health')"
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
Membangun dan Menjalankan
# Membangun image
docker build -t prediksi-churn:1.0 -f api/Dockerfile .
# Menjalankan container
docker run -d -p 8000:8000 --name api-churn prediksi-churn:1.0
# Melihat yang sedang berjalan
docker ps
# Melihat catatan log
docker logs -f api-churn
# Menghentikan
docker stop api-churn
docker rm api-churn
Kenapa requirements.txt Disalin Duluan
Ini trik yang menghemat banyak waktu.
Docker menyimpan hasil tiap langkah. Kalau hanya kode yang berubah tapi daftar pustaka tetap, langkah pemasangan pustaka tidak diulang.
Kalau urutannya dibalik, setiap perubahan kode kecil akan memicu pemasangan ulang semua pustaka, yang bisa memakan waktu beberapa menit.
Menjalankan Beberapa Layanan Sekaligus
# docker-compose.yml
version: "3.8"
services:
api:
build:
context: .
dockerfile: api/Dockerfile
ports:
- "8000:8000"
environment:
- LOG_LEVEL=info
volumes:
- ./models:/app/models:ro # model bisa diganti tanpa build ulang
restart: unless-stopped
mlflow:
image: ghcr.io/mlflow/mlflow:latest
ports:
- "5000:5000"
volumes:
- ./mlruns:/mlruns
command: mlflow server --host 0.0.0.0 --backend-store-uri /mlruns
docker compose up -d
docker compose logs -f
docker compose down
Tips Mengecilkan Ukuran Image
Pakai python:3.11-slim alih-alih python:3.11 yang jauh lebih besar.
Buat file .dockerignore agar data dan file tidak perlu tidak ikut tersalin.
data/
notebooks/
venv/
mlruns/
.git/
__pycache__/
*.ipynb
Pemantauan dan Deteksi Data Drift
Kenapa Model Bisa Memburuk
Model yang bagus saat diluncurkan bisa memburuk perlahan tanpa ada yang mengubah kodenya.
Penyebabnya: dunia berubah, model tidak.
Perilaku pelanggan berubah. Pesaing baru muncul. Ada pandemi. Ada promo besar. Cara pencatatan data diubah oleh tim lain.
Tiga Hal yang Dipantau
Kesehatan sistem. Apakah API hidup? Berapa lama merespons? Berapa banyak yang gagal?
Perilaku model. Apakah sebaran prediksinya berubah? Apakah tiba-tiba semua diprediksi positif?
Kualitas data masuk. Apakah data yang datang masih mirip dengan data latih?
Mencatat Setiap Prediksi
Ini fondasi dari semua pemantauan. Tanpa catatan, Anda tidak bisa menganalisis apa pun.
# src/monitoring.py
import json
import uuid
from datetime import datetime
from pathlib import Path
class PencatatPrediksi:
def __init__(self, folder="logs/prediksi"):
self.folder = Path(folder)
self.folder.mkdir(parents=True, exist_ok=True)
def catat(self, masukan: dict, probabilitas: float,
keputusan: int, versi_model: str, durasi_ms: float):
catatan = {
"id": str(uuid.uuid4()),
"waktu": datetime.now().isoformat(),
"masukan": masukan,
"probabilitas": probabilitas,
"keputusan": keputusan,
"versi_model": versi_model,
"durasi_ms": durasi_ms,
}
# Satu file per hari
nama = self.folder / f"{datetime.now():%Y-%m-%d}.jsonl"
with open(nama, "a") as f:
f.write(json.dumps(catatan) + "\n")
Perhatikan bahwa hasil sebenarnya tidak dicatat di sini, karena biasanya baru diketahui berminggu-minggu kemudian. Nanti hasil itu digabungkan berdasarkan id.
Memantau Sebaran Prediksi
Ini sinyal peringatan paling awal dan paling murah.
import pandas as pd
import numpy as np
def ringkas_harian(path_log):
baris = [json.loads(l) for l in open(path_log)]
df = pd.DataFrame(baris)
return {
"jumlah_prediksi": len(df),
"rata_probabilitas": df["probabilitas"].mean(),
"proporsi_positif": df["keputusan"].mean(),
"durasi_p50_ms": df["durasi_ms"].quantile(0.5),
"durasi_p95_ms": df["durasi_ms"].quantile(0.95),
}
Kalau proporsi_positif biasanya 0,15 lalu tiba-tiba menjadi 0,45, ada yang tidak beres. Bisa jadi data masuknya berubah, bisa jadi ada bug di sistem yang memanggil API.
Dua Jenis Drift
Data drift berarti sebaran data masuk berubah. Contohnya, rata-rata umur pengguna turun karena kampanye pemasaran menyasar anak muda.
Concept drift berarti hubungan antara fitur dan target yang berubah. Contohnya, dulu pelanggan yang banyak komplain cenderung berhenti, sekarang tidak lagi karena layanan sudah diperbaiki.
Data drift bisa dideteksi tanpa label. Concept drift butuh label, sehingga baru ketahuan belakangan.
Mendeteksi Data Drift dengan PSI
PSI, singkatan dari Population Stability Index, adalah metode yang paling banyak dipakai di industri karena sederhana dan mudah ditafsirkan.
def hitung_psi(acuan, terkini, jumlah_bin=10):
"""
Membandingkan sebaran data acuan dengan data terkini.
Nilai kecil berarti stabil, nilai besar berarti berubah.
"""
batas = np.percentile(acuan, np.linspace(0, 100, jumlah_bin + 1))
batas[0], batas[-1] = -np.inf, np.inf
hitung_acuan = np.histogram(acuan, bins=batas)[0] / len(acuan)
hitung_terkini = np.histogram(terkini, bins=batas)[0] / len(terkini)
# Hindari pembagian nol
hitung_acuan = np.clip(hitung_acuan, 1e-6, None)
hitung_terkini = np.clip(hitung_terkini, 1e-6, None)
return np.sum((hitung_terkini - hitung_acuan) *
np.log(hitung_terkini / hitung_acuan))
def tafsirkan_psi(nilai):
if nilai < 0.1:
return "stabil"
elif nilai < 0.25:
return "perlu diperhatikan"
else:
return "berubah signifikan"
Ambang PSI yang Umum Dipakai
| Nilai PSI | Artinya | Tindakan |
|---|---|---|
| Di bawah 0,10 | Sebaran stabil | Tidak perlu tindakan |
| 0,10 sampai 0,25 | Ada pergeseran sedang | Amati lebih sering |
| Di atas 0,25 | Pergeseran besar | Selidiki dan pertimbangkan melatih ulang |
Laporan Drift Lengkap
from scipy import stats
def periksa_drift(data_acuan: pd.DataFrame, data_terkini: pd.DataFrame,
kolom_numerik, kolom_kategori):
laporan = []
for kolom in kolom_numerik:
psi = hitung_psi(data_acuan[kolom].dropna(),
data_terkini[kolom].dropna())
# Uji statistik sebagai pelengkap
_, p = stats.ks_2samp(data_acuan[kolom].dropna(),
data_terkini[kolom].dropna())
laporan.append({
"kolom": kolom,
"jenis": "numerik",
"psi": round(psi, 4),
"status": tafsirkan_psi(psi),
"p_value_ks": round(p, 4),
"rata_acuan": round(data_acuan[kolom].mean(), 2),
"rata_terkini": round(data_terkini[kolom].mean(), 2),
})
for kolom in kolom_kategori:
p_acuan = data_acuan[kolom].value_counts(normalize=True)
p_terkini = data_terkini[kolom].value_counts(normalize=True)
semua = set(p_acuan.index) | set(p_terkini.index)
a = np.array([p_acuan.get(k, 1e-6) for k in semua])
t = np.array([p_terkini.get(k, 1e-6) for k in semua])
psi = np.sum((t - a) * np.log(t / a))
# Kategori baru yang belum pernah ada saat pelatihan
kategori_baru = set(p_terkini.index) - set(p_acuan.index)
laporan.append({
"kolom": kolom,
"jenis": "kategori",
"psi": round(psi, 4),
"status": tafsirkan_psi(psi),
"kategori_baru": list(kategori_baru) if kategori_baru else None,
})
return pd.DataFrame(laporan).sort_values("psi", ascending=False)
# Contoh pemakaian
# hasil = periksa_drift(data_latih, data_bulan_ini, KOLOM_NUMERIK, KOLOM_KATEGORI)
# print(hasil.to_string(index=False))
Bagian kategori_baru sangat berguna. Munculnya kota atau paket yang belum pernah ada saat pelatihan adalah tanda jelas bahwa sesuatu berubah.
Memantau Kualitas Sebenarnya
Kalau label sudah tersedia, ini pemantauan yang paling langsung.
def pantau_kualitas(prediksi_lama, label_sebenarnya, metrik_saat_deploy):
from sklearn.metrics import f1_score, recall_score
f1_sekarang = f1_score(label_sebenarnya, prediksi_lama)
penurunan = metrik_saat_deploy["f1"] - f1_sekarang
status = "baik"
if penurunan > 0.10:
status = "MENURUN DRASTIS, segera latih ulang"
elif penurunan > 0.05:
status = "menurun, siapkan pelatihan ulang"
return {
"f1_saat_deploy": round(metrik_saat_deploy["f1"], 4),
"f1_sekarang": round(f1_sekarang, 4),
"penurunan": round(penurunan, 4),
"status": status,
}
Kapan Alarm Dinyalakan
Jangan menyalakan alarm untuk setiap pergeseran kecil, karena tim akan berhenti memperhatikannya.
Alarm yang wajar:
- API tidak merespons atau tingkat kegagalan di atas 1 persen
- Waktu respons melampaui batas yang disepakati
- Proporsi prediksi positif berubah lebih dari dua kali lipat
- PSI di atas 0,25 pada fitur yang termasuk paling berpengaruh
- Muncul kategori baru pada kolom penting
- Metrik kualitas turun lebih dari 5 persen
Pipeline Pelatihan Ulang
Kapan Model Perlu Dilatih Ulang
Ada tiga pemicu yang umum dipakai.
Berdasarkan jadwal. Setiap bulan atau setiap kuartal, tanpa syarat. Sederhana dan mudah dijalankan.
Berdasarkan pemicu. Kalau PSI melewati ambang atau metrik turun. Lebih efisien tapi butuh pemantauan yang berjalan.
Berdasarkan penambahan data. Kalau sudah terkumpul sejumlah data baru.
Untuk pemula, mulai dari jadwal bulanan. Tambahkan pemicu setelah pemantauan berjalan.
Aturan Paling Penting: Model Baru Harus Lolos Ujian
Ini yang sering dilupakan dan akibatnya bisa fatal.
Model baru tidak boleh langsung menggantikan model lama hanya karena lebih baru. Ia harus dibuktikan lebih baik dulu.
# src/retrain.py
import shutil
from datetime import datetime
def latih_ulang_dengan_pengaman(data_baru, model_lama_path, config):
"""Melatih model baru dan hanya menggantinya kalau lolos semua ujian."""
X, y = siapkan_data(data_baru)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42)
# Latih model baru
model_baru = bangun_pipeline().fit(X_train, y_train)
# Muat model lama untuk pembanding
paket_lama = joblib.load(model_lama_path)
model_lama = paket_lama["pipeline"]
ambang = paket_lama["ambang"]
# Uji keduanya pada data uji yang SAMA
def hitung_f1(m):
proba = m.predict_proba(X_test)[:, 1]
return f1_score(y_test, (proba >= ambang).astype(int))
f1_baru = hitung_f1(model_baru)
f1_lama = hitung_f1(model_lama)
# Daftar ujian yang harus dilewati
ujian = {
"lebih_baik_dari_lama": f1_baru > f1_lama,
"di_atas_minimal": f1_baru >= config["ambang"]["minimal_f1_untuk_deploy"],
"data_cukup": len(X_train) >= 1000,
"tidak_semua_satu_kelas": 0.01 < model_baru.predict(X_test).mean() < 0.99,
}
print(f"F1 model lama : {f1_lama:.4f}")
print(f"F1 model baru : {f1_baru:.4f}")
for nama, lulus in ujian.items():
print(f" {nama:26s}: {'LULUS' if lulus else 'GAGAL'}")
if not all(ujian.values()):
print("\nModel baru TIDAK dipasang. Model lama tetap dipakai.")
return None
# Simpan model lama sebagai cadangan sebelum diganti
cadangan = f"models/cadangan_{datetime.now():%Y%m%d_%H%M%S}.joblib"
shutil.copy(model_lama_path, cadangan)
print(f"\nModel lama dicadangkan ke {cadangan}")
path_baru = simpan_model(model_baru, X_test, y_test, ambang)
print("Model baru dipasang.")
return path_baru
Kenapa Perlu Cadangan
Kalau model baru ternyata bermasalah di produksi, Anda butuh cara cepat kembali ke keadaan sebelumnya. Ini disebut rollback.
Tanpa cadangan, satu-satunya pilihan adalah melatih ulang dari awal sambil sistem bermasalah.
Tiga Strategi Memasang Model Baru
Shadow deployment. Model baru dijalankan berdampingan dengan model lama, tapi hasilnya hanya dicatat, tidak dipakai. Setelah beberapa hari, bandingkan. Ini cara paling aman.
Canary deployment. Model baru melayani sebagian kecil lalu lintas, misalnya 5 persen. Kalau tidak ada masalah, proporsinya dinaikkan bertahap.
A/B testing. Lalu lintas dibagi dua secara acak, lalu dampaknya terhadap ukuran bisnis dibandingkan secara statistik.
Untuk pemula, mulai dari shadow deployment. Risikonya nol karena hasilnya tidak dipakai.
Menjadwalkan Pelatihan Ulang
# Cara paling sederhana: cron
# Jalankan setiap tanggal 1 pukul 2 pagi
0 2 1 * * cd /path/proyek && venv/bin/python src/retrain.py >> logs/retrain.log 2>&1
Untuk kebutuhan yang lebih rumit dengan banyak tahap yang saling bergantung, ada alat khusus seperti Airflow atau Prefect. Tapi untuk memulai, cron sudah memadai.
Peta Jalan Belajar
Jangan mencoba menguasai semuanya sekaligus. Ini urutan yang masuk akal.
Tingkat 1: Dasar yang Wajib
Kuasai Git, susun proyek dengan struktur folder yang rapi, pindahkan kode dari notebook ke modul, simpan model beserta metadatanya, dan tulis README.md yang menjelaskan cara menjalankan proyek.
Ini saja sudah membuat Anda berbeda dari kebanyakan orang yang hanya bekerja di notebook.
Tingkat 2: Bisa Dipakai Orang Lain
Bungkus model menjadi API dengan FastAPI, tambahkan validasi masukan dan titik akhir kesehatan, catat semua prediksi, dan tulis pengujian dasar.
Tingkat 3: Bisa Dijalankan di Mana Saja
Pelajari Docker, pakai MLflow untuk mencatat percobaan, dan siapkan pipeline otomatis sederhana yang menjalankan pengujian saat kode diubah.
Tingkat 4: Sistem yang Dipelihara
Bangun pemantauan dan deteksi drift, siapkan pipeline pelatihan ulang beserta pengamannya, dan pelajari strategi pemasangan bertahap.
Yang Belum Perlu Dipelajari Sekarang
Kubernetes, feature store, layanan penyajian model berskala besar, dan alat orkestrasi rumit. Semua itu berguna pada skala tertentu, tapi hampir tidak pernah dibutuhkan untuk proyek pertama sampai kelima Anda.
Membangun sistem yang terlalu rumit untuk kebutuhan yang sederhana adalah kesalahan yang umum dan mahal.
Kesalahan Pemula yang Sering Terjadi
Menyimpan Model Tanpa Pipeline Persiapannya
Model yang disimpan sendirian tanpa langkah penskalaan dan encoding akan menghasilkan prediksi yang salah saat dipakai. Simpan seluruh Pipeline sebagai satu objek.
Perhitungan Fitur Berbeda antara Pelatihan dan Produksi
Ini bug paling umum dan paling sulit ditemukan. Solusinya sederhana: pakai fungsi yang sama persis di kedua tempat, disimpan di satu modul bersama.
Menaruh Rahasia di Dalam Kode
Kunci API dan kata sandi tidak boleh masuk ke Git. Pakai file .env dan pastikan masuk .gitignore.
Mengunggah Data ke Git
Repo jadi membengkak, dan kalau datanya berisi informasi pribadi, itu masalah hukum. Pakai .gitignore sejak awal.
Tidak Mencatat Versi Pustaka
Model yang dilatih dengan scikit-learn 1.3 bisa gagal dimuat oleh versi 1.5. Simpan requirements.txt dengan versi yang terkunci.
Tidak Mencatat Prediksi di Produksi
Tanpa catatan, Anda tidak bisa mendeteksi drift, tidak bisa menyelidiki keluhan, dan tidak bisa mengumpulkan label untuk pelatihan ulang.
Mengganti Model Tanpa Diuji Dulu
Model baru belum tentu lebih baik. Selalu bandingkan dengan model lama pada data uji yang sama, dan siapkan cadangan sebelum mengganti.
Menganggap Model Selesai Setelah Dipasang
Model memburuk seiring waktu tanpa ada yang mengubah kodenya. Pemantauan bukan pelengkap, melainkan bagian dari sistem.
Membuat Sistem Terlalu Rumit di Awal
Kubernetes untuk model yang dipanggil 100 kali sehari itu berlebihan. Mulai sederhana, tambah kerumitan hanya kalau memang dibutuhkan.
Tidak Menulis README
Tiga bulan lagi Anda sendiri tidak akan ingat cara menjalankan proyek Anda. Tulis cara memasang, cara melatih, cara menjalankan API, dan di mana datanya.
Penutup
Perbedaan antara notebook yang bagus dan sistem yang jalan bukan soal model yang lebih canggih. Perbedaannya ada pada hal-hal yang jarang dibahas di kursus machine learning: penataan kode, pencatatan, pengemasan, pemantauan, dan pemeliharaan.
Tiga hal untuk diingat:
Pertama, mulai dari Git dan struktur proyek yang rapi. Dua hal ini paling mudah dipelajari dan langsung membedakan cara Anda bekerja.
Kedua, pakai fungsi yang sama persis untuk membuat fitur saat pelatihan dan saat prediksi. Perbedaan sekecil apa pun di antara keduanya menghasilkan bug yang sangat sulit ditemukan.
Ketiga, model yang dipasang tanpa pemantauan akan memburuk diam-diam. Mencatat setiap prediksi adalah investasi kecil yang membuat semua bentuk pemantauan lain menjadi mungkin.