Chapter Ancaman Keamanan Model Machine Learning menutup dengan satu kategori yang menuntut pembongkaran matematis: evasion — input yang digeser sangat tipis sehingga manusia hampir tidak bisa membedakannya, tetapi model mengubah keputusannya. Kategori ini menempati posisi ML01 pada OWASP ML Security Top 10 dan menjadi risiko paling sering dilaporkan pada sistem klasifikasi produksi. Kabar baiknya untuk pembelajaran: pada model linear seperti LogisticRegression, seluruh mekanisme evasion bisa dihitung dengan tangan — gradien decision function terhadap input hanyalah vektor koefisien. Chapter ini membangun adversarial example secara eksak dengan NumPy, mengukur kerentanannya lewat robust accuracy, lalu mengujikan pertahanan yang paling terkenal: adversarial training.

Evasion: Menggeser Input, Bukan Data Latih #

Masalah nyatanya mudah dinyatakan. Sebuah filter transaksi memutuskan “wajar” atau “penipuan” berdasarkan beberapa fitur numerik; pelaku yang memahami arah kasar keputusan model cukup mengubah nilai transaksinya sedikit — masih wajar di mata manusia — agar jatuh di sisi boundary yang aman. Tidak ada data latih yang disentuh, tidak ada akses khusus yang dibutuhkan; ini persis alasan evasion dikelompokkan terpisah dari data poisoning pada taxonomy Chapter sebelumnya.

Mekanismenya menjadi transparan pada model linear. LogisticRegression memutuskan lewat logit f(x) = w·x + b: kelas 1 jika positif, kelas 0 jika negatif. Turunan f terhadap input x adalah w itu sendiri — artinya menggeser x sebesar delta = eps * sign(w) menaikkan logit sebesar eps * ||w||_1, pergeseran logit terbesar yang bisa dibeli dengan budget L-infinity sebesar eps. Inilah inti Fast Gradient Sign Method (FGSM). Dua rumus tertutup lain menyusul: perturbasi L2 minimal yang tepat menempatkan titik pada boundary adalah |f(x)| / ||w||_2, dan jarak L-infinity minimal adalah |f(x)| / ||w||_1.

Membangun Adversarial Example dengan NumPy #

Praktikum pertama melatih LogisticRegression pada 300 transaksi sintetis dua fitur, lalu melakukan tiga hal: menyapu robust accuracy pada beberapa nilai epsilon, memverifikasi rumus perturbasi L2 minimal secara eksak, dan membalik prediksi satu transaksi nyata dengan pergeseran yang lebih kecil dari seperempat simpangan baku fitur.

python
# Demonstrasi adversarial example terhadap LogisticRegression.
# Untuk model linear, gradien decision function terhadap input adalah
# koefisien w itu sendiri, sehingga perturbasi gaya FGSM (Fast Gradient
# Sign Method) dapat dihitung langsung dengan NumPy: delta = eps * sign(w).
# Kita juga menghitung perturbasi L2 minimal secara eksak, memverifikasi
# bahwa titik hasilnya jatuh tepat di decision boundary, lalu melihat
# bahwa sedikit tambahan epsilon sudah membalik prediksi.

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

# ---------- Data: transaksi dengan 2 fitur, kelas 0 = wajar, 1 = penipuan ----------
rng = np.random.default_rng(42)
n = 300

X_wajar = rng.normal(loc=[-1.2, -1.2], scale=1.05, size=(n // 2, 2))
X_penipuan = rng.normal(loc=[1.2, 1.2], scale=1.05, size=(n // 2, 2))
X = np.vstack([X_wajar, X_penipuan])
y = np.array([0] * (n // 2) + [1] * (n // 2))

X_latih, X_uji, y_latih, y_uji = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42
)

model = LogisticRegression().fit(X_latih, y_latih)
w = model.coef_[0]
b = model.intercept_[0]


def decision_function(M):
    """Logit mentah f(x) = w.x + b (positif = kelas 1)."""
    return M @ w + b


# ---------- Robust accuracy: perturbasi optimal per titik ----------
# Arah optimal untuk model linear: kelas 1 didorong ke bawah logit,
# kelas 0 didorong ke atas. sign(w) adalah arah kenaikan logit tercepat
# per satuan L-infinity, gaya FGSM.
print("== Robust accuracy terhadap perturbasi FGSM ==")
print(f"||w||_1 = {np.abs(w).sum():.3f} | ||w||_2 = {np.linalg.norm(w):.3f}")
arah = (1 - 2 * y_uji).astype(float)[:, None]  # +1 utk kelas 0, -1 utk kelas 1
print("epsilon | akurasi")
for eps in (0.0, 0.25, 0.5, 1.0, 2.0):
    X_adv = X_uji + eps * arah * np.sign(w)[None, :]
    akurasi = (model.predict(X_adv) == y_uji).mean()
    print(f"{eps:7.2f} | {akurasi:.3f}")

# ---------- Satu contoh konkret: perturbasi minimal yang membalik prediksi ----------
# Pilih titik uji kelas 1 dengan margin logit sedang (0.5 sampai 1.5).
kandidat = np.where((y_uji == 1) & (decision_function(X_uji) > 0.5)
                    & (decision_function(X_uji) < 1.5))[0]
i = kandidat[0]
x = X_uji[i]
f_x = float(decision_function(x[None, :])[0])
p_x = float(model.predict_proba(x[None, :])[0, 1])

# Perturbasi L2 minimal eksak: dorong sepanjang -w/||w||_2 sejauh f/||w||_2,
# lalu beri 20% tambahan agar benar-benar melintasasi boundary.
eps_l2 = f_x / np.linalg.norm(w)
x_tepat = x - eps_l2 * w / np.linalg.norm(w)
f_tepat = float(decision_function(x_tepat[None, :])[0])
x_adv = x - 1.2 * eps_l2 * w / np.linalg.norm(w)
f_adv = float(decision_function(x_adv[None, :])[0])
p_adv = float(model.predict_proba(x_adv[None, :])[0, 1])

# Versi FGSM: epsilon sejauh 1.2 * f/||w||_1 pada arah -sign(w).
eps_fgsm = 1.2 * f_x / np.abs(w).sum()
delta_fgsm = -eps_fgsm * np.sign(w)
x_fgsm = x + delta_fgsm

print("\n== Satu transaksi kelas penipuan di ambang batas ==")
print(f"x asli            : [{x[0]:+.3f}, {x[1]:+.3f}]")
print(f"logit f(x)        : {f_x:+.3f} | peluang penipuan {p_x:.3f}")
print(f"prediksi          : kelas {model.predict(x[None, :])[0]} (benar)")
print()
print("-- Teori: eps L2 minimal = f(x) / ||w||_2 --")
print(f"eps L2 minimal    : {eps_l2:.3f}")
print(f"logit di titik tepat batas : {f_tepat:+.3f} "
      f"(nol presisi, prediksi belum berubah)")
print(f"eps x 1.2         : {1.2 * eps_l2:.3f} | ||delta||_2 = "
      f"{1.2 * eps_l2:.3f}")
print(f"x adversarial     : [{x_adv[0]:+.3f}, {x_adv[1]:+.3f}]")
print(f"logit f(x_adv)    : {f_adv:+.3f} | peluang penipuan {p_adv:.3f}")
print(f"prediksi          : kelas {model.predict(x_adv[None, :])[0]} (salah)")
print()
print("-- Praktik: FGSM pada arah -sign(w) --")
print(f"eps FGSM (x 1.2)  : {eps_fgsm:.3f} | ||delta||_2 = "
      f"{np.linalg.norm(delta_fgsm):.3f}")
print(f"x FGSM            : [{x_fgsm[0]:+.3f}, {x_fgsm[1]:+.3f}]")
print(f"prediksi FGSM     : kelas {model.predict(x_fgsm[None, :])[0]} (salah)")
print()
print("skala fitur (std) :", np.round(X.std(axis=0), 3).tolist())
print("kesimpulan: pergeseran sekecil itu - sebagian kecil simpangan baku "
      "fitur - sudah memindahkan prediksi ke kelas yang salah")

Output yang Dihasilkan:

text
== Robust accuracy terhadap perturbasi FGSM ==
||w||_1 = 3.161 | ||w||_2 = 2.238
epsilon | akurasi
   0.00 | 0.978
   0.25 | 0.878
   0.50 | 0.767
   1.00 | 0.578
   2.00 | 0.200

== Satu transaksi kelas penipuan di ambang batas ==
x asli            : [-0.107, +0.267]
logit f(x)        : +0.712 | peluang penipuan 0.671
prediksi          : kelas 1 (benar)

-- Teori: eps L2 minimal = f(x) / ||w||_2 --
eps L2 minimal    : 0.318
logit di titik tepat batas : +0.000 (nol presisi, prediksi belum berubah)
eps x 1.2         : 0.382 | ||delta||_2 = 0.382
x adversarial     : [-0.362, -0.016]
logit f(x_adv)    : -0.142 | peluang penipuan 0.464
prediksi          : kelas 0 (salah)

-- Praktik: FGSM pada arah -sign(w) --
eps FGSM (x 1.2)  : 0.270 | ||delta||_2 = 0.382
x FGSM            : [-0.377, -0.003]
prediksi FGSM     : kelas 0 (salah)

skala fitur (std) : [1.589, 1.587]
kesimpulan: pergeseran sekecil itu - sebagian kecil simpangan baku fitur - sudah memindahkan prediksi ke kelas yang salah

Mari kita bedah bagian-bagian pentingnya:

  1. Baris 16–23 (data dan pelatihan): dua klaster transaksi dibangkitkan rng berseed 42; train_test_split memisahkan 90 baris uji. Model mencapai akurasi bersih 0,978 pada epsilon 0,00.
  2. Baris 25–30 (decision_function): fungsi kecil ini menghitung logit mentah M @ w + b — salinan eksak dari perhitungan internal LogisticRegression, sehingga semua rumus chapter ini bisa diverifikasi tanpa membongkar pustaka.
  3. Baris 37–42 (sapuan robust accuracy): arah memilih tanda per titik — kelas 1 didorong ke bawah logit, kelas 0 ke atas — lalu X_uji digeser eps * sign(w) per titik. Kolom akurasi runtuh dari 0,978 ke 0,200 saat epsilon naik ke 2,0: model yang tampak solid menjadi tidak bisa dipercaya di bawah gangguan terarah.
  4. Baris 45–49 (pemilihan titik uji): kandidat menyaring titik uji kelas 1 dengan margin logit antara 0,5 dan 1,5 — cukup yakin bagi model, cukup dekat ke boundary bagi attacker.
  5. Baris 56–62 (teori eksak): eps_l2 = f_x / ||w||_2 lalu titik digeser sepanjang -w/||w||_2. Keluaran logit di titik tepat batas : +0.000 memverifikasi rumus hingga presisi tiga desimal — titik hasil jatuh tepat di boundary, peluang 0,500.
  6. Baris 64–69 (FGSM): versi tanda delta = -eps * sign(w) dengan eps = 1.2 * f/||w||_1 membeli kebalikan yang sama dengan norma delta yang hampir identik (0,382) — pada koefisien yang hampir seimbang, FGSM praktis menyamai jalur L2 optimal.

Perhatikan pula skala relativnya: simpangan baku fitur sekitar 1,59, sedangkan ||delta||_2 pembalik prediksi hanya 0,382 — sekitar seperempat. Pada data berdimensi tinggi rasio ini makin ekstrem: dengan ratusan fitur, budget per koordinat menjadi sangat kecil, sehingga perubahan per fitur nyaris tak terdeteksi mata manusia, sementara akumulasi ratusan pergeseran searah sign(w) memindahkan logit jauh.

Pertahanan: Adversarial Training #

Pertahanan yang paling banyak dipelajari memakai mekanisme attacker itu sendiri sebagai bahan data latih. Adversarial training bekerja tiga langkah: hitung versi adversarial dari data latih dengan epsilon tetap, beri label asli (label tidak berubah — hanya posisi fitur yang digeser), gabungkan dengan data asli, lalu latih ulang. Model dipaksa membenarkan keputusan yang sama pada titik-titik yang sudah digeser ke arah paling merugikannya.

Praktikum kedua sengaja memakai data dengan dua fitur berskala berbeda — fitur kedua bervariasi sempit (simpangan baku 0,40) sehingga model standar meletakkan koefisien besar padanya. Pertanyaannya: apakah adversarial training menemukan ketimpangan itu sendiri?

python
# Demonstrasi adversarial training: data latih diperbesar dengan salinan
# adversarial (FGSM, epsilon tetap, label asli), lalu model dilatih ulang.
# Data ini sengaja memakai dua fitur dengan skala tidak seimbang -
# fitur kedua berstd kecil sehingga koefisiennya besar pada model standar.
# Model standar karenanya rapuh: menggeser fitur kedua sedikit saja sudah
# menggeser logit jauh. Adversarial training menemukan ketimpangan itu
# dari dalam dan menyeimbangkan kembali koefisiennya.

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

# ---------- Data: 2 fitur informatif tapi berskala berbeda ----------
# fitur_1: simpangan baku 1.05 | fitur_2: simpangan baku 0.40
rng = np.random.default_rng(42)
n = 300
std_fitur = np.array([1.05, 0.40])

X_wajar = rng.normal(loc=[-1.2, -0.45], scale=std_fitur, size=(n // 2, 2))
X_penipuan = rng.normal(loc=[1.2, 0.45], scale=std_fitur, size=(n // 2, 2))
X = np.vstack([X_wajar, X_penipuan])
y = np.array([0] * (n // 2) + [1] * (n // 2))

X_latih, X_uji, y_latih, y_uji = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42
)

EPS_LATIH = 0.4  # besar perturbasi FGSM saat membuat contoh adversarial latih


def robust_accuracy(modelnya, eps):
    """Akurasi pada X_uji setelah tiap titik digeser optimal menurut model."""
    w = modelnya.coef_[0]
    arah = (1 - 2 * y_uji).astype(float)[:, None]
    X_adv = X_uji + eps * arah * np.sign(w)[None, :]
    return (modelnya.predict(X_adv) == y_uji).mean()


# ---------- Model standar: dilatih hanya pada data asli ----------
model_standar = LogisticRegression().fit(X_latih, y_latih)

# ---------- Adversarial training: tiga putaran augmentasi FGSM ----------
# Tiap putaran: buat salinan adversarial dari SELURUH data latih menurut
# koefisien model saat ini, gabungkan (label asli dipertahankan), latih
# ulang dari nol.
model_at = LogisticRegression().fit(X_latih, y_latih)
for putaran in range(3):
    w = model_at.coef_[0]
    arah = (1 - 2 * y_latih).astype(float)[:, None]
    X_adv = X_latih + EPS_LATIH * arah * np.sign(w)[None, :]
    X_perbesar = np.vstack([X_latih, X_adv])
    y_perbesar = np.concatenate([y_latih, y_latih])
    model_at = LogisticRegression().fit(X_perbesar, y_perbesar)

# ---------- Perbandingan ----------
print("== Adversarial training pada LogisticRegression ==")
print(f"epsilon latih    : {EPS_LATIH} | fitur (std): "
      f"{std_fitur.tolist()}")
print()
print("model    | koefisien w         | akurasi bersih")
for nama, m in (("standar", model_standar), ("adv-train", model_at)):
    koef = np.round(m.coef_[0], 3).tolist()
    print(f"{nama:9s}| {str(koef):18s} | {m.score(X_uji, y_uji):.3f}")

# ---------- Biaya serangan per fitur: eps * |w_j| ----------
# Pergeseran logit maksimum lewat fitur ke-j jika attacker menggesernya
# sejauh eps adalah eps * |w_j|. Fitur dengan biaya termahal adalah
# jalur serangan termurah bagi attacker.
print()
print("== Jalur serangan termurah: kontribusi per fitur (eps = 0.5) ==")
print("fitur | std data | model standar | model adv-train")
w_std = model_standar.coef_[0]
w_at = model_at.coef_[0]
for j in range(2):
    print(f"{j + 1:6d} | {std_fitur[j]:8.2f} | eps*|w| = "
          f"{0.5 * abs(w_std[j]):5.3f}    | eps*|w| = {0.5 * abs(w_at[j]):.3f}")

# ---------- Kurva robustness ----------
print()
print("== Robust accuracy pada berbagai epsilon ==")
print("epsilon | standar | adv-train")
for eps in (0.0, 0.25, 0.5, 0.75, 1.0):
    print(f"{eps:7.2f} | {robust_accuracy(model_standar, eps):7.3f} | "
          f"{robust_accuracy(model_at, eps):.3f}")

Output yang Dihasilkan:

text
== Adversarial training pada LogisticRegression ==
epsilon latih    : 0.4 | fitur (std): [1.05, 0.4]

model    | koefisien w         | akurasi bersih
standar  | [1.483, 3.034]     | 0.956
adv-train| [1.487, 1.632]     | 0.944

== Jalur serangan termurah: kontribusi per fitur (eps = 0.5) ==
fitur | std data | model standar | model adv-train
     1 |     1.05 | eps*|w| = 0.742    | eps*|w| = 0.743
     2 |     0.40 | eps*|w| = 1.517    | eps*|w| = 0.816

== Robust accuracy pada berbagai epsilon ==
epsilon | standar | adv-train
   0.00 |   0.956 | 0.944
   0.25 |   0.767 | 0.811
   0.50 |   0.633 | 0.689
   0.75 |   0.478 | 0.500
   1.00 |   0.300 | 0.367

Bedah baris-per-baris untuk contoh kedua:

  1. Baris 14–27 (data berskala timpang): kedua fitur sama-sama informatif — pusat kelasnya berjarak kira-kira 2,3 simpangan baku pada masing-masing fitur — tetapi simpangan baku fitur kedua hanya 0,40. train_test_split dengan random_state=42 menjaga pembandingan adil.
  2. Baris 29–35 (robust_accuracy): fungsi penilaian menghitung arah serangan dari koefisien model yang sedang dinilai — model adv-train diuji dengan serangan yang disesuaikan terhadapnya, bukan serangan model lama.
  3. Baris 43–51 (tiga putaran): setiap putaran membaca model_at.coef_[0] terbaru, membangkitkan salinan adversarial seluruh data latih, lalu melatih ulang dari nol pada data gabungan 420 baris. Label y_perbesar mengulang label asli — hanya posisi titik yang digeser.
  4. Baris 61–63 (tabel koefisien): model standar menulis w = [1.483, 3.034] — mayoritas bobot jatuh pada fitur sempit. Model adv-train menulis [1.487, 1.632]: bobot fitur kedua terpangkas hampir separuh.
  5. Baris 70–76 (biaya serangan per fitur): kolom eps*|w| menerjemahkan koefisien menjadi daya beli attacker — menggeser fitur 2 sejauh 0,5 memindahkan logit 1,517 pada model standar, jalur termurah; setelah adversarial training jalur itu menjadi 0,816, hampir setara jalur fitur 1 (0,743).

Cara membaca hasilnya: akurasi bersih dibayar 0,956 → 0,944, dan robust accuracy naik di seluruh kurva epsilon (misalnya 0,633 → 0,689 pada 0,5; 0,300 → 0,367 pada 1,0). Kenaikan ini terlihat sederhana, tetapi mekanismenya yang penting: adversarial training menemukan sendiri bahwa fitur berskala sempit adalah pintu masuk termurah, lalu menyeimbangkan pertahanan — perilaku yang pada kasus ini juga bisa dicapai dengan StandardScaler, tetapi di sini dipelajari dari pola serangan, bukan dari asumsi preprocessing.

Batas Pertahanan dan Transferability #

Kejujuran angka di atas perlu dilanjutkan: pada model linear, kenaikan robustness dibatasi oleh jarak geometris antar kelas — titik yang benar-benar berada di wilayah tumpang tindih akan selalu bisa dibalik, oleh model apa pun. Laporan-literatur menunjukkan kenaikan yang jauh lebih besar pada jaringan dalam (deep networks), karena jaringan nonlinear punya ruang untuk melengkungkan boundary menjauhi titik data; implementasinya memakai pustaka differentiation otomatis seperti PyTorch — di luar cakupan lingkungan latihan kita, tetapi idenya identik dengan tiga putaran augmentasi di atas. Satu properti lain yang membuat evasion sulit: transferability — adversarial example yang dibuat terhadap satu model sering kali juga mengubah prediksi model lain yang dilatih pada data serupa, sehingga attacker tidak membutuhkan akses koefisien untuk memulai.

Tabel berikut merangkum keluarga pertahanan yang dikenal luas beserta posisinya.

Keluarga pertahananCara kerjaCatatan
Adversarial trainingMelatih ulang dengan salinan input tergangguPaling teruji; menaikkan robust accuracy, menurunkan akurasi bersih sedikit
Preprocessing / sanitasi inputMembersihkan atau meng-kompres input sebelum masuk modelMurah, tetapi bisa di-bypass jika preprocessing ikut dipelajari attacker
Deteksi input anomaliMenandai input yang dekat ke boundary atau ganjil secara statistikPelengkap, bukan pengganti — deteksi punya false positive sendiri
Ensemble beberapa modelPrediksi mayoritas menutup celah satu modelMembantu jika model beragam; transferability tetap menjadi risiko

Pertahanan bertemu batasnya pada satu titik: sekuat apa pun modelnya, sistem yang membocorkan informasi lewat output-nya tetap menyisakan ruang bagi dua kategori OWASP berikutnya — model inversion dan membership inference. Keduanya tidak menyerang keputusan, melainkan data pribadi di balik model. Kita beralih ke sana di Chapter Privasi Data dan Machine Learning.

Referensi Resmi #