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.

FieldIsiContoh
idIdentitas unik berurutan"003"
namaLabel pendek yang dibaca manusia"logreg-C1"
paramParameter yang membedakan run ini{"C": 1.0, "max_iter": 1000}
metrikSkor 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.

python
# 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:

text
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:

  1. 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.
  2. Baris 45–51 (konfigurasi bersama): data dan cv dibangun 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.
  3. Baris 64–79 (melatih dan mencatat): tiap kandidat dilatih dalam Pipeline yang sama, get_params() diiris untuk hanya menyimpan parameter pembeda, dan dua metrik dicatat — rerata akurasi CV plus simpangan baku antar lipatan. Skor tanpa std_cv mudah menyesatkan: selisih 0,002 antar run yang simpangannya 0,03 bukan bukti apa-apa.
  4. Baris 82–83 (format baris): satu baris log bisa dibaca apa adanya — id, nama, param, metrik — karena sort_keys=True menjaga urutan kunci stabil. Inilah seluruh “skema basis data” kita.
  5. 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: C terkecil justru menang — pada data dengan sinyal lemah relatif terhadap derau, regularisasi kuat menekan model agar tidak menghafal noise; RandomForestClassifier dengan 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.

python
# 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:

text
== 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 : False

Tiga hasilnya membentuk pelajaran yang tegas:

  1. 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.
  2. 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.
  3. 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… vs 66e2…) langsung menandainya.
  4. Sidik jari (baris 39–42): sha256 atas json.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 #