Model yang sudah berjalan di produksi menghadapi satu kenyataan yang tidak bisa dinegosiasikan: dunia terus berubah. Perilaku pengguna bergeser, campuran produk baru, kebijakan harga berubah — dan model yang dibekukan pada saat pelatihan perlahan kehilangan kesesuaiannya dengan data yang mengalir hari ini. Penurunan seperti ini tidak memunculkan galat apa pun: API tetap menjawab 200, batch tetap menghasilkan seribu skor semalam, padahal kualitas keputusannya sudah tidak sama. Chapter ini membahas sistem peringatan dini untuk kondisi tersebut: mengukur drift — pergeseran distribusi — dengan Population Stability Index (PSI) yang kita hitung manual dan uji Kolmogorov–Smirnov dari scipy, lalu menetapkan ambang yang memutuskan kapan tim perlu bertindak.

Empat Hal yang Perlu Dipantau #

Kata drift sering dipakai longgar, padahal ada empat jenis pemantauan yang berbeda sasarannya:

Jenis pemantauanYang bergeserButuh label?Pertanyaan yang dijawab
Data driftDistribusi fitur masukanTidakApakah masukan hari ini masih menyerupai data pelatihan?
Prediction driftDistribusi keluaran modelTidakApakah skor yang dihasilkan model masih berdistribusi seperti biasanya?
Concept driftHubungan fitur dengan labelYa (saat label tersedia)Apakah aturan yang menghubungkan masukan dan hasil sudah berubah?
Kinerja modelMetrik seperti akurasi atau AUCYaBerapa kualitas prediksi model hari ini?

Dua baris pertama bisa dijalankan segera karena hanya membutuhkan data masukan dan keluaran model — keduanya sudah kita miliki dari chapter sebelumnya, entah lewat respons API atau hasil batch. Dua baris terakhir menunggu label, dan label selalu datang terlambat: nasabah baru gagal membayar berbulan-bulan kemudian, umpan balik tim kurasi baru terkumpul minggu depan. Karena itu pemantauan tanpa label adalah garis pertahanan pertama, dan kinerja sesungguhnya adalah konfirmasi yang menyusul.

Population Stability Index (PSI) #

PSI mengukur seberapa jauh distribusi saat ini menjauh dari distribusi referensi — distribusi pada saat pelatihan. Carinya sederhana: bagi rentang nilai ke dalam beberapa bin, hitung proporsi penghuni tiap bin pada kedua periode, lalu ringkas selisihnya menjadi satu angka:

text
PSI = Σ_i  (p_saat_ini,i − p_ref,i) · ln(p_saat_ini,i / p_ref,i)

Simbol Σ_i berarti penjumlahan atas seluruh bin. Properti rumus ini elegan: bin yang proporsinya persis sama menyumbang nol; makin timpang proporsinya, makin besar kontribusinya — dan kontribusi itu tumbuh secara asimetris, sehingga bin yang nyaris kosong di salah satu periode dihukum berat. Satu keputusan desain yang penting: tepi bin diambil dari kuantil distribusi referensi, bukan dari data saat ini, sehingga setiap bin berisi sekitar 10% data referensi dan PSI antar-fitur bisa dibandingkan secara adil. Ambang yang menjadi kaidum umum industri:

  • PSI < 0,1 — distribusi stabil, pemantauan rutin lanjut.
  • PSI 0,1 – 0,25 — pergeseran moderat, selidiki fitur terkait.
  • PSI > 0,25 — pergeseran signifikan, tindakan diperlukan.

Angka 0,1 dan 0,25 bukan hukum alam melainkan kaidum praktik yang lahir dari industri risiko kredit; gunakan sebagai titik awal, lalu kalibrasi terhadap biaya kesalahan pada masalah Anda.

Uji Kolmogorov–Smirnov #

PSI mengukur besarnya pergeseran; uji Kolmogorov–Smirnov (KS) menjawab pertanyaan yang berbeda: apakah dua sampel berasal dari distribusi yang sama. Statistik KS adalah jarak vertikal maksimum antara dua fungsi distribusi kumulatif empiris — nilainya antara 0 (identik) dan 1 (sama sekali tidak tumpang tindih). Fungsi scipy.stats.ks_2samp mengembalikan statistik tersebut beserta p-value untuk uji hipotesis H0: kedua sampel dari distribusi yang sama.

Kombinasi keduanya penting karena kekuatan KS justru menjadi kelemahannya sebagai alat tunggal: dengan ribuan baris data, KS hampir selalu berhasil menolak H0 meski pergeserannya kecil dan tidak berbahaya. Praktik yang sehat membaca KS sebagai detektor keberadaan dan PSI sebagai pengukur besar — seperti hasil durasi_kunjungan_menit pada praktikum di bawah: KS menyatakan “ada pergeseran” (p = 0,000) sementara PSI menilai “moderat” (0,135).

Praktikum: PSI Manual dan KS untuk Tiga Fitur #

Skrip berikut membangun dua periode data sintetis — referensi saat pelatihan dan saat ini di produksi — dengan tiga pola pergeseran yang berbeda: rata-rata bergeser, hampir tak berubah, dan sebaran yang melebar. PSI dihitung dari nol dengan pembinning kuantil, KS diambil dari scipy, lalu ditutup dengan drift pada distribusi prediksi model itu sendiri.

python
# Monitoring drift: PSI dihitung manual dengan pembinning kuantil referensi,
# ditambah uji Kolmogorov-Smirnov dua sampel dari scipy.stats. Ditutup dengan
# drift pada distribusi prediksi itu sendiri (tanpa label).

import numpy as np
import pandas as pd
from scipy.stats import ks_2samp
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

EPSILON = 1e-6
JUMLAH_BIN = 10


def proporsi_per_bin(nilai, tepi_interior):
    """Proporsi penghuni tiap bin; bin 0 menampung nilai di bawah tepi pertama."""
    indeks = np.searchsorted(tepi_interior, nilai)
    hitungan = np.bincount(indeks, minlength=len(tepi_interior) + 1)
    return hitungan / hitungan.sum()


def hitung_psi(referensi, saat_ini, jumlah_bin=JUMLAH_BIN):
    """Population Stability Index: tepi bin diambil dari kuantil referensi."""
    kuantil = np.quantile(referensi, np.linspace(0, 1, jumlah_bin + 1))
    tepi_interior = np.unique(kuantil)[1:-1]
    p_ref = np.maximum(proporsi_per_bin(referensi, tepi_interior), EPSILON)
    p_cur = np.maximum(proporsi_per_bin(saat_ini, tepi_interior), EPSILON)
    return float(np.sum((p_cur - p_ref) * np.log(p_cur / p_ref)))


def vonis_psi(nilai_psi):
    if nilai_psi < 0.1:
        return "stabil"
    if nilai_psi < 0.25:
        return "moderat"
    return "signifikan"


# 1) Dua periode data: referensi (saat pelatihan) vs saat ini (produksi).
rng = np.random.default_rng(42)
n = 2000
FITUR = ["usia", "pendapatan_juta", "durasi_kunjungan_menit"]

ref = pd.DataFrame({
    "usia": rng.normal(35.0, 8.0, n),
    "pendapatan_juta": rng.normal(12.0, 4.0, n),
    "durasi_kunjungan_menit": rng.normal(20.0, 6.0, n),
})
cur = pd.DataFrame({
    "usia": rng.normal(42.0, 8.0, n),          # rata-rata bergeser +7 tahun
    "pendapatan_juta": rng.normal(12.2, 4.0, n),  # nyaris tidak berubah
    "durasi_kunjungan_menit": rng.normal(20.0, 8.2, n),  # sebaran melebar
})

# 2) Bedah bin untuk fitur 'usia': inti mekanisme PSI.
kuantil_usia = np.quantile(ref["usia"], np.linspace(0, 1, JUMLAH_BIN + 1))
tepi_usia = np.unique(kuantil_usia)[1:-1]
p_ref_usia = proporsi_per_bin(ref["usia"], tepi_usia)
p_cur_usia = proporsi_per_bin(cur["usia"], tepi_usia)
print("tabel bin fitur 'usia' (tepi dari kuantil referensi):")
for i in range(JUMLAH_BIN):
    kiri = "-inf" if i == 0 else f"{tepi_usia[i - 1]:6.1f}"
    kanan = "+inf" if i == JUMLAH_BIN - 1 else f"{tepi_usia[i]:6.1f}"
    print(f"  bin {i + 1:>2}: [{kiri:>6} .. {kanan:>6})  "
          f"p_ref={p_ref_usia[i]:.3f}  p_cur={p_cur_usia[i]:.3f}")
print()

# 3) Ringkasan drift per fitur: PSI + uji KS dua sampel.
print(f"{'fitur':<22} {'PSI':>6}  {'vonis':<11} {'KS':>6}  {'p':>6}  kesimpulan")
for fitur in FITUR:
    nilai_psi = hitung_psi(ref[fitur], cur[fitur])
    hasil_ks = ks_2samp(ref[fitur], cur[fitur])
    kesimpulan = "tolak H0" if hasil_ks.pvalue < 0.05 else "gagal tolak H0"
    print(f"{fitur:<22} {nilai_psi:>6.3f}  {vonis_psi(nilai_psi):<11} "
          f"{hasil_ks.statistic:>6.3f}  {hasil_ks.pvalue:>6.3f}  {kesimpulan}")
print()

# 4) Drift pada distribusi prediksi: dipantau TANPA label.
logit = (0.06 * (ref["usia"] - 35.0)
         + 0.15 * (ref["pendapatan_juta"] - 12.0)
         + 0.08 * (ref["durasi_kunjungan_menit"] - 20.0)
         + rng.normal(0.0, 1.0, n))
label = (logit > 0.0).astype(int)

model = Pipeline([
    ("skala", StandardScaler()),
    ("regresi", LogisticRegression(random_state=42)),
])
model.fit(ref[FITUR], label)

peluang_ref = model.predict_proba(ref[FITUR])[:, 1]
peluang_cur = model.predict_proba(cur[FITUR])[:, 1]
psi_prediksi = hitung_psi(peluang_ref, peluang_cur)
ks_prediksi = ks_2samp(peluang_ref, peluang_cur)
print("drift pada distribusi prediksi (peluang kelas positif):")
print(f"  rata-rata peluang  : referensi={peluang_ref.mean():.3f} "
      f"saat_ini={peluang_cur.mean():.3f}")
print(f"  PSI prediksi        = {psi_prediksi:.3f} ({vonis_psi(psi_prediksi)})")
print(f"  KS prediksi         = {ks_prediksi.statistic:.3f} "
      f"(p={ks_prediksi.pvalue:.3f})")

Output yang Dihasilkan:

text
tabel bin fitur 'usia' (tepi dari kuantil referensi):
  bin  1: [  -inf ..   24.0)  p_ref=0.100  p_cur=0.015
  bin  2: [  24.0 ..   27.8)  p_ref=0.100  p_cur=0.022
  bin  3: [  27.8 ..   30.5)  p_ref=0.100  p_cur=0.035
  bin  4: [  30.5 ..   32.8)  p_ref=0.100  p_cur=0.053
  bin  5: [  32.8 ..   34.8)  p_ref=0.100  p_cur=0.063
  bin  6: [  34.8 ..   36.8)  p_ref=0.100  p_cur=0.064
  bin  7: [  36.8 ..   38.6)  p_ref=0.100  p_cur=0.084
  bin  8: [  38.6 ..   41.1)  p_ref=0.100  p_cur=0.142
  bin  9: [  41.1 ..   44.8)  p_ref=0.100  p_cur=0.173
  bin 10: [  44.8 ..   +inf)  p_ref=0.100  p_cur=0.349

fitur                     PSI  vonis           KS       p  kesimpulan
usia                    0.784  signifikan   0.370   0.000  tolak H0
pendapatan_juta         0.005  stabil       0.023   0.666  gagal tolak H0
durasi_kunjungan_menit  0.135  moderat      0.099   0.000  tolak H0

drift pada distribusi prediksi (peluang kelas positif):
  rata-rata peluang  : referensi=0.490 saat_ini=0.610
  PSI prediksi        = 0.199 (moderat)
  KS prediksi         = 0.192 (p=0.000)

Mari kita telaah bagian-bagian yang memikul beban utama:

  1. Baris 16–20 (def proporsi_per_bin): np.searchsorted(tepi_interior, nilai) mengembalikan posisi bin setiap nilai — 0 untuk nilai di bawah tepi pertama hingga len(tepi_interior) untuk nilai di atas tepi terakhir, sehingga pencilan tetap tertampung di bin tepi. np.bincount menghitung penghuni per bin dan pembagian mengubahnya menjadi proporsi.
  2. Baris 23–29 (def hitung_psi): np.quantile(referensi, np.linspace(0, 1, 11)) menghasilkan sebelas titik kuantil; np.unique(...)[1:-1] membuang titik 0% dan 100% lalu menjaga tepi tetap unik, meninggalkan sembilan tepi interior untuk sepuluh bin. np.maximum(..., EPSILON) mencegah pembagian nol pada bin yang kosong — trik baku agar logaritma tetap terdefinisi. Baris terakhir adalah rumus PSI itu sendiri.
  3. Baris 32–37 (def vonis_psi): ambang 0,1 dan 0,25 diterjemahkan menjadi label tindakan; fungsi kecil seperti ini layak menjadi satu-satunya tempat ambang didefinisikan agar perubahannya terlacak.
  4. Baris 40–54 (dua periode data): ref dan cur memakai rng yang sama berseed 42 sehingga seluruh hasil dapat direproduksi. Tiga fitur sengaja diberi tiga pola: usia bergeser rata-ratanya, pendapatan_juta nyaris identik, durasi_kunjungan_menit hanya melebar sebarannya.
  5. Baris 56–67 (tabel bin usia): pembedahan mekanisme PSI. Proporsi referensi rata 0,100 di tiap bin — bukan kebetulan, melainkan akibat pembinning kuantil. Proporsi saat ini menumpuk di bin 8–10 (0,142, 0,173, 0,349) dan menipis di bin 1–3: wajah pergeseran rata-rata ke kanan terlihat langsung di sini.
  6. Baris 69–77 (ringkasan per fitur): satu loop menghitung PSI dan KS untuk ketiga fitur sekaligus. Perhatikan kontras yang dibahas di atas: durasi_kunjungan_menit dinyatakan bergeser oleh KS (p = 0,000) tetapi PSI menilai moderat (0,135) — keduanya benar pada pertanyaannya masing-masing.
  7. Baris 79–101 (drift prediksi): model dilatih pada data referensi, lalu peluang prediksinya dibandingkan antara dua periode — tanpa satu pun label produksi. Rata-rata peluang naik dari 0,490 ke 0,610 dan PSI distribusi prediksi mencapai 0,199 (moderat).

Tabel bin usia pantas dibaca pelan karena di sanalah seluruh intuisi PSI terbentuk: PSI tinggi (0,784) pada usia berasal murni dari ketimpangan proporsi antar bin yang tercetak itu — bin teratas menampung 0,349 data saat ini padahal dirancang untuk 0,100.

Mengapa Memantau Prediksi Penting Tanpa Label #

Bagian keempat skrip layak ditegaskan kembali. Label produksi hampir selalu datang terlambat, dan pada masalah seperti risiko kredit, keterlambatannya dihitung dalam bulan. Selama masa tunggu itu, distribusi prediksi adalah satu-satunya jendela ke kondisi model: model yang mendapat masukan jauh berbeda dari data pelatihannya akan menghasilkan sebaran skor yang jauh berbeda juga — persis yang terjadi pada keluaran kita (0,490 menjadi 0,610). Pergeseran seperti ini tidak membuktikan model sudah salah; model bisa saja benar bahwa profil pengguna memang berubah. Tetapi pergeseran tersebut selalu membenarkan penyelidikan: apakah sumber datanya sah, apakah terjadi galat pipeline, atau apakah dunia memang berubah dan model perlu dipelajari ulang.

Ambang Peringatan dan Aksi #

Sistem monitoring baru berguna ketika tiap ambang memiliki pemilik dan tindakan — tabel keputusan berikut adalah bentuk paling ringkasnya:

Kondisi PSITindakan baku
< 0,1 (stabil)Pemantauan rutin berlanjut; tidak ada intervensi
0,1 – 0,25 (moderat)Periksa fitur terkait dan sumber datanya; perketat frekuensi pemantauan
> 0,25 (signifikan)Selidiki akar penyebab, siapkan data baru, dan jalankan evaluasi ulang model

Dua tindakan terakhir mengarah ke satu pertanyaan penutup: setelah drift terbukti dan datanya terkumpul, bagaimana model diperbarui secara terkendali — kapan retraining dipicu, bagaimana model baru diadu dengan model lama, dan bagaimana kita mundur bila model baru justru menurun? Seluruh siklus tersebut adalah materi Chapter Pembaruan Model dan Retraining.

Referensi Resmi #