Chapter Penyimpanan dan Pemuatan Model menutup dengan satu pertanyaan yang belum terjawab: dari sepuluh pelatihan dengan kombinasi hyperparameter berbeda, manakah yang layak menjadi artefak final? Jawaban itu tidak bisa datang dari memori — eksperimen machine learning mudah menumpuk, dan tanpa pencatatan yang disiplin, dalam dua minggu kita hanya ingat bahwa “suatu kombinasi parameter kemarin bagus”, tanpa bisa menyebut kombinasi yang mana. Chapter ini membangun sistem pencatatan tersebut: satu run eksperimen = konfigurasi + data + metrik yang terekam lengkap, tersimpan di disk, dan bisa dimuat ulang untuk dibandingkan. Kita membangun pelacak ringan sendiri berbasis JSONL agar mekanismenya transparan, lalu memetakan pola yang sama ke alat industri seperti MLflow.
Masalah: Eksperimen yang Tidak Meninggalkan Jejak #
Alur kerja yang tidak menjejak eksperimen punya pola kegagalan yang bisa diramal. Notebook diberi nama final.ipynb, lalu final_fix.ipynb, lalu final_beneran.ipynb — masing-masing berisi skor yang tidak bisa dikaitkan balik ke konfigurasi pembentuknya. Lima belas menit setelah percobaan terbaik ditemukan, angkanya masih ada di layar; lima hari kemudian, tidak ada yang bisa memproduksinya ulang. Di tim, masalahnya berlipat: dua orang menjalankan eksperimen yang persis sama tanpa sadar, dan tidak ada yang tahu run mana yang menghasilkan model yang sekarang dipakai produksi.
Manajemen eksperimen menuntut tiga hal sederhana yang harus terpenuhi bersamaan. Pertama, setiap run tercatat lengkap: parameter model, konfigurasi data, seed, dan metrik — bukan sebagian. Kedua, catatan itu persisten: tersimpan di disk atau server, bukan di memori sesi. Ketiga, bisa dibandingkan: memuat kembali seluruh riwayat dan mengurutkannya untuk memilih yang terbaik. Tiga syarat ini persis yang dipenuhi pelacak JSONL kita sebentar lagi.
Anatomi Satu Run Tercatat #
Satu run eksperimen layaknya satu baris catatan laboratorium. Minimal ia memuat empat field berikut.
| Field | Isi | Contoh |
|---|---|---|
id | Identitas unik berurutan | "003" |
nama | Label pendek yang dibaca manusia | "logreg-C1" |
param | Parameter yang membedakan run ini | {"C": 1.0, "max_iter": 1000} |
metrik | Skor terukur + simpangannya | {"akurasi_cv": 0.82, "std_cv": 0.0245} |
Dua hal yang tidak boleh tertinggal dari param: seed dan konfigurasi pembentuk data (jumlah baris, tingkat derau, pembagian lipatan). Tanpa keduanya, metrik terbaik sekalipun tidak bisa diproduksi ulang — bab terakhir chapter ini membuktikannya dengan eksperimen langsung.
Pelacak Eksperimen Berbasis JSONL #
JSONL (JSON Lines) memformat satu objek JSON per baris berkas — menambah run baru berarti menambah satu baris, tanpa membaca atau menulis ulang yang lama. Contoh berikut melatih lima kandidat (empat variasi C pada LogisticRegression plus satu RandomForestClassifier sebagai pembanding keluarga model), mencatat semuanya, lalu memuat log dari disk dan menyusun papan peringkat.
# Pelacak eksperimen ringan berbasis JSONL: setiap run mendapat ID
# berurutan, parameter dan metrik CV dicatat apa adanya, log disimpan ke
# disk lalu dimuat ulang untuk perbandingan dan pemilihan run terbaik.
# Tanpa pustaka eksternal -- pola inti yang sama dipakai alat tracking
# seperti MLflow (dibahas konseptual di index.md).
import json
import os
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
BERKAS = "pelacak_eksperimen.jsonl"
class PelacakEksperimen:
"""Catat run eksperimen ke berkas JSONL, satu run satu baris."""
def __init__(self, berkas=BERKAS):
self.berkas = berkas
self.runs = []
def catat(self, nama, param, metrik):
id_run = len(self.runs) + 1
run = {
"id": f"{id_run:03d}",
"nama": nama,
"param": param,
"metrik": metrik,
}
self.runs.append(run)
with open(self.berkas, "a", encoding="utf-8") as f:
f.write(json.dumps(run, sort_keys=True) + "\n")
return run
def muat(self):
with open(self.berkas, encoding="utf-8") as f:
return [json.loads(baris) for baris in f if baris.strip()]
# ---------- Data tetap untuk semua run ----------
rng = np.random.default_rng(42)
n = 500
X = rng.normal(0, 1, size=(n, 8))
logit = 1.2 * X[:, 0] + 0.8 * X[:, 1] - 0.6 * X[:, 2] + rng.normal(0, 1, n)
y = (logit > 0).astype(int)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
pelacak = PelacakEksperimen()
kandidat = [
("logreg-C0.01", LogisticRegression(C=0.01, max_iter=1000)),
("logreg-C0.1", LogisticRegression(C=0.1, max_iter=1000)),
("logreg-C1", LogisticRegression(C=1.0, max_iter=1000)),
("logreg-C10", LogisticRegression(C=10.0, max_iter=1000)),
("rf-200", RandomForestClassifier(
n_estimators=200, random_state=42, n_jobs=1)),
]
for nama, model in kandidat:
pipeline = Pipeline(
[("skala", StandardScaler()), ("model", model)]
)
skor = cross_val_score(pipeline, X, y, cv=cv, scoring="accuracy")
param = model.get_params()
run = pelacak.catat(
nama,
param={k: param[k] for k in sorted(param) if k in
("C", "max_iter", "n_estimators", "random_state")},
metrik={
"akurasi_cv": round(float(skor.mean()), 4),
"std_cv": round(float(skor.std()), 4),
},
)
print(f"run {run['id']} {nama:12s} tercatat: akurasi_cv={skor.mean():.4f}")
# ---------- Format satu baris log ----------
print("\n== Satu baris log (format JSONL) ==")
print(json.dumps(pelacak.runs[2], sort_keys=True))
# ---------- Muat ulang log dari disk ----------
dimuat = pelacak.muat()
print(f"\n== Log dimuat ulang dari {BERKAS}: {len(dimuat)} run ==")
urut = sorted(dimuat, key=lambda r: r["metrik"]["akurasi_cv"], reverse=True)
print("\n== Papan peringkat (diurutkan akurasi CV) ==")
print(f"{'id':>4s} {'nama':12s} {'akurasi':>8s} {'std':>7s}")
for r in urut:
print(f"{r['id']:>4s} {r['nama']:12s} "
f"{r['metrik']['akurasi_cv']:8.4f} {r['metrik']['std_cv']:7.4f}")
terbaik = urut[0]
print(f"\nrun terbaik: {terbaik['id']} ({terbaik['nama']}), "
f"akurasi CV {terbaik['metrik']['akurasi_cv']:.4f}")
os.remove(BERKAS)
print(f"\n{BERKAS} dihapus (contoh selesai)")Output yang Dihasilkan:
run 001 logreg-C0.01 tercatat: akurasi_cv=0.8320
run 002 logreg-C0.1 tercatat: akurasi_cv=0.8240
run 003 logreg-C1 tercatat: akurasi_cv=0.8200
run 004 logreg-C10 tercatat: akurasi_cv=0.8200
run 005 rf-200 tercatat: akurasi_cv=0.8180
== Satu baris log (format JSONL) ==
{"id": "003", "metrik": {"akurasi_cv": 0.82, "std_cv": 0.0245}, "nama": "logreg-C1", "param": {"C": 1.0, "max_iter": 1000, "random_state": null}}
== Log dimuat ulang dari pelacak_eksperimen.jsonl: 5 run ==
== Papan peringkat (diurutkan akurasi CV) ==
id nama akurasi std
001 logreg-C0.01 0.8320 0.0256
002 logreg-C0.1 0.8240 0.0301
003 logreg-C1 0.8200 0.0245
004 logreg-C10 0.8200 0.0245
005 rf-200 0.8180 0.0293
run terbaik: 001 (logreg-C0.01), akurasi CV 0.8320
pelacak_eksperimen.jsonl dihapus (contoh selesai)Mari kita bedah mekanisme pelacaknya:
- Baris 20–42 (kelas PelacakEksperimen):
catat()menomori run secara berurutan (001,002, …), menyusun kamus run, lalu menambahkannya sebagai satu baris JSON di akhir berkas — mode"a"tidak pernah menimpa riwayat.muat()membaca balik seluruh baris menjadi daftar kamus; log di disk adalah sumber kebenaran, bukan atribut di memori. - Baris 45–51 (konfigurasi bersama): data dan
cvdibangun sekali dengan seed 42 lalu dipakai semua run — hanya parameter model yang berbeda antar run. Mengubah data tanpa mencatatnya adalah cara klasik membuat log tidak bisa dipercaya. - Baris 64–79 (melatih dan mencatat): tiap kandidat dilatih dalam
Pipelineyang sama,get_params()diiris untuk hanya menyimpan parameter pembeda, dan dua metrik dicatat — rerata akurasi CV plus simpangan baku antar lipatan. Skor tanpastd_cvmudah menyesatkan: selisih 0,002 antar run yang simpangannya 0,03 bukan bukti apa-apa. - Baris 82–83 (format baris): satu baris log bisa dibaca apa adanya —
id,nama,param,metrik— karenasort_keys=Truemenjaga urutan kunci stabil. Inilah seluruh “skema basis data” kita. - Baris 86–98 (perbandingan): log dimuat ulang dari disk, diurutkan menurun berdasarkan
akurasi_cv, dan run terbaik ditunjuk:001 (logreg-C0.01)dengan akurasi 0,8320. Perhatikan pola hasilnya:Cterkecil justru menang — pada data dengan sinyal lemah relatif terhadap derau, regularisasi kuat menekan model agar tidak menghafal noise;RandomForestClassifierdengan 200 pohon pun kalah tipis dari regresi logistik paling teratur.
Papan peringkat seperti ini menjawab “run mana yang terbaik” secara langsung — dan karena tiap baris menyimpan parameternya, jawaban itu langsung bisa dieksekusi: latih ulang run 001, simpan sebagai artefak dengan prosedur chapter sebelumnya.
Kunci Reproduksibilitas #
Pencatatan hanya berharga jika run yang tercatat bisa diproduksi ulang. Contoh kedua mengujinya secara frontal: fungsi latih_dan_nilai() menerima satu kamus konfigurasi lengkap (seed data, jumlah baris, derau, lipatan CV, parameter model) dan mengembalikan satu angka. Sesi A dijalankan “hari Senin”; Sesi B mengklaim mengulangnya “hari Jumat”; Sesi C mengganti satu nilai saja — seed data dari 42 menjadi 7.
# Kunci reproduksibilitas: konfigurasi + seed yang sama memberi hasil
# identik lintas sesi; satu saja yang berubah, hasil bergeser. Sidik jari
# SHA-256 atas konfigurasi (JSON dengan kunci terurut) menjadi alat
# banding yang murah: sidik sama = konfigurasi sama.
import hashlib
import json
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
def latih_dan_nilai(konfig):
"""Satu 'sesi pelatihan' lengkap, ditentukan penuh oleh konfigurasi."""
rng = np.random.default_rng(konfig["seed"])
n = konfig["n_baris"]
X = rng.normal(0, 1, size=(n, 6))
logit = (1.0 * X[:, 0] + 0.7 * X[:, 1] - 0.5 * X[:, 3]
+ rng.normal(0, konfig["derau"], n))
y = (logit > 0).astype(int)
cv = StratifiedKFold(
n_splits=konfig["cv"], shuffle=True, random_state=konfig["seed"]
)
pipeline = Pipeline(
[
("skala", StandardScaler()),
("model", LogisticRegression(
C=konfig["C"], max_iter=konfig["max_iter"])),
]
)
skor = cross_val_score(pipeline, X, y, cv=cv, scoring="accuracy")
return float(skor.mean())
def sidik_konfigurasi(konfig):
muatan = json.dumps(konfig, sort_keys=True).encode("utf-8")
return hashlib.sha256(muatan).hexdigest()[:12]
konfig_a = {
"seed": 42, "n_baris": 200, "derau": 1.0,
"cv": 5, "C": 1.0, "max_iter": 500,
}
konfig_b = dict(konfig_a) # Senin dan Jumat: konfigurasi sama
konfig_c = dict(konfig_a)
konfig_c["seed"] = 7 # satu nilai berubah
print("== Sesi A (Senin) ==")
skor_a = latih_dan_nilai(konfig_a)
print(f"konfigurasi : {konfig_a}")
print(f"sidik jari : {sidik_konfigurasi(konfig_a)}")
print(f"akurasi CV : {skor_a:.4f}")
print("\n== Sesi B (Jumat, klaim 'mengulang eksperimen Senin') ==")
skor_b = latih_dan_nilai(konfig_b)
print(f"sidik jari : {sidik_konfigurasi(konfig_b)}")
print(f"akurasi CV : {skor_b:.4f}")
print(f"hasil identik dengan Sesi A : {skor_a == skor_b}")
print("\n== Sesi C (seed data diganti 42 -> 7) ==")
skor_c = latih_dan_nilai(konfig_c)
print(f"sidik jari : {sidik_konfigurasi(konfig_c)}")
print(f"akurasi CV : {skor_c:.4f}")
print(f"hasil identik dengan Sesi A : {skor_a == skor_c}")
print("\n== Sidik jari konfigurasi ==")
print(f"A : {sidik_konfigurasi(konfig_a)}")
print(f"B : {sidik_konfigurasi(konfig_b)}")
print(f"C : {sidik_konfigurasi(konfig_c)}")
print(f"A sama dengan B : "
f"{sidik_konfigurasi(konfig_a) == sidik_konfigurasi(konfig_b)}")
print(f"A sama dengan C : "
f"{sidik_konfigurasi(konfig_a) == sidik_konfigurasi(konfig_c)}")Output yang Dihasilkan:
== Sesi A (Senin) ==
konfigurasi : {'seed': 42, 'n_baris': 200, 'derau': 1.0, 'cv': 5, 'C': 1.0, 'max_iter': 500}
sidik jari : f44f613e1e4c
akurasi CV : 0.7950
== Sesi B (Jumat, klaim 'mengulang eksperimen Senin') ==
sidik jari : f44f613e1e4c
akurasi CV : 0.7950
hasil identik dengan Sesi A : True
== Sesi C (seed data diganti 42 -> 7) ==
sidik jari : 66e21a37ad99
akurasi CV : 0.7150
hasil identik dengan Sesi A : False
== Sidik jari konfigurasi ==
A : f44f613e1e4c
B : f44f613e1e4c
C : 66e21a37ad99
A sama dengan B : True
A sama dengan C : FalseTiga hasilnya membentuk pelajaran yang tegas:
- Baris 16–36 (fungsi sesi): seluruh keputusan pelatihan — pembangkitan data, pembagian CV, parameter model — ditentukan hanya oleh kamus
konfig. Tidak ada nilai tersembunyi dari luar; itulah syarat reproduksibilitas struktural. - Sesi A dan B: konfigurasi identik memberi akurasi identik 0,7950 dan sidik jari identik
f44f613e1e4c. Klaim “Jumat mengulang Senin” terbukti tanpa perlu memercayai siapa pun. - Sesi C: satu angka berubah — seed data 42 menjadi 7 — dan akurasi jatuh ke 0,7150, terpaut 0,08 dari Sesi A. Satu parameter kecil yang tidak tercatat cukup untuk membuat seluruh eksperimen tidak bisa direproduksi; sidik jari konfigurasi yang berbeda (
f44f…vs66e2…) langsung menandainya. - Sidik jari (baris 39–42):
sha256atasjson.dumps(konfig, sort_keys=True)— murah, deterministik, dan bisa disimpan bersama run di log JSONL. Dua run dengan sidik jari sama dijamin berasal dari konfigurasi yang sama persis.
Dari Pelacak Ringan ke MLflow #
Pelacak JSONL kita menyelesaikan kebutuhan satu orang dan satu mesin. Kebutuhan industri menambah tiga lapis di atasnya. Lapis penyimpanan bersama: log dan artefak model disimpan di server atau objek storage sehingga seluruh tim melihat riwayat yang sama. Lapis antarmuka: antarmuka web untuk membandingkan ratusan run, menyaring metrik, dan memvisualkan kurva — pekerjaan yang di skala kecil kita kerjakan dengan sorted(). Lapis registri model: run terbaik dipromosikan menjadi versi model terdaftar dengan tahapan (staging, production) sehingga penurunan artefak dari pelatihan ke serving — materi Section Deployment dan Operasional — memiliki jejak resmi.
MLflow adalah alat open source paling umum untuk ketiga lapis tersebut; konsepnya identik dengan pelacak kita — run memuat parameter, metrik, dan artefak — hanya mekanismenya yang diganti dari berkas JSONL lokal menjadi server tracking. Karena itu memahami pelacak ringan di chapter ini bukan latihan akademis: skema param/metrik/artefak yang kita bangun adalah bahasa yang sama dipakai alat industri. Prinsip yang tidak berubah mana pun alatnya: apa yang tidak dicatat, tidak bisa direproduksi; apa yang tidak bisa direproduksi, tidak bisa dipercaya.
Eksperimen yang tercatat rapi kini menghasilkan konfigurasi dan model terpilih dengan metrik yang bisa dipertanggungjawabkan. Tetapi satu gerbang masih berdiri antara kode yang berhasil di notebook dan kode yang bisa dipindahkan ke produksi: kepercayaan bahwa komponen-komponennya berperilaku benar — skema data tidak diam-diam berubah, preprocessing tidak menghasilkan nilai kosong, prediksi tetap dalam rentang wajar. Gerbang itu dibangun dengan pengujian otomatis, dan pembangunannya adalah tugas Chapter Testing untuk Kode Machine Learning.
Referensi Resmi #
- MLflow Documentation — konsep tracking, run, dan model registry pada alat industri.
- Tuning hyperparameters of an estimator — scikit-learn User Guide
—
cv_results_, pola resmi pencatatan hasil pencarian grid. - sklearn.model_selection.cross_val_score — API — metrik rerata dan simpangan antar lipatan yang kita catat.