Bayangkan sistem deteksi penipuan pembayaran di Toko Kopi Kita: dari seluruh transaksi, hanya sebagian kecil yang benar-benar penipuan. Data seperti ini — disebut imbalanced atau timpang — adalah kondisi normal di deteksi penipuan, diagnosis penyakit langka, sampai prediksi pembatalan pesanan, bukan pengecualian. Pada kondisi inilah metrik favorit semua orang, akurasi, berubah menjadi penipu yang halus: model yang tidak pernah mendeteksi apa pun justru mencetak skor 95%. Pada chapter ini kita bedah jebakan akurasi pada data 95:5, lalu mengatasinya dengan tiga perkakas: pembacaan classification_report yang jujur, class_weight, dan SMOTE — plus satu kebenaran yang sering dilupakan: ambang keputusan 0,5 bukan hukum.

Jebakan Akurasi pada Data 95:5 #

Kita mulai dari ilustrasi paling telanjang. Terdapat 60 transaksi, tiga di antaranya (5%) adalah penipuan. Sekarang kita cek model paling naif yang bisa dibayangkan: selalu memprediksi “aman”, apa pun fiturnya.

python
import numpy as np
from sklearn.metrics import accuracy_score, classification_report

rng = np.random.default_rng(42)
n = 60
penipuan = np.zeros(n, dtype=int)
idx_penipu = rng.choice(n, size=3, replace=False)
penipuan[idx_penipu] = 1

prediksi_naif = np.zeros(n, dtype=int)
print("akurasi model naif:", round(accuracy_score(penipuan, prediksi_naif), 3))
print()
print(classification_report(penipuan, prediksi_naif, zero_division=0, digits=2))

Output yang Dihasilkan:

text
akurasi model naif: 0.95

              precision    recall  f1-score   support

           0       0.95      1.00      0.97        57
           1       0.00      0.00      0.00         3

    accuracy                           0.95        60
   macro avg       0.47      0.50      0.49        60
weighted avg       0.90      0.95      0.93        60

Angka 0,95 pada accuracy_score terlihat seperti model juara — padahal baris kelas 1 membongkar kenyataannya: precision 0,00, recall 0,00, f1-score 0,00. Model ini tidak menangkap satu penipuan pun; ia sekadar memihak kelas mayoritas yang berjumlah 57 dari 60. macro avg (0,47-0,50) jauh lebih jujur karena merata-ratakan kedua kelas sama besar, terlepas dari jumlah anggotanya. Pelajarannya: pada data timpang, akurasi mengukur kesepakatan dengan mayoritas, bukan kemampuan mendeteksi kelas yang justru menjadi alasan kita membangun model.

Membaca Ulang classification_report pada Model Sungguhan #

Model naif memang karikatur — bagaimana dengan model yang benar-benar belajar? Kita bangun data transaksi yang lebih realistis: 60 transaksi dengan fitur nilai transaksi (ribu rupiah) dan jam transaksi, enam di antaranya (10%) penipuan yang bernilai besar. Data dibagi dengan stratify — disiplin dari chapter Stratified Sampling — lalu regresi logistik dibiarkan bekerja dengan pengaturan bawaan.

python
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report

rng = np.random.default_rng(7)
n_normal = 54
nilai_normal = np.concatenate([
    rng.integers(15, 400, 44),
    rng.integers(200, 700, 10),
]).astype(float)
pukul_normal = np.concatenate([
    rng.integers(7, 23, 44),
    rng.integers(0, 24, 10),
])
nilai_penipu = rng.integers(250, 900, 6).astype(float)
pukul_penipu = rng.integers(0, 24, 6)
nilai = np.concatenate([nilai_normal, nilai_penipu])
pukul = np.concatenate([pukul_normal, pukul_penipu])
penipuan = np.concatenate([np.zeros(n_normal, int), np.ones(6, int)])
X = np.column_stack([nilai, pukul])

X_latih, X_uji, y_latih, y_uji = train_test_split(
    X, penipuan, test_size=20, stratify=penipuan, random_state=42)
print("latih:", len(y_latih), "baris,", y_latih.sum(), "penipuan | uji:",
      len(y_uji), "baris,", y_uji.sum(), "penipuan")

model = LogisticRegression(max_iter=1000).fit(X_latih, y_latih)
y_pred = model.predict(X_uji)
print(classification_report(y_uji, y_pred, target_names=["aman", "penipuan"],
                            zero_division=0, digits=2))

Output yang Dihasilkan:

text
latih: 40 baris, 4 penipuan | uji: 20 baris, 2 penipuan
              precision    recall  f1-score   support

        aman       0.90      1.00      0.95        18
    penipuan       0.00      0.00      0.00         2

    accuracy                           0.90        20
   macro avg       0.45      0.50      0.47        20
weighted avg       0.81      0.90      0.85        20

Hasilnya mengulang pola yang sama dengan model naif, hanya dengan baju lebih rapi: akurasi 0,90 (18 dari 20 benar), tetapi recall kelas penipuan tetap 0,00 — kedua penipuan di data uji lolos. Penyebabnya bukan model yang tidak cakap belajar, melainkan insentif yang timpang: saat pelatihan meminimalkan kesalahan keseluruhan, mengabaikan 4 sampel penipuan di data latih hanya berbiaya 10% — jauh lebih murah daripada mempelajari pola mereka dan berisiko salah menuduh transaksi sah.

class_weight=“balanced”: Memberi Bobot pada Kelas Langka #

Cara pertama memperbaikinya tidak menyentuh data sama sekali. class_weight="balanced" menyuruh pelatihan menimbang ulang kontribusi tiap kelas berbanding terbalik dengan frekuensinya: kesalahan pada 4 sampel penipuan diperhitungkan semahal kesalahan pada 36 sampel sah, sehingga model tidak lagi boleh mengabaikan kelas langka.

python
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
                             f1_score)

rng = np.random.default_rng(7)
n_normal = 54
nilai_normal = np.concatenate([
    rng.integers(15, 400, 44),
    rng.integers(200, 700, 10),
]).astype(float)
pukul_normal = np.concatenate([
    rng.integers(7, 23, 44),
    rng.integers(0, 24, 10),
])
nilai_penipu = rng.integers(250, 900, 6).astype(float)
pukul_penipu = rng.integers(0, 24, 6)
nilai = np.concatenate([nilai_normal, nilai_penipu])
pukul = np.concatenate([pukul_normal, pukul_penipu])
penipuan = np.concatenate([np.zeros(n_normal, int), np.ones(6, int)])
X = np.column_stack([nilai, pukul])

X_latih, X_uji, y_latih, y_uji = train_test_split(
    X, penipuan, test_size=20, stratify=penipuan, random_state=42)

baris = []
for label, bobot in [("bawaan", None), ("balanced", "balanced")]:
    model = LogisticRegression(class_weight=bobot, max_iter=1000)
    model.fit(X_latih, y_latih)
    y_pred = model.predict(X_uji)
    baris.append({
        "class_weight": label,
        "akurasi": round(accuracy_score(y_uji, y_pred), 3),
        "presisi_penipuan": round(precision_score(y_uji, y_pred, zero_division=0), 3),
        "recall_penipuan": round(recall_score(y_uji, y_pred, zero_division=0), 3),
        "f1_penipuan": round(f1_score(y_uji, y_pred, zero_division=0), 3),
    })

print(pd.DataFrame(baris).to_string(index=False))

Output yang Dihasilkan:

text
class_weight  akurasi  presisi_penipuan  recall_penipuan  f1_penipuan
      bawaan     0.90               0.0              0.0        0.000
    balanced     0.95               1.0              0.5        0.667

Perbandingannya menarik: dengan satu argumen, recall penipuan naik dari 0,0 ke 0,5 (satu dari dua penipuan tertangkap) tanpa satu pun salah tuduh (presisi 1,0), dan akurasi justru ikut naik ke 0,95. F1 kelas penipuan melompat dari 0,000 ke 0,667. Model yang sama, data yang sama — yang berubah hanyalah seberapa mahal pelatihan menghargai kesalahan pada kelas langka.

SMOTE: Menambah Sampel Sintetis, Hanya pada Data Latih #

Cara kedua menyeimbangkan lewat data: oversampling. SMOTE (Synthetic Minority Over-sampling Technique) dari pustaka imbalanced-learn membuat sampel penipuan baru dengan cara menghubungkan dua sampel penipuan terdekat lalu menarik titik acak di sepanjang garis penghubungnya — bukan sekadar menyalin baris lama. Aturan emasnya satu: SMOTE hanya boleh menyentuh data latih, karena sampel sintetis yang bocor ke data uji membuat evaluasi berbohong.

python
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
                             f1_score)
from imblearn.over_sampling import SMOTE

rng = np.random.default_rng(7)
n_normal = 54
nilai_normal = np.concatenate([
    rng.integers(15, 400, 44),
    rng.integers(200, 700, 10),
]).astype(float)
pukul_normal = np.concatenate([
    rng.integers(7, 23, 44),
    rng.integers(0, 24, 10),
])
nilai_penipu = rng.integers(250, 900, 6).astype(float)
pukul_penipu = rng.integers(0, 24, 6)
nilai = np.concatenate([nilai_normal, nilai_penipu])
pukul = np.concatenate([pukul_normal, pukul_penipu])
penipuan = np.concatenate([np.zeros(n_normal, int), np.ones(6, int)])
X = np.column_stack([nilai, pukul])

X_latih, X_uji, y_latih, y_uji = train_test_split(
    X, penipuan, test_size=20, stratify=penipuan, random_state=42)

smote = SMOTE(random_state=42, k_neighbors=3)
X_latih_smt, y_latih_smt = smote.fit_resample(X_latih, y_latih)

print("komposisi data latih sebelum SMOTE:", np.bincount(y_latih).tolist())
print("komposisi data latih sesudah SMOTE:", np.bincount(y_latih_smt).tolist())
print("komposisi data uji (tidak tersentuh):", np.bincount(y_uji).tolist())

model = LogisticRegression(max_iter=1000).fit(X_latih_smt, y_latih_smt)
y_pred = model.predict(X_uji)
print("akurasi        :", round(accuracy_score(y_uji, y_pred), 3))
print("presisi penipuan:", round(precision_score(y_uji, y_pred, zero_division=0), 3))
print("recall penipuan :", round(recall_score(y_uji, y_pred, zero_division=0), 3))
print("f1 penipuan     :", round(f1_score(y_uji, y_pred, zero_division=0), 3))

Output yang Dihasilkan:

text
komposisi data latih sebelum SMOTE: [36, 4]
komposisi data latih sesudah SMOTE: [36, 36]
komposisi data uji (tidak tersentuh): [18, 2]
akurasi        : 1.0
presisi penipuan: 1.0
recall penipuan : 1.0
f1 penipuan     : 1.0

Bukti di output adalah inti disiplinnya: data latih berubah dari [36, 4] menjadi [36, 36] — 32 sampel penipuan sintetis ditambahkan — sementara data uji tetap [18, 2] karena fit_resample() hanya menerima X_latih dan y_latih. Hasil evaluasinya kali ini sempurna: kedua penipuan tertangkap tanpa salah tuduh. Namun jangan buru-buru merayakannya — data uji kita hanya berisi dua penipuan; satu-dua keputusan benar sudah cukup mengisi skor penuh. Skor sempurna pada data uji sekecil ini justru wajib dicurigai dan diperiksa ulang, misalnya dengan cross-validation, sebelum dipercaya.

Ambang 0,5 Bukan Hukum #

Perhatikan kembali model bawaan yang gagal total di blok kedua. Sebenarnya, model itu memang memberi probabilitas tertinggi kepada kedua penipuan — masalahnya, probabilitas tersebut (0,161 dan 0,120) berada di bawah ambang bawaan 0,5 yang dipakai predict(). Mari kita buktikan bahwa menggeser ambang mengubah segalanya:

python
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score

rng = np.random.default_rng(7)
n_normal = 54
nilai_normal = np.concatenate([
    rng.integers(15, 400, 44),
    rng.integers(200, 700, 10),
]).astype(float)
pukul_normal = np.concatenate([
    rng.integers(7, 23, 44),
    rng.integers(0, 24, 10),
])
nilai_penipu = rng.integers(250, 900, 6).astype(float)
pukul_penipu = rng.integers(0, 24, 6)
nilai = np.concatenate([nilai_normal, nilai_penipu])
pukul = np.concatenate([pukul_normal, pukul_penipu])
penipuan = np.concatenate([np.zeros(n_normal, int), np.ones(6, int)])
X = np.column_stack([nilai, pukul])

X_latih, X_uji, y_latih, y_uji = train_test_split(
    X, penipuan, test_size=20, stratify=penipuan, random_state=42)

model = LogisticRegression(max_iter=1000).fit(X_latih, y_latih)
prob = model.predict_proba(X_uji)[:, 1]
print("probabilitas penipuan di data uji (terurut):")
print(np.round(np.sort(prob)[::-1], 3).tolist())

baris = []
for ambang in [0.05, 0.10, 0.15, 0.30, 0.50]:
    y_pred = (prob >= ambang).astype(int)
    baris.append({
        "ambang": ambang,
        "jumlah_alarm": int(y_pred.sum()),
        "presisi": round(precision_score(y_uji, y_pred, zero_division=0), 3),
        "recall": round(recall_score(y_uji, y_pred, zero_division=0), 3),
    })

print()
print(pd.DataFrame(baris).to_string(index=False))

Output yang Dihasilkan:

text
probabilitas penipuan di data uji (terurut):
[0.161, 0.12, 0.098, 0.079, 0.075, 0.069, 0.06, 0.055, 0.05, 0.031, 0.029, 0.015, 0.013, 0.013, 0.012, 0.008, 0.008, 0.007, 0.007, 0.007]

 ambang  jumlah_alarm  presisi  recall
   0.05             9    0.222     1.0
   0.10             2    1.000     1.0
   0.15             1    1.000     0.5
   0.30             0    0.000     0.0
   0.50             0    0.000     0.0

Dua pengamatan penting. Pertama, daftar probabilitas terurut menempatkan 0,161 dan 0,120 — kedua penipuan — di dua posisi teratas, terpisah jelas dari transaksi sah berikutnya (0,098). Model bawaan sebenarnya sudah menyelesaikan tugas belajarnya; ambang bawaan 0,5-lah yang tidak cocok untuk kelas langka. Kedua, tabel ambang memperlihatkan medan tarik-menariknya: ambang 0,10 menangkap kedua penipuan tanpa salah lapor, ambang 0,05 juga menangkap keduanya tetapi dengan 9 alarm (7 di antaranya salah — presisi 0,222), sedangkan ambang 0,3 ke atas melepas semuanya. Pilihan ambang adalah keputusan bisnis: bila satu penipuan lolos jauh lebih mahal daripada satu pemeriksaan manual, ambang rendah layak dipertimbangkan; bila salah tuduh merusak pengalaman pelanggan, naikkan ambangnya.

Rangkuman #

  • Pada data 95:5, model yang selalu memprediksi kelas mayoritas mencetak akurasi 95% sambil gagal total mendeteksi kelas minoritas — akurasi sendirian tidak cukup.
  • classification_report wajib dibaca per kelas; macro avg dan f1 kelas minoritas jauh lebih jujur daripada accuracy.
  • class_weight="balanced" membuat kesalahan pada kelas langka terasa mahal saat pelatihan — tanpa mengubah data; pada contoh kita recall naik 0,0 → 0,5 dengan presisi 1,0.
  • SMOTE (imbalanced-learn) menambah sampel sintetis kelas minoritas hanya pada data latih; data uji wajib tetap mewakili dunia nyata yang timpang.
  • Ambang 0,5 hanyalah bawaan, bukan hukum — geser ambang sesuai biaya relatif false positive dan false negative, dengan kurva precision-recall sebagai peta.

Kedua penipuan kini tertangkap, tetapi pekerjaan kita belum selesai: model mana pun yang dipilih tetap melakukan kesalahan, dan kesalahan itulah yang menyimpan petunjuk perbaikan. Chapter berikutnya, Analisis Kesalahan Model, membaca baris-baris yang salah prediksi untuk menemukan pola perbaikannya.

Referensi #

  1. imbalanced-learn — dokumentasi
  2. imbalanced-learn — SMOTE
  3. scikit-learn — Metrics and scoring: model evaluation