Sebuah model terlatih pada dasarnya adalah berkas biner: sekumpulan bobot yang tidak bisa dibaca dengan mata. Tanpa sistem versi, berkas model.npz hari ini dan berkas model.npz minggu lalu adalah dua benda berbeda yang tidak bisa dibedakan — sampai salah satunya dipakai dan hasilnya salah. Versioning memberi setiap artifact identitas permanen: nomor versi yang tidak dipakai ulang, metadata yang menjelaskan asal-usulnya, dan janji bahwa isi versi 1.1.0 tidak akan pernah berubah setelah ditetapkan. Chapter ini menerapkan pola tersebut pada tiga versi model regresi yang benar-benar disimpan dan dimuat kembali.

Mengapa Model Perlu Versi #

Empat kebutuhan operasional menuntut penomoran versi model. Pertama, A/B testing: dua versi harus bisa dipanggil dengan nama yang jelas (“v1.1.0” lawan “v2.0.0”) agar hasil perbandingan traffic tidak ambigu. Kedua, audit: ketika regulator atau tim kepatuhan bertanya “model versi mana yang membuat keputusan pada tanggal tertentu”, jawabannya harus berupa nomor versi yang bisa ditelusuri. Ketiga, rollback: membatalkan rilis buruk berarti kembali ke versi sebelumnya — mustahil bila versi sebelumnya sudah tertimpa. Keempat, reproduksi: versi menautkan model ke data dan kode yang menghasilkannya.

Skema Penomoran #

Praktik umum meminjam semantic versioning dari dunia perangkat lunak, dengan makna yang disesuaikan untuk model:

KomponenKapan dinaikkanContoh
MAJORperilaku berubah: arsitektur baru, dataset baru, hasil prediksi berbeda signifikandata latih diganti: 1.1.0 -> 2.0.0
MINORperbaikan kompatibel: lebih banyak epoch, data tambahan, tuning hiperparameterepoch dinaikkan: 1.0.0 -> 1.1.0
PATCHperbaikan non-model: preprocessing, metadata, dokumentasikoreksi skala fitur: 1.1.0 -> 1.1.1

Prinsip kedua adalah immutability: begitu versi 1.1.0 ditetapkan, artifact-nya dikunci. Perbaikan apa pun menjadi versi baru, bukan menimpa versi lama. Pola penyimpanan model produksi lengkap (state_dict, konfigurasi arsitektur, parameter preprocessing) sudah kita bangun di chapter Menyimpan Model untuk Produksi ; di sini kita menambahkan lapisan penomoran di atasnya.

Contoh Teruji: Tiga Versi dan Sebuah Registry File #

Berkas versi_model.py melatih regresi linear NumPy tiga kali dengan perbaikan bertahap: rilis perdana, patch yang menambah epoch, dan rilis major dengan dataset baru. Setiap versi disimpan sebagai .npz plus entri di index.json, lalu versi 1.1.0 dimuat kembali untuk memprediksi:

python
"""Model versioning mini: tiga versi model + berkas index.json.

Regresi linear NumPy dilatih tiga kali dengan perbaikan bertahap.
Setiap versi disimpan sebagai artifact (berkas .npz) yang tidak dapat
diubah, plus metadata di index.json: versi semver, versi dataset,
versi kode, metrik, dan hash bobot. Riwayat dibaca ulang dari disk,
versi tertentu dimuat kembali, lalu dipakai untuk memprediksi.
"""

import hashlib
import json
import os
import shutil

import numpy as np

STORE = "model_store"


def buat_data(n, seed):
    """Regresi y = 3x + 4 + derau, deterministik lewat seed."""
    rng = np.random.default_rng(seed)
    X = rng.uniform(0, 4, size=(n, 1))
    y = 3.0 * X[:, 0] + 4.0 + rng.normal(0, 0.8, size=n)
    return X, y


def latih(X, y, epoch, lr=0.05):
    """Gradient descent untuk regresi linear (bias di w[0])."""
    n = len(y)
    w = np.zeros(2)
    for _ in range(epoch):
        pred = X[:, 0] * w[1] + w[0]
        galat = pred - y
        w[0] -= lr * galat.mean()
        w[1] -= lr * (X[:, 0] @ galat) / n
    return w


def rmse(w, X, y):
    pred = X[:, 0] * w[1] + w[0]
    return float(np.sqrt(np.mean((pred - y) ** 2)))


def hash_bobot(w):
    return hashlib.sha256(w.tobytes()).hexdigest()[:12]


def simpan_versi(versi, w, metrik, vdata, vkode, versi_data_ke):
    """Simpan artifact + metadata; versi yang sama ditolak (immutable)."""
    index_path = f"{STORE}/index.json"
    index = {"model": "regresi_harga", "versi": []}
    if os.path.exists(index_path):
        with open(index_path, encoding="utf-8") as f:
            index = json.load(f)
    if any(v["versi"] == versi for v in index["versi"]):
        print(f"  TOLAK: versi {versi} sudah ada -> artifact tidak "
              f"ditimpa (immutability)")
        return None

    np.savez(f"{STORE}/model_{versi}.npz", bobot=w)
    entri = {
        "versi": versi,
        "versi_data": vdata,
        "versi_kode": vkode,
        "rmse_valid": round(metrik, 4),
        "hash_bobot": hash_bobot(w),
        "dilatih_dengan": versi_data_ke,
    }
    index["versi"].append(entri)
    with open(index_path, "w", encoding="utf-8") as f:
        json.dump(index, f, indent=2)
    print(f"  versi {versi:<7} tersimpan  rmse={metrik:.4f}  "
          f"hash={entri['hash_bobot'][:8]}  data={vdata}")
    return entri


def muat_versi(versi):
    """Muat kembali bobot versi tertentu dari artifact di disk."""
    index_path = f"{STORE}/index.json"
    with open(index_path, encoding="utf-8") as f:
        index = json.load(f)
    entri = next(v for v in index["versi"] if v["versi"] == versi)
    with np.load(f"{STORE}/model_{versi}.npz") as paket:
        w = paket["bobot"]
    cocok = hash_bobot(w) == entri["hash_bobot"]
    print(f"  muat {versi}: bobot={w.round(3).tolist()}  "
          f"hash cocok dengan index.json: {cocok}")
    return w


def main():
    shutil.rmtree(STORE, ignore_errors=True)
    os.makedirs(STORE)

    X_valid, y_valid = buat_data(80, seed=99)  # data uji tetap utk semua versi

    print("== melatih dan menyimpan 3 versi ==")
    # v1.0.0 : data 60 sampel, 50 epoch (rilis perdana)
    X, y = buat_data(60, seed=7)
    w1 = latih(X, y, epoch=50)
    simpan_versi("1.0.0", w1, rmse(w1, X_valid, y_valid), "ds-001", "9f2ab41",
                 {"n": 60, "epoch": 50})

    # v1.1.0 : patch — epoch dinaikkan, data sama
    w2 = latih(X, y, epoch=150)
    simpan_versi("1.1.0", w2, rmse(w2, X_valid, y_valid), "ds-001", "9f2ab41",
                 {"n": 60, "epoch": 150})

    # v2.0.0 : major — dataset baru 4x lebih besar
    X_besar, y_besar = buat_data(240, seed=21)
    w3 = latih(X_besar, y_besar, epoch=400)
    simpan_versi("2.0.0", w3, rmse(w3, X_valid, y_valid), "ds-002", "b73c90e",
                 {"n": 240, "epoch": 400})

    print("\n== percobaan menimpa versi 1.1.0 (harus ditolak) ==")
    simpan_versi("1.1.0", np.array([4.0, 3.0]), rmse(w2, X_valid, y_valid),
                 "ds-001", "9f2ab41", {"n": 60, "epoch": 150})

    print("\n== riwayat versi (dibaca ulang dari index.json) ==")
    with open(f"{STORE}/index.json", encoding="utf-8") as f:
        index = json.load(f)
    print(f"{'versi':<7} {'rmse_valid':>10} {'versi_data':>10} "
          f"{'versi_kode':>10}  hash_bobot")
    for v in index["versi"]:
        print(f"{v['versi']:<7} {v['rmse_valid']:>10.4f} "
              f"{v['versi_data']:>10} {v['versi_kode']:>10}  "
              f"{v['hash_bobot'][:8]}")

    print("\n== muat versi 1.1.0 dan prediksi dua sampel ==")
    w_v11 = muat_versi("1.1.0")
    for x in (1.0, 3.5):
        print(f"  prediksi x={x:.1f} -> y={w_v11[0] + w_v11[1] * x:.3f}")


if __name__ == "__main__":
    try:
        main()
    finally:
        shutil.rmtree(STORE, ignore_errors=True)

Output yang Dihasilkan:

text
== melatih dan menyimpan 3 versi ==
  versi 1.0.0   tersimpan  rmse=0.9659  hash=f948b1a0  data=ds-001
  versi 1.1.0   tersimpan  rmse=0.8010  hash=07805872  data=ds-001
  versi 2.0.0   tersimpan  rmse=0.7864  hash=6bf8d886  data=ds-002

== percobaan menimpa versi 1.1.0 (harus ditolak) ==
  TOLAK: versi 1.1.0 sudah ada -> artifact tidak ditimpa (immutability)

== riwayat versi (dibaca ulang dari index.json) ==
versi   rmse_valid versi_data versi_kode  hash_bobot
1.0.0       0.9659     ds-001    9f2ab41  f948b1a0
1.1.0       0.8010     ds-001    9f2ab41  07805872
2.0.0       0.7864     ds-002    b73c90e  6bf8d886

== muat versi 1.1.0 dan prediksi dua sampel ==
  muat 1.1.0: bobot=[3.351, 3.245]  hash cocok dengan index.json: True
  prediksi x=1.0 -> y=6.596
  prediksi x=3.5 -> y=14.708

Mari kita telaah kode di atas baris per baris:

  1. Baris 49-75 (simpan_versi): fungsi ini adalah registry file mini. Berkas index.json dibaca (bila sudah ada), versi baru diperiksa terhadap daftar, lalu artifact bobot disimpan dengan np.savez dan entri metadata ditambahkan. Hash sha256 atas bytes bobot ikut dicatat — sidik jari untuk memverifikasi artifact saat dimuat ulang.
  2. Baris 56-59 (penolakan versi ganda): percobaan menyimpan versi yang sudah ada ditolak dengan pesan eksplisit. Empat baris inilah immutability dalam praktik: sistem menolak menimpa, sehingga riwayat tidak bisa rusak karena kelalaian.
  3. Baris 78-89 (muat_versi): pemuatan ulang tidak percaya begitu saja — bobot yang dibaca dari disk di-hash kembali dan dibandingkan dengan hash di index.json. Baris hash cocok ... : True membuktikan artifact tidak berubah sejak disimpan.
  4. Baris 98-114 (tiga versi): ketiga pelatihan hanya berbeda pada jumlah epoch dan dataset — persis kenaikan PATCH dan MAJOR pada tabel skema. RMSE validasi membaik dari 0.9659 ke 0.8010 ke 0.7864, dan setiap angka tertambat pada versi yang bisa dimuat ulang kapan pun.
  5. Baris 120-128 (riwayat): tabel riwayat dibaca ulang dari index.json, bukan dari memori — registry berdiri sendiri sebagai sumber kebenaran.

Perhatikan satu hal yang jarang disadari: RMSE 1.1.0 (0.8010) dan 2.0.0 (0.7864) hampir sama, tetapi keduanya bukan model yang sama. Bobot v1.1.0 [3.351, 3.245] masih jauh dari parameter sejati [4.0, 3.0], sementara v2.0.0 [4.061, 2.999] hampir tepat. Satu angka metrik tidak cukup untuk membandingkan versi — metadata dan bobot ikut menentukan keputusan.

Registry file index.json sudah menjawab “versi apa saja yang ada dan berasal dari mana”. Pertanyaan berikutnya adalah proses: siapa yang boleh memindahkan sebuah versi dari status eksperimen menjadi model produksi, dan lewat pemeriksaan apa? Chapter berikutnya, Model Registry , membahas siklus stage tersebut.