Chapter Manajemen Eksperimen ditutup dengan satu gerbang yang masih berdiri antara kode yang berhasil di notebook dan kode yang layak naik ke produksi: kepercayaan bahwa komponen-komponennya berperilaku benar. Skema data tidak diam-diam berubah, preprocessing tidak meloloskan nilai kosong, probabilitas tetap berada di rentang wajar, artefak yang dimuat ulang memberi prediksi yang sama. Kode machine learning punya kekhasan yang membuat pengujian otomatis justru lebih penting daripada perangkat lunak biasa: kegagalannya sering senyap — tidak ada crash, tidak ada traceback, hanya angka prediksi yang pelan-pelan salah. Chapter ini membangun dua lapis pertahanan dengan pytest: tes kontrak data yang menjaga pintu masuk, dan tes perilaku model yang menjaga pintu keluar.

Mengapa Kode Machine Learning Perlu Tes #

Tiga kelas kegagalan khas kode machine learning tidak tertangkap oleh “coba jalankan saja”. Pertama, skema berubah senyap: tim data hulu menambah kolom, mengganti tipe usia dari bilangan bulat menjadi teks, atau menduplikasi baris — pipeline tetap jalan, model tetap memprediksi, hasilnya pelan-pelan tidak bisa dipercaya. Kedua, asumsi preprocessing dilanggar diam-diam: imputer yang diasumsikan menangani nilai kosong tertukar posisi di rantai, encoder bertemu kategori baru, dan matriks fitur berlubang tanpa satu pun error. Ketiga, regresi perilaku: peningkatan “kecil” di kode preprocessing menurunkan metrik 2% — terasa tidak ada di code review, langsung terasa di produksi.

Kelas kegagalanTanpa tesDengan tes
Skema berubahDitemukan berminggu-minggu kemudianGagal di gerbang, sebelum pelatihan
Nilai kosong lolos preprocessingPrediksi NaN sampai penggunatest_preprocessing_bebas_nilai_kosong
Regresi metrik/prediksiTerdeteksi saat monitoring produksiTes perilaku gagal di CI

Kuncinya bukan menguji “apakah model pintar” — itu urusan validasi statistik yang sudah kita kuasai di Section Evaluasi dan Validasi Model. Yang diuji di sini adalah kontrak rekayasa: sifat-sifat yang harus selalu benar apa pun datanya.

Kontrak Data di Bawah pytest #

Kontrak data adalah daftar sifat wajib tabel masukan: kolom yang harus ada, keunikan identitas, rentang usia yang masuk akal, nilai target yang biner, dan kolom kritis tanpa nilai kosong. Contoh pertama memusatkan seluruh aturan itu dalam satu fungsi periksa_kontrak(), lalu fungsi tes pytest tinggal memanggilnya — aturan satu, pintu pemeriksaan bisa banyak.

python
# Kontrak data dijaga pytest: skema tabel (kolom, tipe, keunikan ID,
# rentang usia, nilai target) diperiksa sebelum data dipakai melatih.
# Fungsi periksa_kontrak() memusatkan aturan; fungsi tes pytest tinggal
# memanggilnya. Eksekusi mandiri menangkap keluaran pytest lalu mencetak
# ringkasan deterministik (durasi asli pytest sengaja dibuang).

import sys

import pandas as pd
import pytest

KOLOM_WAJIB = ["nasabah_id", "usia", "pendapatan_juta", "kota", "churn"]


def bangun_data_bersih():
    """Data nasabah yang memenuhi kontrak penuh."""
    return pd.DataFrame(
        {
            "nasabah_id": [f"N{i:03d}" for i in range(1, 13)],
            "usia": [21, 34, 45, 29, 52, 38, 41, 33, 27, 48, 36, 30],
            "pendapatan_juta": [5.2, 8.1, 12.0, 6.5, 15.3, 9.4,
                                10.1, 7.8, 5.9, 13.2, 9.9, 6.8],
            "kota": ["Jakarta", "Bandung", "Surabaya", "Jakarta",
                     "Medan", "Bandung", "Surabaya", "Medan",
                     "Jakarta", "Bandung", "Surabaya", "Medan"],
            "churn": [0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0],
        }
    )


def periksa_kontrak(df):
    """Naikkan AssertionError bila tabel melanggar kontrak data."""
    hilang = [k for k in KOLOM_WAJIB if k not in df.columns]
    assert not hilang, f"kolom wajib hilang: {hilang}"

    assert df["nasabah_id"].is_unique, "nasabah_id harus unik"
    assert df["usia"].between(17, 90).all(), "usia di luar rentang 17-90"
    assert set(df["churn"].unique()) <= {0, 1}, "churn harus 0/1"
    assert df["nasabah_id"].notna().all(), "nasabah_id tidak boleh kosong"
    assert df["usia"].notna().all(), "usia tidak boleh kosong"
    assert df["kota"].notna().all(), "kota tidak boleh kosong"


# ---------- Fungsi tes pytest ----------
def test_kolom_wajib_ada():
    periksa_kontrak(bangun_data_bersih())


def test_id_unik_dan_tidak_kosong():
    periksa_kontrak(bangun_data_bersih())


def test_rentang_usia_wajar():
    periksa_kontrak(bangun_data_bersih())


def test_target_biner():
    periksa_kontrak(bangun_data_bersih())


def test_kolom_kritis_tanpa_nilai_kosong():
    periksa_kontrak(bangun_data_bersih())


# ---------- Eksekusi mandiri: ringkasan deterministik ----------
if __name__ == "__main__":
    import io
    from contextlib import redirect_stdout

    sys.dont_write_bytecode = True
    penyangga = io.StringIO()
    with redirect_stdout(penyangga):
        kode_keluar = int(pytest.main(
            ["-q", "-p", "no:cacheprovider", __file__]
        ))

    nama_tes = sorted(
        n for n, v in list(globals().items())
        if n.startswith("test_") and callable(v)
    )
    print("== pytest menjalankan kontrak data ==")
    print(f"fungsi tes ditemukan : {len(nama_tes)}")
    for n in nama_tes:
        print(f"  - {n}")
    print(f"exit code            : {kode_keluar}")
    print(f"status               : "
          f"{'SEMUA LULUS' if kode_keluar == 0 else 'ADA GAGAL'}")

    # ---------- Deteksi pelanggaran kontrak ----------
    rusak = bangun_data_bersih().copy()
    rusak.loc[3, "usia"] = 250          # usia mustahil
    rusak.loc[5, "nasabah_id"] = "N001"  # ID ganda
    print("\n== Pelanggaran kontrak terdeteksi ==")
    try:
        periksa_kontrak(rusak)
        print("tidak terdeteksi (seharusnya tidak terjadi)")
    except AssertionError as galat:
        print(f"AssertionError: {galat}")

Output yang Dihasilkan:

text
== pytest menjalankan kontrak data ==
fungsi tes ditemukan : 5
  - test_id_unik_dan_tidak_kosong
  - test_kolom_kritis_tanpa_nilai_kosong
  - test_kolom_wajib_ada
  - test_rentang_usia_wajar
  - test_target_biner
exit code            : 0
status               : SEMUA LULUS

== Pelanggaran kontrak terdeteksi ==
AssertionError: nasabah_id harus unik

Mari kita bedah arsitektur pengujianannya:

  1. Baris 31–41 (fungsi kontrak): seluruh aturan hidup di satu fungsi, masing-masing assert membawa pesan galat yang menjelaskan aturan yang dilanggar — pesan inilah yang dibaca manusia saat tes gagal di pipeline CI pada jam tiga pagi. Urutan pemeriksaan disusun dari struktur (kolom) ke isi (nilai), sehingga pelanggaran struktural terlaporkan lebih dulu.
  2. Baris 45–62 (fungsi tes): lima fungsi bernama test_* — pytest mengenali dan menjalankannya otomatis. Setiap fungsi satu konsep yang diuji; nama fungsinya adalah dokumentasi yang selalu sinkron dengan kode.
  3. Baris 66–87 (eksekusi mandiri): pytest.main() menjalankan berkas ini dalam proses yang sama; keluaran aslinya (termasuk durasi yang berubah-ubah tiap eksekusi) ditangkap ke penyangga, lalu kita mencetak ringkasan sendiri — jumlah fungsi tes ditemukan lewat introspeksi globals(), status dari exit code pytest. Hasil cetak selalu identik antar eksekusi, syarat yang kita pegang di seluruh course ini.
  4. Baris 90–98 (deteksi pelanggaran): tabel sengaja dirusak dua cara — usia 250 dan ID ganda. periksa_kontrak() langsung menaikkan AssertionError: nasabah_id harus unik. Inilah bukti kontrak bekerja: pelanggaran tidak lolos senyap, ia berhenti di pintu dengan pesan yang menjelaskan dirinya sendiri.

Perilaku Model di Bawah pytest #

Kontrak data menjaga pintu masuk; tes perilaku menjaga pintu keluar. Objek yang diuji pipeline imputasi → skala → LogisticRegression yang sudah terlatih, dan sifat-sifat yang dijaga adalah invariant: keluaran preprocessing bebas nilai kosong, probabilitas di rentang [0, 1] dan berjumlah satu per baris, prediksi identik antar panggilan, artefak joblib termuat ulang memberi prediksi identik, dan masukan dimensi salah ditolak keras.

python
# Perilaku model dijaga pytest: hasil preprocessing bebas nilai kosong,
# probabilitas terjaga di [0, 1] dan berjumlah satu, prediksi deterministik
# lintas panggilan, artefak joblib dimuat ulang memberi hasil identik, dan
# dimensi masukan salah ditolak dengan ValueError. Ringkasan akhir dicetak
# deterministik (durasi asli pytest dibuang).

import shutil
import sys
import tempfile

import numpy as np
import pytest
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

# ---------- Objek yang diuji: pipeline churn ----------
rng = np.random.default_rng(42)
n = 250
X = rng.normal(0, 1, size=(n, 6))
logit = 1.2 * X[:, 0] - 0.8 * X[:, 1] + rng.normal(0, 0.9, n)
y = (logit > 0).astype(int)
X[rng.choice(n, size=10, replace=False), 4] = np.nan

pipeline = Pipeline(
    [
        ("imputasi", SimpleImputer(strategy="median")),
        ("skala", StandardScaler()),
        ("model", LogisticRegression(C=1.0, max_iter=1000)),
    ]
)
pipeline.fit(X, y)

X_uji = rng.normal(0, 1, size=(40, 6))
X_uji[:5, 2] = np.nan  # nilai kosong harus tetap terimputasi


# ---------- Fungsi tes pytest ----------
def test_preprocessing_bebas_nilai_kosong():
    pra = pipeline[:-1].transform(X_uji)
    assert not np.isnan(pra).any(), "hasil preprocessing masih berisi NaN"


def test_probabilitas_dalam_rentang_dan_berjumlah_satu():
    proba = pipeline.predict_proba(X_uji)
    assert (proba >= 0).all() and (proba <= 1).all(), "proba di luar [0,1]"
    assert np.allclose(proba.sum(axis=1), 1.0), "baris proba tidak berjumlah 1"


def test_prediksi_deterministik_lintas_panggilan():
    pertama = pipeline.predict(X_uji)
    kedua = pipeline.predict(X_uji)
    assert np.array_equal(pertama, kedua), "prediksi berubah antar panggilan"


def test_artefak_dimuat_ulang_identik():
    folder = tempfile.mkdtemp(prefix="uji-artefak-")
    try:
        import joblib

        berkas = f"{folder}/model.joblib"
        joblib.dump(pipeline, berkas)
        muat = joblib.load(berkas)
        assert np.array_equal(
            pipeline.predict(X_uji), muat.predict(X_uji)
        ), "artefak dimuat memberi prediksi berbeda"
    finally:
        shutil.rmtree(folder, ignore_errors=True)


def test_dimensi_masukan_salah_ditolak():
    with pytest.raises(ValueError):
        pipeline.predict(X_uji[:, :4])


# ---------- Eksekusi mandiri: ringkasan deterministik ----------
if __name__ == "__main__":
    import io
    from contextlib import redirect_stdout

    sys.dont_write_bytecode = True
    penyangga = io.StringIO()
    with redirect_stdout(penyangga):
        kode_keluar = int(pytest.main(
            ["-q", "-p", "no:cacheprovider", __file__]
        ))

    nama_tes = sorted(
        n for n, v in list(globals().items())
        if n.startswith("test_") and callable(v)
    )
    print("== pytest menjalankan perilaku model ==")
    print(f"fungsi tes ditemukan : {len(nama_tes)}")
    for n in nama_tes:
        print(f"  - {n}")
    print(f"exit code            : {kode_keluar}")
    print(f"status               : "
          f"{'SEMUA LULUS' if kode_keluar == 0 else 'ADA GAGAL'}")

Output yang Dihasilkan:

text
== pytest menjalankan perilaku model ==
fungsi tes ditemukan : 5
  - test_artefak_dimuat_ulang_identik
  - test_dimensi_masukan_salah_ditolak
  - test_prediksi_deterministik_lintas_panggilan
  - test_preprocessing_bebas_nilai_kosong
  - test_probabilitas_dalam_rentang_dan_berjumlah_satu
exit code            : 0
status               : SEMUA LULUS

Lima tes tersebut menjaga lima invariant berbeda:

  1. Baris 40–42 (invariant preprocessing): pipeline[:-1] memotong langkah terakhir — trik dari Chapter Pipeline scikit-learn: Dasar — sehingga yang diperiksa murni keluaran preprocessing. X_uji sengaja berisi 5 nilai kosong; imputer wajib menutupnya semua.
  2. Baris 45–48 (invariant probabilitas): dua sifat sekaligus — setiap nilai di [0, 1] dan setiap baris berjumlah 1. Pelanggaran keduanya hampir selalu menandakan rantai pipeline tersusun salah, bukan modelnya buruk.
  3. Baris 51–54 (determinisme): dua panggilan predict() harus identik. Tes ini murah tetapi menangkap kelas galat yang tidak terduga — state yang bocor antar panggilan, random_state yang lupa dipasang.
  4. Baris 57–69 (round-trip artefak): materi Chapter Penyimpanan dan Pemuatan Model diubah menjadi tes — tempfile.mkdtemp() menyediakan folder sementara di luar folder kerja, blok finally memastikan pembersihan terjadi walau tes gagal, dan prediksi objek asli vs objek termuat dibandingkan np.array_equal.
  5. Baris 72–74 (penolakan eksplisit): pytest.raises(ValueError) menegaskan bahwa masukan 4 kolom pada model ber-6 fitur harus ditolak keras. Kegagalan menolak yang layak ditolak adalah bug yang sama berbahayanya dengan kegagalan menerima yang seharusnya diterima.

Apa yang Tidak Kita Uji #

Batas disiplin sama pentingnya dengan cakupannya. Kita tidak menguji akurasi model dengan ambang keras (misalnya “akurasi harus di atas 0,85”) — akurasi bergantung pada data dan distribusi yang bergeser; tes seperti itu menjadi alarm palsu yang diabaikan orang. Kita juga tidak menguji isi koefisien atau nilai numerik hasil pelatihan secara persis, karena nilai itu wajar berubah antar rilis pustaka. Yang diuji adalah bentuk dan perilaku: dimensi, rentang, keunikan, determinisme, round-trip. Untuk pergeseran kualitas yang lambat, alat yang benar adalah monitoring drift — materi yang sudah kita bangun di Chapter Monitoring dan Deteksi Drift.

Kontrak data yang terjaga di pintu masuk, perilaku model yang terjaga di pintu keluar, artefak yang terverifikasi utuh, eksperimen yang tercatat penuh — seluruh Section Pipeline dan Produktivitas kini membentuk satu rantai kerja yang bisa dipertanggungjawabkan dari data mentah sampai model siap saji. Rantai itu tinggal dipindahkan ke medan sesungguhnya: model harus hidup di luar notebook — dilayankan, dimonitor, dan diperbarui. Perjalanan tahap akhir ini dibuka oleh Section Deployment dan Operasional, dimulai dari Chapter Strategi Deployment Model.

Referensi Resmi #