Pada proyek perangkat lunak biasa, continuous integration (CI) sudah lama menjadi standar: setiap perubahan kode melewati rangkaian test otomatis sebelum digabungkan. Proyek machine learning selama ini sering luput dari disiplin itu — “kan tinggal latih ulang” menjadi alasan untuk tidak menulis test. Padahal proyek ML punya dua permukaan yang sama-sama bisa rusak diam-diam: kode dan data. Chapter ini menerapkan CI pada keduanya dengan test suite mini yang benar-benar dijalankan, lalu menunjukkan bentuknya di GitHub Actions.

CI untuk Machine Learning #

Perbedaan utama CI untuk ML bukan pada alatnya, melainkan pada cakupan hal yang diuji. Kode model yang lolos test bisa tetap menghasilkan model yang buruk karena data yang berubah; sebaliknya data yang sehat tidak menolong bila kode preprocessing berubah perilaku. Tiga kelompok test perlu berjalan bersama:

KelompokYang diujiContoh
test unit kodefungsi dan modulfungsi pembagi dataset mengembalikan bentuk yang benar
test kualitas dataskema, rentang, kehilangan nilailabel hanya boleh {0, 1}; fitur tidak boleh NaN
test kualitas modelmetrik di atas ambangakurasi validasi >= 0.90; training deterministik dengan seed

Test kualitas model adalah tambahan khas ML: kita menegaskan properti hasil training, bukan hanya properti kode. Ambang harus dipilih hati-hati — terlalu rendah membuat test selalu lolos, terlalu tinggi membuatnya selalu gagal seperti yang akan kita lihat pada demo.

Contoh Teruji: Mini Test Suite #

Berkas test_ci.py memakai konvensi pytest — fungsi bernama test_* dengan assert — dijalankan oleh runner kecil yang mencatat kegagalan tanpa menghentikan suite. Lima test mencakup tiga kelompok di atas, lalu sebuah gerbang ketat menunjukkan ambang yang berlebihan menahan perubahan:

python
"""Mini test suite pola pytest untuk proyek machine learning.

Fungsi test_* dijalankan oleh runner kecil: setiap kegagalan assert
dicatat tanpa menghentikan suite. Dua kelompok diuji — kualitas data
dan kualitas model — lalu gerbang kualitas ketat menunjukkan bagaimana
ambang yang lebih tinggi menolak perubahan kode.
"""

import numpy as np


def buat_dataset(seed, n_per_kelas):
    rng = np.random.default_rng(seed)
    kelas0 = rng.normal(loc=[-1.0, 0.0], scale=0.7, size=(n_per_kelas, 2))
    kelas1 = rng.normal(loc=[1.0, 0.4], scale=0.7, size=(n_per_kelas, 2))
    X = np.vstack([kelas0, kelas1])
    y = np.array([0] * n_per_kelas + [1] * n_per_kelas)
    urut = rng.permutation(len(y))
    return X[urut], y[urut]


def latih(X, y, epoch=300, lr=1.0):
    n = len(y)
    w = np.zeros(3)
    for _ in range(epoch):
        skor = np.clip(X @ w[1:] + w[0], -30, 30)
        p = 1.0 / (1.0 + np.exp(-skor))
        galat = p - y
        w[1:] -= lr * (X.T @ galat) / n
        w[0] -= lr * galat.mean()
    return w


def akurasi(w, X, y):
    return float((((X @ w[1:] + w[0] >= 0).astype(int)) == y).mean())


# ---------- data uji disiapkan sekali, deterministik ----------
X, Y = buat_dataset(seed=42, n_per_kelas=120)
X_VALID, Y_VALID = buat_dataset(seed=99, n_per_kelas=100)


# ---------- kelompok 1: test kualitas data ----------

def test_bentuk_data():
    assert X.shape == (240, 2), f"bentuk X salah: {X.shape}"
    assert Y.shape == (240,), f"bentuk y salah: {Y.shape}"


def test_rentang_label():
    unik = set(np.unique(Y).tolist())
    assert unik == {0, 1}, f"label di luar {0, 1}: {unik}"


def test_tidak_ada_nilai_hilang():
    assert not np.isnan(X).any(), "fitur memuat NaN"
    assert not np.isinf(X).any(), "fitur memuat inf"


# ---------- kelompok 2: test kualitas model ----------

def test_akurasi_model():
    w = latih(X, Y, epoch=300)
    ak = akurasi(w, X_VALID, Y_VALID)
    assert ak >= 0.90, f"akurasi validasi {ak:.3f} < ambang 0.90"


def test_training_deterministik():
    w1 = latih(X, Y, epoch=300)
    w2 = latih(X, Y, epoch=300)
    assert np.array_equal(w1, w2), "dua training identik hasilnya beda"


def jalankan_suite():
    daftar_test = [(nama, fn) for nama, fn in sorted(globals().items())
                   if nama.startswith("test_") and callable(fn)]
    print("== mini test suite (pola pytest) ==")
    gagal = 0
    for nama, fn in daftar_test:
        try:
            fn()
            print(f"PASS  {nama}")
        except AssertionError as galat:
            gagal += 1
            print(f"FAIL  {nama}: {galat}")
    print(f"\nringkasan: {len(daftar_test) - gagal}/{len(daftar_test)} "
          f"lulus" + (" — CI hijau, merge diizinkan" if gagal == 0
                      else " — CI merah, merge DITOLAK"))
    return gagal


def main():
    gagal = jalankan_suite()

    print("\n== gerbang kualitas ketat (ambang akurasi 0.99) ==")
    w = latih(X, Y, epoch=300)
    ak = akurasi(w, X_VALID, Y_VALID)
    status = "LOLOS" if ak >= 0.99 else "GAGAL"
    print(f"akurasi validasi {ak:.3f} vs ambang 0.99 -> {status}")
    if ak < 0.99:
        print("pull request ditahan: gerbang kualitas model belum terpenuhi")
    return gagal


if __name__ == "__main__":
    main()

Output yang Dihasilkan:

text
== mini test suite (pola pytest) ==
PASS  test_akurasi_model
PASS  test_bentuk_data
PASS  test_rentang_label
PASS  test_tidak_ada_nilai_hilang
PASS  test_training_deterministik

ringkasan: 5/5 lulus — CI hijau, merge diizinkan

== gerbang kualitas ketat (ambang akurasi 0.99) ==
akurasi validasi 0.925 vs ambang 0.99 -> GAGAL
pull request ditahan: gerbang kualitas model belum terpenuhi

Mari kita telaah kode di atas baris per baris:

  1. Baris 39-40 (data di tingkat modul): data latih dan validasi dibuat sekali dengan seed tetap sebelum test berjalan — padanan fixture pytest. Semua test menghadapi data yang sama sehingga kegagalan bisa dibandingkan antar test.
  2. Baris 45-57 (test kualitas data): tiga test memeriksa bentuk array, rentang label, dan nilai NaN/inf. Pesan kegagalan disertakan pada setiap assert — pada CI, pesan inilah yang pertama dibaca orang.
  3. Baris 62-65 (test_akurasi_model): test ini melatih model sungguhan lalu menegaskan akurasi validasi minimal 0.90. Inilah gerbang kualitas model: perubahan kode yang menurunkan akurasi di bawah ambang akan menggagalkan CI, bukan hanya merusak di produksi nanti.
  4. Baris 68-71 (test_training_deterministik): dua pelatihan dengan seed sama harus menghasilkan bobot identik — proteksi regression untuk perilaku yang kita andalkan di chapter Reproducible Experiments .
  5. Baris 74-89 (jalankan_suite): runner mengumpulkan seluruh fungsi test_* lewat globals(), menjalankan satu per satu, menangkap AssertionError agar suite tetap lanjut, lalu mencetak ringkasan. Ringkasan 5/5 lulus — CI hijau, merge diizinkan adalah sinyal yang nantinya dibaca oleh layanan CI, bukan oleh mata kita.
  6. Baris 95-101 (gerbang ketat): ambang yang sama dinaikkan ke 0.99. Akurasi 0.925 gagal, dan pesan menahan pull request. Ambang adalah kebijakan, bukan konstanta universal — angkanya harus disesuaikan dengan kemampuan nyata model.

Workflow GitHub Actions #

Di repositori nyata, runner kita digantikan layanan CI seperti GitHub Actions . Workflow berikut menjalankan test setiap ada push — cukup disimpan sebagai .github/workflows/ci.yml:

yaml
name: ci-model
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: pytest tests/ -q

Enam baris tersebut memuat seluruh siklus: checkout kode, siapkan Python, pasang dependensi, jalankan pytest. Bila ada satu test gagal, status commit menjadi merah dan proteksi cabang mencegah penggabungan. Linter dan pemeriksa format (ruff, black) biasanya ditambahkan sebagai langkah pertama sebelum test — kode yang gagal format tidak perlu membuang waktu menunggu training test.

CI memastikan setiap perubahan aman untuk digabungkan. Pertanyaan berikutnya adalah bagaimana perubahan yang sudah aman itu sampai ke pengguna — seberapa cepat, dan dengan eksposur seberapa kecil. Chapter berikutnya, Continuous Deployment , membahas strategi rilisnya.