Bab 58 — Explainable AI, MLOps, dan AI Bertanggung Jawab

Seri A3 — Machine Learning untuk Sumber Daya Alam

CatatanDi bab ini, Anda akan:
  • Menjelaskan model dengan permutation importance, SHAP, LIME, dan partial dependence
  • Mencatat percobaan dengan MLflow dan memahami versi data dengan DVC
  • Memantau drift (data berubah) dengan uji KS dan PSI
  • Menyatakan ketidakpastian prediksi dengan selang yang cakupannya teruji
  • Memeriksa bias, melindungi privasi, dan menulis model card

Semua skrip bab ini ada di folder kode/bab58/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Bayangkan Anda memberi peta tutupan lahan kepada Dinas Kehutanan, lalu seorang kepala bidang bertanya: “Kenapa petak ini Anda sebut kebun, bukan hutan? Dan peta ini masih berlaku tahun depan?” Jawaban “karena modelnya bilang begitu” tidak cukup. Bab ini membekali Anda untuk menjawab: menjelaskan model, mencatat dan memantau model sepanjang hidupnya (inilah MLOps, machine learning operations), dan memakainya secara bertanggung jawab.

Siklus hidup model: data, latih, jelaskan dan uji, sajikan, pantau, lalu kembali ke data bila ada drift.
CatatanINGAT

Data di bab ini sama dengan Bab 54: petak lahan tiruan. Salinan buat_data.py ada di folder bab ini agar bab ini berdiri sendiri. Semua angka adalah contoh.

Pentingnya fitur: hati-hati dengan ukuran bawaan

Di Bab 54 kita membaca feature_importances_ dari random forest. Ukuran itu (berbasis penurunan impurity) punya kelemahan: bila dua fitur berkorelasi, nilainya terbagi di antara mereka. Permutation importance menghitung seberapa turun akurasi pada data uji bila satu kolom diacak-acak: bila kolom itu penting, akurasi anjlok.

Kita buktikan dengan menambah fitur elevasi_ft: elevasi yang sama dalam satuan kaki (salinan persis elevasi_m).

01_importance_permutasi.py
# Bab 58 - Pentingnya fitur: bawaan random forest vs permutasi (pada data uji)
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
from sklearn.model_selection import train_test_split

from buat_data import FITUR, buat_lahan

df = buat_lahan()
# Tambah fitur "elevasi_ft" = elevasi dalam kaki: salinan hampir persis elevasi_m (berkorelasi sempurna)
df["elevasi_ft"] = df["elevasi_m"] * 3.28084
kolom = FITUR + ["elevasi_ft"]
X_latih, X_uji, y_latih, y_uji = train_test_split(df[kolom], df["kelas"], test_size=0.3, random_state=42, stratify=df["kelas"])
model = RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=1).fit(X_latih, y_latih)
print("Akurasi uji:", round(model.score(X_uji, y_uji), 3))

perm = permutation_importance(model, X_uji, y_uji, n_repeats=10, random_state=42, n_jobs=1)
print("\nfitur                | bawaan RF (impurity) | permutasi (turun akurasi)")
for i in np.argsort(-model.feature_importances_):
    print(f"{kolom[i]:20s} | {model.feature_importances_[i]:20.3f} | {perm.importances_mean[i]:.3f} +- {perm.importances_std[i]:.3f}")
Akurasi uji: 0.914

fitur                | bawaan RF (impurity) | permutasi (turun akurasi)
ndvi                 |                0.413 | 0.356 +- 0.014
elevasi_ft           |                0.205 | 0.082 +- 0.014
elevasi_m            |                0.178 | 0.061 +- 0.009
kemiringan_derajat   |                0.143 | 0.126 +- 0.017
curah_hujan_mm       |                0.062 | 0.017 +- 0.005

Elevasi muncul dua kali (elevasi_ft 0,205 dan elevasi_m 0,178 menurut ukuran bawaan), padahal informasinya satu. Permutasi menunjukkan hal serupa: mengacak salah satu salinan hanya menurunkan akurasi 0,082 atau 0,061, karena model masih bisa memakai salinan lainnya. Pelajaran: sebelum menyimpulkan “fitur ini tidak penting”, periksa apakah ada fitur kembarannya.

SHAP: kontribusi tiap fitur pada tiap prediksi

SHAP (SHapley Additive exPlanations) membagi selisih antara prediksi sebuah petak dan prediksi rata-rata menjadi kontribusi tiap fitur, memakai gagasan dari teori permainan (membagi “hadiah” secara adil di antara “pemain”). Untuk model berbasis pohon, shap.TreeExplainer menghitungnya cepat dan eksak. Dokumentasi SHAP (dibaca 2026-10-10) menyatakan bahwa jumlah nilai SHAP ditambah nilai dasar (expected_value) sama dengan keluaran model, dan random forest scikit-learn didukung.

02_shap_rf.py
# Bab 58 - SHAP: menjelaskan prediksi random forest per petak, dan bukti bahwa penjelasannya "menjumlah"
import numpy as np
import shap
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

from buat_data import FITUR, buat_lahan

df = buat_lahan()
X_latih, X_uji, y_latih, y_uji = train_test_split(df[FITUR], df["kelas"], test_size=0.3, random_state=42, stratify=df["kelas"])
model = RandomForestClassifier(n_estimators=100, max_depth=8, random_state=42, n_jobs=1).fit(X_latih, y_latih)
kelas = list(model.classes_)

penjelas = shap.TreeExplainer(model)
sv = penjelas(X_uji.iloc[:100])                         # objek Explanation: nilai (petak, fitur, kelas)
print("Bentuk nilai SHAP (petak, fitur, kelas):", sv.values.shape)

# Satu petak: mengapa model menjawab seperti itu?
i = 0
proba = model.predict_proba(X_uji.iloc[[i]])[0]
k = int(np.argmax(proba))
print(f"\nPetak uji #{i}: kelas sebenarnya = {y_uji.iloc[i]}, prediksi = {kelas[k]} (peluang {proba[k]:.2f})")
print("Nilai fitur :", X_uji.iloc[i].round(2).to_dict())
print(f"Peluang dasar kelas '{kelas[k]}' (rata-rata data latih): {sv.base_values[i, k]:.3f}")
for nama, nilai in sorted(zip(FITUR, sv.values[i, :, k]), key=lambda p: -abs(p[1])):
    print(f"  {nama:20s} {nilai:+.3f}")
print("Dasar + jumlah kontribusi =", round(sv.base_values[i, k] + sv.values[i, :, k].sum(), 3), "| peluang model =", round(proba[k], 3))

# Gambaran global: rata-rata |SHAP| per fitur untuk kelas 'hutan'
kh = kelas.index("hutan")
penting = np.abs(sv.values[:, :, kh]).mean(axis=0)
print("\nRata-rata |SHAP| untuk kelas 'hutan':")
for nama, nilai in sorted(zip(FITUR, penting), key=lambda p: -p[1]):
    print(f"  {nama:20s} {nilai:.3f}")
Bentuk nilai SHAP (petak, fitur, kelas): (100, 4, 4)

Petak uji #0: kelas sebenarnya = kebun, prediksi = kebun (peluang 0.78)
Nilai fitur : {'ndvi': 0.59, 'elevasi_m': 237.0, 'kemiringan_derajat': 3.7, 'curah_hujan_mm': 2174.0}
Peluang dasar kelas 'kebun' (rata-rata data latih): 0.121
  elevasi_m            +0.424
  ndvi                 +0.199
  kemiringan_derajat   +0.039
  curah_hujan_mm       +0.001
Dasar + jumlah kontribusi = 0.784 | peluang model = 0.784

Rata-rata |SHAP| untuk kelas 'hutan':
  elevasi_m            0.131
  kemiringan_derajat   0.108
  ndvi                 0.066
  curah_hujan_mm       0.009
  • Bentuk (100, 4, 4): 100 petak, 4 fitur, 4 kelas. Tiap kelas punya penjelasan sendiri.
  • Petak uji #0 (sebenarnya kebun) diprediksi kebun dengan peluang 0,78. Peluang dasar kelas kebun hanya 0,121. Elevasi 237 m menambah +0,424 dan NDVI 0,59 menambah +0,199; kemiringan dan curah hujan hampir tak berperan.
  • Buktinya: 0,121 + 0,424 + 0,199 + 0,039 + 0,001 = 0,784, sama dengan peluang model (0,784). Penjelasan SHAP benar-benar menjumlah ke prediksi.
  • Secara global, untuk kelas hutan: elevasi (rata-rata |SHAP| 0,131) dan kemiringan (0,108) paling berpengaruh. Bandingkan dengan bawaan RF di Bab 54 yang menaruh NDVI di puncak; ukuran yang berbeda menjawab pertanyaan yang berbeda (di sini: pengaruh pada kelas hutan saja).

Tiap titik satu petak; warna = nilai fitur; posisi kiri-kanan = dorongan ke kelas ‘hutan’. Elevasi tinggi dan kemiringan curam mendorong ke ‘hutan’.

LIME: tetangga acak dan model sederhana

LIME menjelaskan satu prediksi dengan cara lain: ia membuat ribuan petak “tetangga” dengan mengacak fitur di sekitar petak itu, meminta model asli menebaknya, lalu mencocokkan model linear sederhana pada hasilnya. Koefisien model sederhana itulah penjelasannya. Kita jalankan tiga kali dengan pengacakan LIME berbeda.

03_lime.py
# Bab 58 - LIME: menjelaskan SATU prediksi dengan model sederhana di sekitar titik itu
import pandas as pd
from lime.lime_tabular import LimeTabularExplainer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

from buat_data import FITUR, buat_lahan

df = buat_lahan()
X_latih, X_uji, y_latih, y_uji = train_test_split(df[FITUR], df["kelas"], test_size=0.3, random_state=42, stratify=df["kelas"])
model = RandomForestClassifier(n_estimators=100, max_depth=8, random_state=42, n_jobs=1).fit(X_latih, y_latih)

def prediksi(larik):                                      # LIME mengirim larik NumPy; model kita dilatih dengan DataFrame
    return model.predict_proba(pd.DataFrame(larik, columns=FITUR))


k = list(model.classes_).index("kebun")
for biji in (42, 1, 2):                                   # jalankan tiga kali dengan pengacakan LIME berbeda
    penjelas = LimeTabularExplainer(X_latih.values, feature_names=FITUR, class_names=list(model.classes_),
                                    discretize_continuous=True, random_state=biji)
    penjelasan = penjelas.explain_instance(X_uji.values[0], prediksi, num_features=4, num_samples=2000, labels=[k])
    print(f"Petak uji #0, kelas 'kebun', random_state LIME = {biji}  (R2 model lokal = {penjelasan.score:.2f})")
    for aturan, bobot in penjelasan.as_list(label=k):
        print(f"  {aturan:45s} {bobot:+.3f}")
Petak uji #0, kelas 'kebun', random_state LIME = 42  (R2 model lokal = 0.03)
  0.38 < ndvi <= 0.61                           +0.082
  1.00 < kemiringan_derajat <= 4.15             -0.053
  114.50 < elevasi_m <= 237.25                  -0.004
  2117.50 < curah_hujan_mm <= 2245.00           -0.001
Petak uji #0, kelas 'kebun', random_state LIME = 1  (R2 model lokal = 0.02)
  0.38 < ndvi <= 0.61                           +0.066
  1.00 < kemiringan_derajat <= 4.15             -0.039
  2117.50 < curah_hujan_mm <= 2245.00           -0.017
  114.50 < elevasi_m <= 237.25                  -0.001
Petak uji #0, kelas 'kebun', random_state LIME = 2  (R2 model lokal = 0.02)
  0.38 < ndvi <= 0.61                           +0.050
  1.00 < kemiringan_derajat <= 4.15             -0.044
  114.50 < elevasi_m <= 237.25                  -0.025
  2117.50 < curah_hujan_mm <= 2245.00           +0.009

LIME sepakat dengan SHAP pada arah umum: 0,38 < ndvi <= 0,61 mendorong ke kebun. Tetapi lihat tanda peringatannya:

  • Angka tiap jalan berbeda (bobot NDVI +0,082, +0,066, +0,050) dan urutan fitur lemah berubah. LIME tidak stabil karena memakai sampel acak.
  • R² model lokal hanya 0,02 sampai 0,03: model sederhana itu hampir tidak bisa meniru model asli di sekitar petak ini. Penjelasannya tidak dapat dipercaya sebagai rincian.
  • SHAP sebaliknya eksak untuk pohon dan menjumlah ke prediksi. Untuk model berbasis pohon, utamakan SHAP; LIME berguna untuk model yang tidak punya penjelas khusus, dengan skeptisisme dan pemeriksaan R².

Partial dependence: bentuk hubungan

Partial dependence menjawab “bila fitur ini digeser, bagaimana prediksi rata-rata berubah?” dengan mengganti nilai satu fitur pada semua baris, lalu merata-ratakan prediksinya.

04_pdp.py
# Bab 58 - Partial dependence: bagaimana prediksi rata-rata berubah bila SATU fitur digeser
import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.inspection import partial_dependence

from buat_data import FITUR, buat_lahan

df = buat_lahan()
sawah = df[df["kelas"] == "sawah"].copy()
rng = np.random.default_rng(7)
sawah["panen_kg_ha"] = (4000 + 5000 * np.sin(np.pi * sawah["ndvi"].clip(0, 1))
                        + 0.8 * (sawah["curah_hujan_mm"] - 2200) + rng.normal(0, 250, len(sawah))).round(0)
kolom = ["ndvi", "curah_hujan_mm", "elevasi_m"]
model = RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=1).fit(sawah[kolom], sawah["panen_kg_ha"])

for fitur in ["ndvi", "curah_hujan_mm"]:
    pd_ = partial_dependence(model, sawah[kolom], [fitur], grid_resolution=6)
    print(f"\nPartial dependence panen terhadap {fitur}:")
    for nilai, pred in zip(pd_["grid_values"][0], pd_["average"][0]):
        print(f"  {fitur} = {nilai:8.2f}  ->  panen rata-rata = {pred:7.0f} kg/ha")

Partial dependence panen terhadap ndvi:
  ndvi =     0.25  ->  panen rata-rata =    7259 kg/ha
  ndvi =     0.33  ->  panen rata-rata =    8175 kg/ha
  ndvi =     0.41  ->  panen rata-rata =    8708 kg/ha
  ndvi =     0.49  ->  panen rata-rata =    8830 kg/ha
  ndvi =     0.57  ->  panen rata-rata =    8759 kg/ha
  ndvi =     0.65  ->  panen rata-rata =    8185 kg/ha

Partial dependence panen terhadap curah_hujan_mm:
  curah_hujan_mm =  1811.42  ->  panen rata-rata =    8372 kg/ha
  curah_hujan_mm =  1929.86  ->  panen rata-rata =    8404 kg/ha
  curah_hujan_mm =  2048.30  ->  panen rata-rata =    8490 kg/ha
  curah_hujan_mm =  2166.73  ->  panen rata-rata =    8524 kg/ha
  curah_hujan_mm =  2285.17  ->  panen rata-rata =    8653 kg/ha
  curah_hujan_mm =  2403.61  ->  panen rata-rata =    8675 kg/ha

Panen sawah (angka contoh) naik dari 7.259 kg/ha pada NDVI 0,25 hingga puncak 8.830 pada NDVI 0,49, lalu turun ke 8.185 pada NDVI 0,65: hubungannya melengkung, seperti yang kita susun di Bab 54. Curah hujan hanya menaikkan panen perlahan, dari 8.372 ke 8.675. Hubungan seperti ini tidak terlihat dari angka feature_importances_ saja.

PeringatanAWAS

Penjelasan model menerangkan apa yang dilakukan model, bukan apa yang terjadi di alam. “NDVI tinggi mendorong prediksi hutan” tidak berarti menaikkan NDVI akan menjadikan petak itu hutan. Hubungan sebab-akibat membutuhkan desain penelitian, bukan hanya model (Bab 26-27).

MLflow: buku catatan percobaan

Setelah puluhan percobaan, siapa ingat model mana memakai parameter apa dan hasilnya berapa? MLflow (tracking) mencatat parameter, metrik, dan berkas tiap percobaan (run) ke penyimpanan lokal atau server. Kita memakai basis data SQLite di folder _tmp/, tanpa server apa pun.

05_mlflow.py
# Bab 58 - MLflow: mencatat percobaan (parameter, metrik, model) ke folder lokal, tanpa server
import logging
import shutil
from pathlib import Path

import mlflow
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GroupKFold, cross_val_score

from buat_data import FITUR, buat_lahan

logging.disable(logging.INFO)                         # sembunyikan log INFO MLflow yang berisi jam
folder = Path(__file__).parent / "_tmp" / "mlflow_lokal"
shutil.rmtree(folder, ignore_errors=True)
folder.mkdir(parents=True)
mlflow.set_tracking_uri(f"sqlite:///{(folder / 'mlflow.db').as_posix()}")        # basis data SQLite lokal
mlflow.set_experiment("tutupan-lahan")

df = buat_lahan()
for n_pohon, kedalaman in [(50, 4), (100, 8), (200, 12)]:
    with mlflow.start_run(run_name=f"rf_{n_pohon}_{kedalaman}"):
        model = RandomForestClassifier(n_estimators=n_pohon, max_depth=kedalaman, random_state=42, n_jobs=1)
        skor = cross_val_score(model, df[FITUR], df["kelas"], groups=df["blok"], cv=GroupKFold(n_splits=4))
        mlflow.log_params({"n_estimators": n_pohon, "max_depth": kedalaman, "validasi": "GroupKFold blok"})
        mlflow.log_metric("akurasi_blok", float(skor.mean()))
        mlflow.log_metric("simpangan_baku", float(skor.std()))

hasil = mlflow.search_runs(experiment_names=["tutupan-lahan"], order_by=["metrics.akurasi_blok DESC"])
print("Jumlah percobaan tercatat:", len(hasil))
print(hasil[["tags.mlflow.runName", "params.n_estimators", "params.max_depth", "metrics.akurasi_blok"]]
      .round(3).to_string(index=False))
print("\nBerkas basis data MLflow ada:", (folder / "mlflow.db").exists())
Jumlah percobaan tercatat: 3
tags.mlflow.runName params.n_estimators params.max_depth  metrics.akurasi_blok
          rf_200_12                 200               12                 0.889
           rf_100_8                 100                8                 0.884
            rf_50_4                  50                4                 0.876

Berkas basis data MLflow ada: True

Tiga percobaan (50, 100, dan 200 pohon) tercatat dan search_runs mengurutkannya dari akurasi blok tertinggi: 0,889 untuk 200 pohon kedalaman 12. Di komputer Anda, mlflow ui --backend-store-uri sqlite:///... membuka antarmuka web untuk membandingkannya (tidak dijalankan di buku ini). Dokumentasi tentang pilihan penyimpanan MLflow tidak berhasil kami baca hari ini, jadi buku ini hanya menyatakan bahwa jalur SQLite berjalan di versi MLflow yang terpasang.

TipTIPS

Catat juga versi data dan kode (hash Git) sebagai parameter. Tanpa itu, “model 0,889” tidak bisa direproduksi tiga bulan lagi.

DVC: versi untuk data

Git hebat untuk kode tetapi buruk untuk berkas data besar. DVC (Data Version Control) menyimpan data di tempat lain (disk bersama, cloud) dan hanya memasukkan berkas kecil .dvc yang berisi sidik jari (hash) data ke Git. Mengubah data mengubah sidik jarinya, sehingga Git mencatat versi data bersama versi kode.

DVC belum bisa dipasang di Python 3.14 milik buku ini: pip gagal menyelesaikan dependensinya (percobaan 2026-10-10). Kami memasangnya di lingkungan Python 3.12 terpisah (DVC 3.67.1) dan mengambil keluaran asli sekali; naskah ini memakai keluaran tersimpan itu (@statis), jadi skrip di bawah tidak dijalankan ulang saat buku dibuat.

09_dvc_versi_data.py
# Bab 58 - DVC: versi data seperti Git untuk berkas besar (dijalankan lewat perintah dvc di folder sementara)
import os
import shutil
import stat
import subprocess
from pathlib import Path

kerja = Path(__file__).parent / "_tmp" / "demo_dvc"


def paksa_hapus(fungsi, jalur, galat):                  # DVC membuat berkas cache read-only di Windows; izinkan dulu lalu hapus
    os.chmod(jalur, stat.S_IWRITE)
    fungsi(jalur)


if kerja.exists():
    shutil.rmtree(kerja, onexc=paksa_hapus)
kerja.mkdir(parents=True)
os.environ["DVC_NO_ANALYTICS"] = "1"


def dvc(*argumen):
    hasil = subprocess.run(["dvc", *argumen], cwd=kerja, capture_output=True, text=True)
    print("$ dvc", *argumen)
    print((hasil.stdout + hasil.stderr).strip())


(kerja / "lahan.csv").write_text("petak,ndvi\n1,0.81\n2,0.35\n3,0.62\n", encoding="utf-8")
dvc("init", "--no-scm")                    # --no-scm: tanpa Git, hanya untuk demo
dvc("add", "lahan.csv")
print("\nIsi lahan.csv.dvc (berkas kecil yang dimasukkan ke Git):")
print((kerja / "lahan.csv.dvc").read_text(encoding="utf-8"))
dvc("status")
(kerja / "lahan.csv").write_text("petak,ndvi\n1,0.81\n2,0.35\n3,0.62\n4,0.48\n", encoding="utf-8")   # data berubah
print("\nSetelah data diubah:")
dvc("status")
$ dvc init --no-scm
Initialized DVC repository.

What's next?
------------
- Check out the documentation: <https://dvc.org/doc>
- Get help and share ideas: <https://dvc.org/chat>
- Star us on GitHub: <https://github.com/treeverse/dvc>
$ dvc add lahan.csv


Isi lahan.csv.dvc (berkas kecil yang dimasukkan ke Git):
outs:
- md5: 02302a68c615060922d2eaa491ffb4e3
  size: 36
  hash: md5
  path: lahan.csv

$ dvc status
Data and pipelines are up to date.

Setelah data diubah:
$ dvc status
lahan.csv.dvc:
    changed outs:
        modified:           lahan.csv
  • dvc init --no-scm menyiapkan DVC tanpa Git (hanya untuk demo; di proyek nyata Anda memakai Git).
  • dvc add lahan.csv menghitung hash md5 02302a68... dan menulis lahan.csv.dvc. Berkas kecil inilah yang Anda git add.
  • dvc status berkata “up to date” lalu, setelah kita menambah satu baris data, melaporkan modified: lahan.csv: DVC menyadari data berubah dari hash-nya.
  • Perintah dvc push dan dvc pull (untuk mengirim data ke penyimpanan jarak jauh) tidak dicoba di sini.

Memantau model: drift

Model dilatih dari potret masa lalu. Dunia berubah: sensor menua, musim kemarau panjang, kebijakan mengubah tanam. Pergeseran distribusi data masukan disebut data drift. Cara paling sederhana mendeteksinya: bandingkan distribusi tiap fitur antara data latih dan data baru dengan uji Kolmogorov-Smirnov (scipy.stats.ks_2samp). P-value kecil berarti dua sebaran sangat mungkin berbeda.

06_drift_ks.py
# Bab 58 - Memantau drift: apakah data baru masih "sejenis" dengan data latih? (uji Kolmogorov-Smirnov)
import numpy as np
from scipy.stats import ks_2samp

from buat_data import FITUR, buat_lahan

semua = buat_lahan(n=2400, benih=42)
acak = np.random.default_rng(0).permutation(len(semua))
latih = semua.iloc[acak[:1200]]                       # data waktu model dilatih
aman = semua.iloc[acak[1200:1800]]                    # data baru dari proses yang sama
bergeser = semua.iloc[acak[1800:]].copy()             # data baru, tetapi kondisi lapangan berubah
bergeser["ndvi"] = (bergeser["ndvi"] - 0.08).round(3)                    # sensor menua / musim kering: NDVI turun 0,08
bergeser["curah_hujan_mm"] = bergeser["curah_hujan_mm"] * 0.85           # curah hujan 15% lebih rendah

print("fitur                | data baru normal (p) | data baru bergeser (p)")
for f in FITUR:
    p_aman = ks_2samp(latih[f], aman[f]).pvalue
    p_geser = ks_2samp(latih[f], bergeser[f]).pvalue
    tanda = "  <- DRIFT" if p_geser < 0.01 else ""
    print(f"{f:20s} | {p_aman:20.3f} | {p_geser:22.4g}{tanda}")


def psi(a, b, k=10):
    """Population Stability Index: <0,1 stabil, 0,1-0,25 waspada, >0,25 berubah besar (aturan praktis industri)."""
    batas = np.quantile(a, np.linspace(0, 1, k + 1))
    batas[0], batas[-1] = -np.inf, np.inf
    pa = np.histogram(a, batas)[0] / len(a) + 1e-6
    pb = np.histogram(b, batas)[0] / len(b) + 1e-6
    return float(((pb - pa) * np.log(pb / pa)).sum())


print("\nPSI NDVI, data normal  :", round(psi(latih["ndvi"], aman["ndvi"]), 3))
print("PSI NDVI, data bergeser:", round(psi(latih["ndvi"], bergeser["ndvi"]), 3))
fitur                | data baru normal (p) | data baru bergeser (p)
ndvi                 |                0.412 |              1.368e-05  <- DRIFT
elevasi_m            |                0.995 |                  0.679
kemiringan_derajat   |                0.882 |                 0.4124
curah_hujan_mm       |                0.595 |             1.046e-132  <- DRIFT

PSI NDVI, data normal  : 0.01
PSI NDVI, data bergeser: 0.166
  • Data baru “normal” (diambil dari peta yang sama) p-value 0,412 untuk NDVI, 0,995 untuk elevasi: tidak ada drift, sesuai harapan.
  • Setelah NDVI diturunkan 0,08 dan curah hujan dikurangi 15%, uji KS menandai keduanya (p = 1,4 x 10^-5 dan 1 x 10^-132). Elevasi dan kemiringan tidak berubah dan tidak ditandai.
  • PSI (population stability index) merangkum pergeseran dengan satu angka: 0,010 (normal) vs 0,166 (bergeser). Ambang “di bawah 0,1 stabil, 0,1 sampai 0,25 waspada, di atas 0,25 berubah besar” adalah aturan praktis industri, bukan standar baku.

Distribusi NDVI data latih (hijau) dan data baru (merah): puncaknya bergeser ke kiri.
PeringatanAWAS

Uji KS dengan data besar sangat sensitif: perbedaan kecil yang tak berdampak bisa menghasilkan p-value mungil. Lengkapi dengan ukuran besarnya pergeseran (PSI, selisih rata-rata) dan, bila tersedia, kinerja model sebenarnya pada data berlabel terbaru. Drift pada fitur tidak otomatis berarti model buruk.

Ketidakpastian prediksi

Model yang berkata “panen 8.696 kg/ha” tanpa rentang itu terdengar lebih yakin daripada seharusnya. Dua cara memberi rentang:

  1. Sebaran pohon: tiap pohon di random forest memberi prediksi sendiri; sebarannya menggambarkan perbedaan pendapat. Mudah, tetapi cakupannya tidak dijamin.
  2. Conformal prediction (versi split): sisihkan data kalibrasi, ukur galat sebenarnya model pada data itu, lalu pakai kuantil galat sebagai lebar selang. Dengan asumsi data kalibrasi dan data baru sejenis, ia menjamin cakupan minimal sekitar tingkat yang diminta (di sini 90%) rata-rata.
07_ketidakpastian.py
# Bab 58 - Ketidakpastian prediksi: selang dari sebaran pohon, dan "conformal prediction" yang cakupannya teruji
import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split

from buat_data import buat_lahan

df = buat_lahan()
rng = np.random.default_rng(7)
sawah = df[df["kelas"] == "sawah"].copy()
sawah["panen_kg_ha"] = (4000 + 5000 * np.sin(np.pi * sawah["ndvi"].clip(0, 1))
                        + 0.8 * (sawah["curah_hujan_mm"] - 2200) + rng.normal(0, 250, len(sawah))).round(0)
kolom = ["ndvi", "curah_hujan_mm", "elevasi_m"]
X_sisa, X_uji, y_sisa, y_uji = train_test_split(sawah[kolom], sawah["panen_kg_ha"], test_size=0.3, random_state=1)
X_latih, X_kal, y_latih, y_kal = train_test_split(X_sisa, y_sisa, test_size=0.4, random_state=1)   # kalibrasi terpisah
model = RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=1).fit(X_latih, y_latih)

# 1) Sebaran prediksi 200 pohon untuk satu petak
semua_pohon = np.array([p.predict(X_uji.iloc[[0]].values)[0] for p in model.estimators_])
print(f"Petak uji #0: prediksi forest = {model.predict(X_uji.iloc[[0]])[0]:.0f} kg/ha, kenyataan = {y_uji.iloc[0]:.0f}")
print(f"  pohon-pohon berbeda pendapat: 10%-90% = {np.percentile(semua_pohon, 10):.0f} sampai {np.percentile(semua_pohon, 90):.0f}")

# 2) Split conformal prediction: ukur galat nyata pada data kalibrasi, pakai sebagai lebar selang
galat = np.abs(y_kal - model.predict(X_kal))
alfa = 0.1                                                                  # target cakupan 90%
n = len(galat)
q = np.quantile(galat, min(1.0, np.ceil((n + 1) * (1 - alfa)) / n))
pred = model.predict(X_uji)
tercakup = np.mean((y_uji >= pred - q) & (y_uji <= pred + q))
print(f"\nConformal: lebar selang = +-{q:.0f} kg/ha (dari {n} petak kalibrasi)")
print(f"Cakupan pada {len(y_uji)} petak uji (target 90%): {tercakup:.0%}")

# Selang 10%-90% dari sebaran pohon tidak dijamin memenuhi cakupan
lo, hi = [], []
for x in X_uji.values:
    ps = np.array([p.predict(x.reshape(1, -1))[0] for p in model.estimators_])
    lo.append(np.percentile(ps, 5)), hi.append(np.percentile(ps, 95))
print("Cakupan selang 5%-95% sebaran pohon (nominal 90%):", f"{np.mean((y_uji >= lo) & (y_uji <= hi)):.0%}")
Petak uji #0: prediksi forest = 8696 kg/ha, kenyataan = 8692
  pohon-pohon berbeda pendapat: 10%-90% = 8389 sampai 9273

Conformal: lebar selang = +-564 kg/ha (dari 92 petak kalibrasi)
Cakupan pada 98 petak uji (target 90%): 97%
Cakupan selang 5%-95% sebaran pohon (nominal 90%): 82%
  • Petak #0: prediksi 8.696, kenyataan 8.692; pohon-pohon berbeda pendapat di rentang 8.389 sampai 9.273 (persentil 10-90).
  • Conformal: selang ±564 kg/ha dari 92 petak kalibrasi, cakupan di 98 petak uji 97% (target minimal 90%). Cakupan lebih tinggi daripada target adalah hal wajar: jaminannya batas bawah rata-rata dan sampel kecil bisa membuat selang konservatif.
  • Selang 5%-95% dari sebaran pohon yang diberi label “90%” hanya mencakup 82% kenyataan: terlalu percaya diri. Tanpa pengujian cakupan seperti ini, kita tidak tahu selang mana yang jujur.

Bias, privasi, dan model card

Bias di sini berarti model bekerja buruk untuk kelompok tertentu tanpa disadari karena akurasi keseluruhan menutupinya. Periksa kinerja per kelompok: per kelas, per wilayah, per musim, per kelompok pemilik lahan bila datanya ada. Privasi: koordinat petak milik perorangan bisa menunjukkan siapa pemiliknya; kaburkan sebelum data dibagikan. Model card adalah dokumen ringkas (diusulkan peneliti sebagai praktik dokumentasi model) berisi tujuan, data, evaluasi, kelemahan, dan batas pemakaian model.

08_bias_dan_model_card.py
# Bab 58 - Bias antar-wilayah, pengaburan koordinat (privasi), dan model card
import json
from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GroupKFold, cross_val_predict

from buat_data import FITUR, buat_lahan

df = buat_lahan()
model = RandomForestClassifier(n_estimators=100, max_depth=8, random_state=42, n_jobs=1)
df["prediksi"] = cross_val_predict(model, df[FITUR], df["kelas"], groups=df["blok"], cv=GroupKFold(n_splits=4))
df["benar"] = df["prediksi"] == df["kelas"]

# 1) Kinerja per kelompok: akurasi keseluruhan bisa menyembunyikan kelompok yang diperlakukan buruk
print("Akurasi keseluruhan:", round(df["benar"].mean(), 3))
print("\nAkurasi per kelas sebenarnya (recall):")
print(df.groupby("kelas")["benar"].agg(["mean", "size"]).round(3).rename(columns={"mean": "akurasi", "size": "jumlah"}).to_string())
df["wilayah"] = np.where(df["x"] < 500, "barat", "timur")
print("\nAkurasi per wilayah:")
print(df.groupby("wilayah")["benar"].agg(["mean", "size"]).round(3).rename(columns={"mean": "akurasi", "size": "jumlah"}).to_string())

# 2) Privasi: koordinat petak milik perorangan bisa dikaburkan sebelum data dibagikan
publik = df[["x", "y"]].copy()
publik["x"], publik["y"] = (publik["x"] // 100) * 100, (publik["y"] // 100) * 100       # dibulatkan ke sel 100 m
print("\nTitik unik sebelum dikaburkan:", len(df[["x", "y"]].drop_duplicates()), "| sesudah:", len(publik.drop_duplicates()))

# 3) Model card: dokumen ringkas tentang model, dibuat dari angka nyata percobaan
kartu = {
    "nama": "Klasifikasi tutupan lahan (random forest, contoh buku)",
    "tujuan": "Memetakan empat kelas lahan dari NDVI, elevasi, kemiringan, curah hujan pada data TIRUAN",
    "data": f"{len(df)} petak tiruan, peta 1000 x 1000 m, 4 kelas",
    "evaluasi": f"GroupKFold 4 lipatan berbasis blok 250 m; akurasi {df['benar'].mean():.3f}",
    "kelemahan": "kelas terlemah: " + df.groupby("kelas")["benar"].mean().idxmin(),
    "bukan_untuk": ["keputusan hukum atau sanksi", "wilayah dengan sensor atau musim berbeda tanpa validasi ulang"],
    "pemantauan": "uji KS dan PSI pada NDVI dan curah hujan tiap bulan",
}
berkas = Path(__file__).parent / "_tmp" / "model_card.json"
berkas.parent.mkdir(exist_ok=True)
berkas.write_text(json.dumps(kartu, indent=2, ensure_ascii=False), encoding="utf-8")
print("\nIsi model card:")
print(berkas.read_text(encoding="utf-8"))
Akurasi keseluruhan: 0.884

Akurasi per kelas sebenarnya (recall):
            akurasi  jumlah
kelas                      
hutan         0.939     198
kebun         0.753     146
permukiman    0.928     530
sawah         0.837     326

Akurasi per wilayah:
         akurasi  jumlah
wilayah                 
barat      0.905     602
timur      0.863     598

Titik unik sebelum dikaburkan: 1200 | sesudah: 100

Isi model card:
{
  "nama": "Klasifikasi tutupan lahan (random forest, contoh buku)",
  "tujuan": "Memetakan empat kelas lahan dari NDVI, elevasi, kemiringan, curah hujan pada data TIRUAN",
  "data": "1200 petak tiruan, peta 1000 x 1000 m, 4 kelas",
  "evaluasi": "GroupKFold 4 lipatan berbasis blok 250 m; akurasi 0.884",
  "kelemahan": "kelas terlemah: kebun",
  "bukan_untuk": [
    "keputusan hukum atau sanksi",
    "wilayah dengan sensor atau musim berbeda tanpa validasi ulang"
  ],
  "pemantauan": "uji KS dan PSI pada NDVI dan curah hujan tiap bulan"
}
  • Akurasi keseluruhan 0,884 terdengar baik, tetapi kelas kebun hanya 0,753 sementara hutan 0,939. Peta yang dipakai untuk kebijakan kebun akan paling sering salah.
  • Wilayah barat 0,905 vs timur 0,863: sedikit lebih buruk di timur. Selisih sekecil ini bisa kebetulan; yang penting adalah memeriksa.
  • 1.200 titik unik menjadi 100 sel setelah koordinat dibulatkan ke 100 m. Pengaburan mengurangi risiko, tetapi tidak menjamin anonimitas bila data lain bisa dicocokkan.
  • Model card dibuat dari angka nyata percobaan, lengkap dengan “bukan untuk” (keputusan hukum atau sanksi) dan rencana pemantauan.

Proyek Bersambung A3: model yang dapat dipertanggungjawabkan

Proyek ini bukan skrip baru, tetapi kebiasaan: untuk setiap model Seri A3, siapkan lima hal berikut sebelum menyerahkan peta kepada pengguna.

Pertanyaan pengguna Alat di bab ini
“Mengapa petak ini begini?” SHAP (skrip 02), partial dependence (04)
“Percobaan mana yang dipakai, bisa diulang?” MLflow (05), DVC (09)
“Masih berlaku tahun depan?” KS dan PSI (06)
“Seberapa yakin?” Conformal (07)
“Adil dan aman?” Akurasi per kelompok, pengaburan koordinat, model card (08)

Latihan

  1. Di 02_shap_rf.py, ganti i = 0 dengan petak lain dan tulis penjelasan satu kalimat dari angka SHAP-nya.
  2. Di 03_lime.py, naikkan num_samples dari 2000 menjadi 20000. Apakah R² lokal dan kestabilan antar-jalan membaik?
  3. Di 06_drift_ks.py, kurangi NDVI hanya 0,01. Apakah KS masih menandai drift? Apa arti hasilnya?
  4. Tantangan: ubah 07_ketidakpastian.py agar mencoba target alfa = 0.2 (cakupan 80%) dan 0.05 (95%). Apakah cakupan nyata mengikuti targetnya?

Ringkasan Bab

  • Ukuran pentingnya fitur bawaan bisa menyesatkan bila fitur berkorelasi; coba permutasi.
  • SHAP menjelaskan tiap prediksi dengan kontribusi yang menjumlah; LIME acak dan bisa tidak stabil; partial dependence menunjukkan bentuk hubungan.
  • MLflow mencatat percobaan; DVC memberi versi pada data lewat hash.
  • Drift: bandingkan sebaran data baru dengan data latih (KS, PSI), dan jangan lupa memantau kinerja sebenarnya.
  • Beri selang ketidakpastian yang cakupannya diuji (conformal).
  • Periksa kinerja per kelompok, kaburkan data pribadi, dan tulis model card.

Selanjutnya, di Bab 59: semua perkakas Seri A3 bertemu di lapangan: kehutanan, pertanian, perikanan, peternakan, dan sumber daya alam, dari LiDAR sampai ternak dalam foto udara.