Chapter sebelumnya menutup dengan baseline kepadatan Gaussian; sebelum melompat ke algoritme yang lebih canggih, kita berhenti di metode yang paling murah, paling cepat, dan paling mudah diaudit: ambang statistik pada satu kolom. Banyak sistem peninjauan transaksi di dunia nyata memang berjalan di atas aturan seperti “tandai jika nilai transaksi melebihi pagar IQR” — tanpa model, tanpa training, dan dapat dijelaskan ke auditor dalam satu kalimat. Di chapter ini kita membedah tiga aturan tersebut — z-score, pagar IQR, dan modified z-score dengan MAD — lalu membiarkan ketiganya berselisih di depan mata kita pada data transaksi TokoKita.

Z-Score: Aturan Tiga Sigma #

Z-score mengukur berapa simpangan baku sebuah nilai dari rata-rata kolomnya:

text
z = (x - mean) / std        anomali jika |z| > 3

Aturan |z| > 3 berasal dari distribusi normal: sekitar 99,7% massa probabilitas berada dalam ±3σ, sehingga nilai di luar pagar itu wajar dicurigai. Konsekuensinya juga jelas — aturan ini hanya sah jika kolomnya mendekati normal dan simetris. Pada data mencong ke kanan seperti nilai transaksi (banyak nilai kecil, sedikit nilai besar), mean tertarik ke ekor dan simpangan baku membengkak, sehingga pagar mean + 3σ bisa berpindah jauh dari kerumunan data.

Terdapat masalah yang lebih halus lagi: mean dan std dihitung dari data yang sudah mengandung anomali. Sembilan transaksi Rp1,5–4 juta cukup untuk menggandakan std seluruh kolom — anomali menarik pagar sehingga anomali lain yang lebih kecil lolos begitu saja. Efek ini disebut masking, dan menjadi alasan utama versi tahan-nya dibuat.

Pagar IQR dan Modified Z-Score #

Interquartile range (IQR) memakai kuantil ketimbang momen. Pagar Tukey menandai nilai di luar:

text
bawah = Q1 - 1.5 x IQR        atas = Q3 + 1.5 x IQR        (IQR = Q3 - Q1)

Kuantil praktis tidak bergeser oleh segelintir nilai ekstrem — menambah transaksi Rp4 juta tidak menggerakkan Q1 maupun Q3 — sehingga pagar IQR kebal masking. Versi ketiga, modified z-score, mengganti mean dengan median dan std dengan MAD (median absolute deviation):

text
mz = 0.6745 x (x - median) / MAD        anomali jika |mz| > 3.5

Konstanta 0.6745 adalah kuantil-75 distribusi normal standar; konstanta ini membuat mz berperilaku seperti z pada data normal, sehingga ambang 3.5 punya makna yang sebanding. Seperti IQR, median dan MAD tidak tergoyahkan oleh ekor data. Ketiganya tetap mengasumsikan sebaran yang kira-kira simetris — asumsi yang akan kita uji dan langgar secara terbuka sebentar lagi.

Praktik: Tiga Aturan pada Data Transaksi #

Kita pakai 309 transaksi TokoKita: 300 nilai normal yang menyerupai log-normal (median Rp120 ribu, ekor kanan sampai sekitar Rp400 ribu) ditambah sembilan suntikan Rp1,5–4 juta. Ketiga aturan dihitung pada kolom yang sama, lalu kita tanyakan tiga hal ke hasilnya: berapa anomali suntikan yang tertangkap, berapa transaksi normal yang salah ditandai, dan transaksi mana persisnya yang membuat para metode berselisih pendapat.

python
# Deteksi statistik univariat: z-score, pagar IQR, dan modified z-score (MAD)
# pada daftar nilai transaksi toko daring "TokoKita".

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)

# 1) 300 nilai transaksi normal (ribu rupiah), menyerupai log-normal:
#    mayoritas Rp80--180 ribu, ekor kanan panjang hingga ~Rp400 ribu.
nilai_normal = rng.lognormal(mean=np.log(120.0), sigma=0.35, size=300)

# 2) Sembilan transaksi ekstrem yang disuntik: Rp1,5--4 juta.
nilai_ekstrem = rng.uniform(low=1500.0, high=4000.0, size=9)

nilai = np.concatenate([nilai_normal, nilai_ekstrem])
injeksi = np.concatenate([np.zeros(300, dtype=int), np.ones(9, dtype=int)])
pelanggan = np.array(["Budi", "Siti", "Dewi"] * 103)[: len(nilai)]

df = pd.DataFrame({"nilai": nilai, "pelanggan": pelanggan, "injeksi": injeksi})

# 3) Z-score klasik: |z| > 3, mean dan std dihitung dari data yang
#    sudah mengandung anomali itu sendiri.
rata = df["nilai"].mean()
std = df["nilai"].std()
df["z"] = (df["nilai"] - rata) / std
df["flag_z"] = df["z"].abs() > 3

# 4) Pagar IQR: di luar [Q1 - 1.5*IQR, Q3 + 1.5*IQR].
q1, q3 = df["nilai"].quantile([0.25, 0.75])
iqr = q3 - q1
pagar_bawah = q1 - 1.5 * iqr
pagar_atas = q3 + 1.5 * iqr
df["flag_iqr"] = (df["nilai"] < pagar_bawah) | (df["nilai"] > pagar_atas)

# 5) Modified z-score: median dan MAD tahan terhadap nilai ekstrem.
median = df["nilai"].median()
mad = float((df["nilai"] - median).abs().median())
df["mz"] = 0.6745 * (df["nilai"] - median) / mad
df["flag_mad"] = df["mz"].abs() > 3.5

print("Statistik ambang (ribu rupiah):")
print(f"  mean = {rata:.1f}   std = {std:.1f}   median = {median:.1f}   MAD = {mad:.1f}")
print(f"  z-score  : ditandai jika |z| > 3  -> nilai > {rata + 3 * std:.1f}")
print(f"  IQR      : Q1 = {q1:.1f}, Q3 = {q3:.1f}, pagar atas = {pagar_atas:.1f}")
print(f"  MAD      : ditandai jika |mz| > 3.5 -> nilai > {median + 3.5 * mad / 0.6745:.1f}")

# 6) Bandingkan ketiganya terhadap anomali suntikan.
print("\nJumlah transaksi yang ditandai tiap metode:")
for kolom, nama_metode in [("flag_z", "z-score"), ("flag_iqr", "IQR"), ("flag_mad", "MAD")]:
    benar = int((df[kolom] & (df["injeksi"] == 1)).sum())
    salah = int((df[kolom] & (df["injeksi"] == 0)).sum())
    print(f"  {nama_metode:8s}: {int(df[kolom].sum()):>2d} total = {benar} anomali suntikan + {salah} transaksi normal")

# 7) Transaksi mana persisnya yang membuat metode berselisih?
selisih = df[df["flag_z"] != df["flag_iqr"]].sort_values("nilai")
print(f"\n{len(selisih)} transaksi ditandai berbeda oleh z-score vs IQR:")
print("  nilai_ribu  pelanggan  z      mz      z-score  IQR   MAD")
for baris in selisih.itertuples():
    print(
        f"  {baris.nilai:>9.1f}  {baris.pelanggan:<9s}  {baris.z:>5.2f}  {baris.mz:>6.2f}"
        f"  {'ya' if baris.flag_z else '-':>7s}  {'ya' if baris.flag_iqr else '-':>4s}"
        f"  {'ya' if baris.flag_mad else '-':>4s}"
    )

# 8) Perbaikan murah: pagar IQR dihitung pada skala log.
log_q1, log_q3 = df["nilai"].apply(np.log).quantile([0.25, 0.75])
log_pagar_atas = log_q3 + 1.5 * (log_q3 - log_q1)
flag_log = df["nilai"].apply(np.log) > log_pagar_atas
benar = int((flag_log & (df["injeksi"] == 1)).sum())
salah = int((flag_log & (df["injeksi"] == 0)).sum())
print(f"\nIQR pada skala log (pagar atas = Rp{np.exp(log_pagar_atas):.1f} ribu):")
print(f"  {int(flag_log.sum())} total = {benar} anomali suntikan + {salah} transaksi normal")

Output yang Dihasilkan:

text
Statistik ambang (ribu rupiah):
  mean = 201.6   std = 458.8   median = 118.3   MAD = 24.2
  z-score  : ditandai jika |z| > 3  -> nilai > 1577.9
  IQR      : Q1 = 95.4, Q3 = 145.1, pagar atas = 219.7
  MAD      : ditandai jika |mz| > 3.5 -> nilai > 244.1

Jumlah transaksi yang ditandai tiap metode:
  z-score :  9 total = 9 anomali suntikan + 0 transaksi normal
  IQR     : 21 total = 9 anomali suntikan + 12 transaksi normal
  MAD     : 16 total = 9 anomali suntikan + 7 transaksi normal

12 transaksi ditandai berbeda oleh z-score vs IQR:
  nilai_ribu  pelanggan  z      mz      z-score  IQR   MAD
      219.8  Budi        0.04    2.82        -    ya     -
      222.8  Dewi        0.05    2.91        -    ya     -
      228.2  Siti        0.06    3.06        -    ya     -
      231.5  Dewi        0.07    3.15        -    ya     -
      241.4  Budi        0.09    3.42        -    ya     -
      249.7  Budi        0.10    3.65        -    ya    ya
      252.8  Budi        0.11    3.74        -    ya    ya
      252.8  Siti        0.11    3.74        -    ya    ya
      253.9  Budi        0.11    3.77        -    ya    ya
      289.6  Siti        0.19    4.77        -    ya    ya
      331.7  Dewi        0.28    5.94        -    ya    ya
      332.7  Siti        0.29    5.97        -    ya    ya

IQR pada skala log (pagar atas = Rp272.2 ribu):
  12 total = 9 anomali suntikan + 3 transaksi normal

Mari kita bedah hasil eksekusinya:

  1. Baris 24–27 (z-score): df["nilai"].std() pada pandas memakai std sampel (pembagi n−1); nilainya 458,8 — hampir empat kali lebar data normal yang sesungguhnya, karena sembilan suntikan ikut dihitung. Pagar mean + 3σ jatuh di Rp1.577,9 ribu; kebetulan sembilan suntikan kita semua di atasnya, tetapi transaksi Rp1,55 juta akan lolos mulus — masking sedang bekerja di depan mata.
  2. Baris 30–34 (pagar IQR): pagar atas Rp219,7 ribu diperoleh murni dari kuantil data normal; sembilan suntikan tidak menggerakkan Q1/Q3 sedikit pun. Kekalahan masking dibayar dengan salah tanda: 12 transaksi normal ikut ditandai.
  3. Baris 37–40 (modified z-score): median 118,3 dan MAD 24,2 juga tak tergoyahkan; ambang setara Rp244,1 ribu, hasilnya 9 anomali + 7 salah tanda — di antara IQR dan z-score.
  4. Baris 55–64 (tabel perselisihan): inilah bagian paling jujur dari eksperimen ini. Transaksi Dewi senilai Rp331,7 ribu — belanja besar namun masih dalam ekor wajar log-normal — mencatat z hanya 0,28 (terlihat normal karena std sudah dirusak) tetapi mz 5,94. Dua metode yang mengaku mengukur hal yang sama memberi vonis berlawanan pada baris yang sama.
  5. Baris 66–73 (skala log): menghitung pagar pada np.log(nilai) mengubah pagar menjadi multiplikatif (Q3 dikali faktor 1,5×IQR-log); hasilnya 9 anomali + 3 salah tanda — presisi terbaik hari itu, hanya dengan satu baris transformasi.

Membaca Perselisihan sebagai Karakter Metode #

Ketiga metode tidak salah; ketiganya menunjukkan karakter aslinya pada data mencong:

MetodeStatistik dasarAsumsi bentukTahan maskingHasil pada data ini
Z-scoremean, stdnormal, simetrisTidak — pagar ikut tertarik ekor9 anomali, 0 salah, pagar membengkak ke Rp1,58 juta
Pagar IQRQ1, Q3simetrisYa9 anomali, 12 salah tandai
Modified z (MAD)median, MADsimetrisYa9 anomali, 7 salah tandai
IQR skala logkuantil logmencong kananYa9 anomali, 3 salah tandai

Kesimpulan praktisnya: pada data keuangan yang hampir selalu mencong ke kanan, terapkan transformasi log sebelum aturan apa pun, atau gunakan ambang pada kuantil tinggi (misalnya P99,5) yang maknanya mudah dikomunikasikan. Aturan univariat juga hanya melihat satu kolom sekaligus — pola “nilai besar + jumlah item kecil” dari chapter sebelumnya tak tertangkap kolom mana pun secara terpisah.

Ambang statistik menyelesaikan masalah satu kolom dengan jujur; masalah berikutnya adalah banyak kolom sekaligus, di mana kombinasi antar fitur yang menentukan keanehan. Untuk itu kita butuh algoritme yang mengisolasi, bukan mengukur: Chapter Isolation Forest memperkenalkan pendeteksi yang memotong data secara acak dan menghitung betapa cepatnya sebuah titik terpisahkan dari yang lain.

Referensi Resmi #