Setiap proyek deteksi anomali dimulai dari pertanyaan yang berlawanan dengan proyek klasifikasi: alih-alih mempelajari apa yang membedakan kelas dari contoh berlabel, kita mempelajari seperti apa transaksi normal itu, lalu menandai yang jauh dari kebiasaan tersebut. Agar penyimpangan punya acuan, dataset sintetis 5.000 transaksi dibangun dengan struktur normal yang kuat — jam belanja sibuk, nominal dan durasi sebanding item — sebelum 60 anomali ditanam di posisi acak. Kolom is_fraud ikut ditulis ke CSV namun disegel: eksplorasi ini, aturan Bagian 2, dan model Bagian 3 sama-sama dilarang membacanya. Bagian ini menutup dengan dua temuan untuk rekayasa fitur model: kemiringan nominal dan hubungan durasi-item.

Membangkitkan 5.000 Transaksi Deterministik #

Pembangkit memakai satu generator np.random.default_rng(42) — bukan fungsi acak global — sehingga setiap eksekusi menghasilkan CSV identik bit demi bit. Jam transaksi ditarik dari distribusi berbobot dengan puncak sore hari dan lembah nyaris kosong pada jam 02–05; jumlah item mengikuti Poisson; harga satuan normal di sekitar 90 ribu rupiah; nominal menjadi perkalian item dan harga ditambah derau kecil. Durasi checkout sengaja dibuat sebanding item — rumus (40 + 45 × item) detik dikali derau lognormal — karena struktur itulah yang membuat anomali tipe C bisa dikenali sebagai pelanggaran hubungan, bukan sekadar nilai rendah.

Tiga tipe anomali masing-masing 20 baris ditanam pada posisi acak pilihan generator. Tipe A bernilai ekstrem 3.500–8.500 ribu pada jam sibuk dengan item sedikit; tipe B berdagang di jam 02–05 dengan nominal dan durasi yang menyamar normal; tipe C berbelanja banyak item (4–8) namun menyelesaikan checkout dalam 25–70 detik — jauh di bawah wajar untuk keranjang sebesar itu. Pembedanya bertingkat: nominal menyingkap tipe A, jam menyingkap tipe B, dan hanya kombinasi fitur yang menyingkap tipe C.

python
# Bangkitkan dataset transaksi e-commerce sintetis untuk proyek deteksi
# anomali. Deterministik penuh: satu generator np.random.default_rng(42)
# menentukan seluruh keacakan sehingga siapa pun yang menjalankan skrip
# mendapat CSV identik bit demi bit. Jalankan dari root proyek:
# python3 src/01_bangun_data.py
# Keluaran: data/transaksi.csv (5000 baris, 8 kolom).
#
# Struktur kolom:
#   id_transaksi      - nomor urut 1..5000 (hanya penanda, bukan fitur).
#   nominal_ribu      - nilai transaksi dalam ribu rupiah.
#   jam_transaksi     - jam 0..23 mengikuti pola sibuk belanja.
#   menit             - menit 0..59, seragam (deru tanpa sinyal).
#   hari_dalam_minggu - 0..6, Senin..Minggu, seragam.
#   durasi_detik      - lama sesi checkout dalam detik; normalnya
#                       membesar mengikuti jumlah item.
#   jumlah_item       - banyaknya item per transaksi.
#   is_fraud          - label 0/1, HANYA untuk evaluasi Bagian 4;
#                       skrip deteksi (02-04) tidak boleh membacanya.
#
# Tiga tipe anomali yang ditanam (total 60 dari 5000 baris = 1,2%):
#   A. nominal ekstrem : 20 transaksi bernilai jauh di atas populasi.
#   B. jam dini hari   : 20 transaksi pada jam 02..05 yang sepi.
#   C. checkout kilat  : 20 transaksi banyak item dengan durasi jauh
#                        terlalu cepat untuk sebanyak itu item - pola
#                        yang tidak wajar hanya pada kombinasi fitur.

import os

import numpy as np
import pandas as pd

SEED = 42
N_NORMAL = 4940
N_ANOMALI = 60
N_TOTAL = N_NORMAL + N_ANOMALI
NAMA_BERKAS = "data/transaksi.csv"

rng = np.random.default_rng(SEED)

# ---------- Populasi normal ----------
# Bobot jam belanja: siang hingga malam sibuk, jam 02..05 nyaris kosong.
BOBOT_JAM = np.array(
    [
        0.5, 0.4, 0.15, 0.12, 0.15, 0.4, 1.5, 3.5, 6.0, 9.0,
        12.0, 13.0, 13.0, 12.0, 11.0, 11.0, 12.0, 13.0, 14.0, 13.0,
        10.0, 7.0, 4.0, 1.8,
    ]
)
jam_n = rng.choice(24, size=N_NORMAL, p=BOBOT_JAM / BOBOT_JAM.sum())
hari_n = rng.integers(0, 7, size=N_NORMAL)
menit_n = rng.integers(0, 60, size=N_NORMAL)
item_n = 1 + rng.poisson(1.8, size=N_NORMAL)
harga_n = rng.normal(90.0, 18.0, size=N_NORMAL)
nominal_n = np.clip(
    item_n * harga_n + rng.normal(0.0, 10.0, size=N_NORMAL), 20.0, None
)
# Durasi normal sebanding jumlah item: (40 + 45*item) detik kali derau
# lognormal 0,20 - struktur inilah yang dilanggar tipe C.
durasi_n = np.round(
    (40.0 + 45.0 * item_n) * rng.lognormal(0.0, 0.20, size=N_NORMAL)
).astype(int)

# ---------- Tipe A: nominal ekstrem (20) ----------
jam_a = rng.choice(np.arange(10, 22), size=20)          # jam sibuk
nominal_a = rng.uniform(3500.0, 8500.0, size=20)        # jauh di atas populasi
item_a = rng.integers(1, 3, size=20)
durasi_a = np.round(
    (40.0 + 45.0 * item_a) * rng.lognormal(0.0, 0.20, size=20)
).astype(int)

# ---------- Tipe B: jam dini hari 02..05 (20) ----------
jam_b = rng.integers(2, 6, size=20)
nominal_b = np.clip(rng.normal(280.0, 90.0, size=20), 60.0, None)
item_b = 1 + rng.poisson(1.8, size=20)
durasi_b = np.round(
    (40.0 + 45.0 * item_b) * rng.lognormal(0.0, 0.20, size=20)
).astype(int)

# ---------- Tipe C: checkout kilat (20) ----------
jam_c = rng.choice(np.arange(10, 22), size=20)          # jam sibuk
nominal_c = rng.uniform(350.0, 700.0, size=20)          # wajar di mata aturan nominal
item_c = rng.integers(4, 9, size=20)                    # item banyak
durasi_c = rng.integers(25, 71, size=20)                # terlalu cepat untuk sebanyak itu item

# ---------- Tempatkan 60 anomali pada posisi acak ----------
posisi = np.sort(rng.choice(N_TOTAL, size=N_ANOMALI, replace=False))
mask_anomali = np.zeros(N_TOTAL, dtype=bool)
mask_anomali[posisi] = True
idx_normal = np.flatnonzero(~mask_anomali)
p_a, p_b, p_c = posisi[:20], posisi[20:40], posisi[40:60]

nominal = np.empty(N_TOTAL)
jam = np.empty(N_TOTAL, dtype=int)
hari = np.empty(N_TOTAL, dtype=int)
menit = np.empty(N_TOTAL, dtype=int)
item = np.empty(N_TOTAL, dtype=int)
durasi = np.empty(N_TOTAL, dtype=int)

nominal[idx_normal] = nominal_n
jam[idx_normal] = jam_n
hari[idx_normal] = hari_n
menit[idx_normal] = menit_n
item[idx_normal] = item_n
durasi[idx_normal] = durasi_n

hari_a = rng.integers(0, 7, size=20)
menit_a = rng.integers(0, 60, size=20)
hari_b = rng.integers(0, 7, size=20)
menit_b = rng.integers(0, 60, size=20)
hari_c = rng.integers(0, 7, size=20)
menit_c = rng.integers(0, 60, size=20)

nominal[p_a], jam[p_a], hari[p_a], menit[p_a], item[p_a], durasi[p_a] = (
    nominal_a, jam_a, hari_a, menit_a, item_a, durasi_a,
)
nominal[p_b], jam[p_b], hari[p_b], menit[p_b], item[p_b], durasi[p_b] = (
    nominal_b, jam_b, hari_b, menit_b, item_b, durasi_b,
)
nominal[p_c], jam[p_c], hari[p_c], menit[p_c], item[p_c], durasi[p_c] = (
    nominal_c, jam_c, hari_c, menit_c, item_c, durasi_c,
)

df = pd.DataFrame(
    {
        "id_transaksi": np.arange(1, N_TOTAL + 1),
        "nominal_ribu": np.round(nominal, 1),
        "jam_transaksi": jam,
        "menit": menit,
        "hari_dalam_minggu": hari,
        "durasi_detik": durasi,
        "jumlah_item": item,
        "is_fraud": mask_anomali.astype(int),
    }
)

os.makedirs("data", exist_ok=True)
df.to_csv(NAMA_BERKAS, index=False)

# ---------- Ringkasan untuk pemeriksaan ----------
print("== Ringkasan pembangkitan data ==")
print(f"jumlah baris : {len(df)}")
print(f"kolom        : {list(df.columns)}")
print(f"berkas       : {NAMA_BERKAS}")
print(f"is_fraud = 1 : {int(df['is_fraud'].sum())} baris "
      f"({df['is_fraud'].mean():.1%} dari data)")
print(f"nominal      : mean {df['nominal_ribu'].mean():.1f} ribu, "
      f"median {df['nominal_ribu'].median():.1f} ribu, "
      f"maks {df['nominal_ribu'].max():.1f} ribu")
print(f"durasi       : mean {df['durasi_detik'].mean():.1f} s, "
      f"median {df['durasi_detik'].median():.0f} s")

print("\n== Profil tiga tipe anomali yang ditanam ==")
print(f"{'tipe':30s} {'baris':>5s} {'nominal rata2':>14s} {'jam':>12s} "
      f"{'item rata2':>11s} {'durasi rata2':>13s}")
print(f"{'A. nominal ekstrem':30s} {20:5d} {nominal_a.mean():14.1f} "
      f"{'10..21':>12s} {item_a.mean():11.1f} {durasi_a.mean():13.1f}")
print(f"{'B. jam dini hari (02-05)':30s} {20:5d} {nominal_b.mean():14.1f} "
      f"{'02..05':>12s} {item_b.mean():11.1f} {durasi_b.mean():13.1f}")
print(f"{'C. checkout kilat':30s} {20:5d} {nominal_c.mean():14.1f} "
      f"{'10..21':>12s} {item_c.mean():11.1f} {durasi_c.mean():13.1f}")

print("\n== Lima baris pertama ==")
print(df.head(5).to_string(index=False))

Output yang Dihasilkan:

text
== Ringkasan pembangkitan data ==
jumlah baris : 5000
kolom        : ['id_transaksi', 'nominal_ribu', 'jam_transaksi', 'menit', 'hari_dalam_minggu', 'durasi_detik', 'jumlah_item', 'is_fraud']
berkas       : data/transaksi.csv
is_fraud = 1 : 60 baris (1.2% dari data)
nominal      : mean 278.9 ribu, median 232.1 ribu, maks 8268.3 ribu
durasi       : mean 168.1 s, median 157 s

== Profil tiga tipe anomali yang ditanam ==
tipe                           baris  nominal rata2          jam  item rata2  durasi rata2
A. nominal ekstrem                20         6111.6       10..21         1.1          97.5
B. jam dini hari (02-05)          20          290.3       02..05         2.8         172.7
C. checkout kilat                 20          507.7       10..21         6.5          42.5

== Lima baris pertama ==
 id_transaksi  nominal_ribu  jam_transaksi  menit  hari_dalam_minggu  durasi_detik  jumlah_item  is_fraud
            1         151.3             18     48                  2           133            2         0
            2         230.5             14     45                  6           153            3         0
            3         376.1             19     17                  2           178            4         0
            4         412.2             17     44                  0           169            4         0
            5         473.0              9     43                  1           454            6         0

Mari kita bedah skrip pembangkit data baris-per-baris:

  1. Baris 32–36 (konstanta): SEED = 42, 4.940 transaksi normal, 60 anomali — tanpa jam sistem, CSV identik di mesin mana pun.
  2. Baris 42–48 (BOBOT_JAM): dua puluh empat bobot jam belanja; jam 18 paling ramai (bobot 14), jam 03 tersepi (0,12).
  3. Baris 49–61 (populasi normal): jam berbobot, hari dan menit seragam, item Poisson, harga normal, nominal perkalian item-harga, durasi (40 + 45 × item) kali derau lognormal 0,20.
  4. Baris 63–69 (tipe A): nominal seragam 3.500–8.500 ribu pada jam sibuk — jauh di atas maksimum populasi sah (sekitar 1.100 ribu).
  5. Baris 71–77 (tipe B): jam 02–05 dengan nominal normal(280, 90) — semua fitur lain menyamar normal.
  6. Baris 79–83 (tipe C): item 4–8 dengan durasi 25–70 detik; nominal 350–700 ribu sengaja lolos dari aturan nominal.
  7. Baris 86–90 (posisi acak): rng.choice tanpa penggantian memilih 60 posisi yang dibagi menjadi tiga kelompok 20 baris.
  8. Baris 92–121 (perakitan array): keenam array diisi nilai normal, lalu tiga kelompok posisi ditimpa nilai anomali — lokasinya tersebar, bukan menumpuk di ekor berkas.
  9. Baris 123–137 (tulis CSV): delapan kolom termasuk is_fraud hasil mask_anomali.astype(int) — label audit yang disegel sampai Bagian 4.
  10. Baris 140–163 (ringkasan): profil rata-rata ketiga tipe dicetak sebagai bukti desain.

Membaca Pola dari Angka Tanpa Label #

Eksplorasi bekerja sebagaimana analis nyata: kolom label dijatuhkan lebih dulu. Gerbang kualitas memeriksa nilai hilang, id ganda, dan rentang kolom agar kesalahan data tidak dikira anomali. Distribusi transaksi per jam memetakan kebiasaan belanja. Statistik bentuk mengukur kemiringan nominal serta hubungan durasi terhadap item.

python
# Eksplorasi data transaksi e-commerce: muat data/transaksi.csv, lewati
# gerbang kualitas, lalu baca pola dari angka TANPA menyentuh kolom
# is_fraud (deteksi proyek ini unsupervised; label hanya dibuka di
# Bagian 4 untuk evaluasi). Fokus eksplorasi: distribusi transaksi dan
# nominal per jam (pola jam sibuk), statistik ringkas tiap fitur,
# kemiringan (skewness) nominal dan durasi, hubungan durasi vs jumlah
# item, serta transaksi bernilai terbesar. Jalankan dari root proyek:
# python3 src/02_eksplorasi.py

import numpy as np
import pandas as pd

df = pd.read_csv("data/transaksi.csv")

# Kolom label is_fraud sengaja dijatuhkan: eksplorasi, aturan statistik,
# dan model semuanya bekerja tanpa label (unsupervised).
fitur_df = df.drop(columns=["id_transaksi", "is_fraud"])

# ---------- Gerbang kualitas ----------
print("== Muat data ==")
print(f"bentuk tabel : {len(df)} baris x {df.shape[1]} kolom")
print(f"nilai hilang : {int(fitur_df.isna().sum().sum())}")
print(f"id ganda     : {int(df['id_transaksi'].duplicated().sum())}")
rentang_ok = (
    df["jam_transaksi"].between(0, 23).all()
    and df["menit"].between(0, 59).all()
    and df["hari_dalam_minggu"].between(0, 6).all()
    and (df["durasi_detik"] > 0).all()
    and (df["jumlah_item"] >= 1).all()
    and (df["nominal_ribu"] > 0).all()
)
print(f"rentang nilai: {'semua kolom di rentang wajar' if rentang_ok else 'ADA NILAI DI LUAR RENTANG'}")
print("kolom label  : is_fraud dijatuhkan (hanya untuk evaluasi Bagian 4)")

# ---------- Statistik ringkas per fitur ----------
print("\n== Statistik ringkas enam fitur ==")
print(f"{'fitur':18s} {'mean':>9s} {'std':>8s} {'min':>8s} {'median':>8s} {'maks':>9s}")
for kolom in fitur_df.columns:
    s = fitur_df[kolom]
    print(f"{kolom:18s} {s.mean():9.1f} {s.std():8.1f} {s.min():8.0f} "
          f"{s.median():8.0f} {s.max():9.0f}")

# ---------- Distribusi transaksi dan nominal per jam ----------
print("\n== Distribusi transaksi per jam (batang = jumlah, 1 '#' ~ 10 transaksi) ==")
print(f"{'jam':>4s} {'jumlah':>7s} {'nominal rata2':>14s} {'batang'}")
for j in range(24):
    sel = df["jam_transaksi"] == j
    jumlah = int(sel.sum())
    rata = df.loc[sel, "nominal_ribu"].mean()
    print(f"{j:4d} {jumlah:7d} {rata:14.1f} {'#' * round(jumlah / 10)}")

malam = df["jam_transaksi"].between(2, 5)
sibuk = df["jam_transaksi"].between(10, 21)
print(f"jam 02..05    : {int(malam.sum())} transaksi "
      f"({malam.mean():.2%} dari data)")
print(f"jam 10..21    : {int(sibuk.sum())} transaksi "
      f"({sibuk.mean():.2%} dari data)")
tersepi = df.loc[malam, "jam_transaksi"].value_counts().sort_index()
print(f"jam tersepi   : {tersepi.idxmin()} (hanya {int(tersepi.min())} transaksi)")

# ---------- Kemiringan fitur ----------
print("\n== Kemiringan (skewness) fitur utama ==")
for kolom in ["nominal_ribu", "durasi_detik"]:
    print(f"{kolom:14s}: skewness {df[kolom].skew():+6.2f} "
          f"(median {df[kolom].median():.0f} vs mean {df[kolom].mean():.0f})")
print(f"{'log1p nominal':14s}: skewness {np.log1p(df['nominal_ribu']).skew():+6.2f}")
print("-> ekor kanan panjang; transformasi log akan dipakai model Bagian 3")

# ---------- Hubungan durasi vs jumlah item ----------
print("\n== Durasi checkout menurut jumlah item ==")
print(f"{'item':>5s} {'n':>5s} {'durasi rata2':>13s} {'durasi min':>11s} {'nominal rata2':>14s}")
for i in range(1, 9):
    sel = df["jumlah_item"] == i
    if int(sel.sum()) == 0:
        continue
    print(f"{i:5d} {int(sel.sum()):5d} {df.loc[sel, 'durasi_detik'].mean():13.1f} "
          f"{df.loc[sel, 'durasi_detik'].min():11d} {df.loc[sel, 'nominal_ribu'].mean():14.1f}")

# ---------- Nominal terbesar ----------
print("\n== Lima transaksi bernilai terbesar ==")
atas = df.nlargest(5, "nominal_ribu")[
    ["id_transaksi", "nominal_ribu", "jam_transaksi", "durasi_detik", "jumlah_item"]
]
print(atas.to_string(index=False))

Output yang Dihasilkan:

text
== Muat data ==
bentuk tabel : 5000 baris x 8 kolom
nilai hilang : 0
id ganda     : 0
rentang nilai: semua kolom di rentang wajar
kolom label  : is_fraud dijatuhkan (hanya untuk evaluasi Bagian 4)

== Statistik ringkas enam fitur ==
fitur                   mean      std      min   median      maks
nominal_ribu           278.9    405.5       35      232      8268
jam_transaksi           14.5      4.3        0       15        23
menit                   29.3     17.4        0       29        59
hari_dalam_minggu        3.0      2.0        0        3         6
durasi_detik           168.1     70.7       26      157       509
jumlah_item              2.8      1.4        1        3         9

== Distribusi transaksi per jam (batang = jumlah, 1 '#' ~ 10 transaksi) ==
 jam  jumlah  nominal rata2 batang
   0      16          267.8 ##
   1      13          301.6 #
   2       6          248.1 #
   3      10          290.7 #
   4       8          309.6 #
   5      10          307.0 #
   6      39          215.2 ####
   7     103          253.9 ##########
   8     182          248.3 ##################
   9     270          262.0 ###########################
  10     377          247.2 ######################################
  11     391          247.7 #######################################
  12     390          259.1 #######################################
  13     330          306.0 #################################
  14     346          327.9 ###################################
  15     344          315.2 ##################################
  16     341          258.6 ##################################
  17     363          285.8 ####################################
  18     410          291.7 #########################################
  19     389          273.2 #######################################
  20     283          265.0 ############################
  21     208          358.9 #####################
  22     108          272.2 ###########
  23      63          237.1 ######
jam 02..05    : 34 transaksi (0.68% dari data)
jam 10..21    : 4172 transaksi (83.44% dari data)
jam tersepi   : 2 (hanya 6 transaksi)

== Kemiringan (skewness) fitur utama ==
nominal_ribu  : skewness +14.28 (median 232 vs mean 279)
durasi_detik  : skewness  +0.96 (median 157 vs mean 168)
log1p nominal : skewness  +0.33
-> ekor kanan panjang; transformasi log akan dipakai model Bagian 3

== Durasi checkout menurut jumlah item ==
 item     n  durasi rata2  durasi min  nominal rata2
    1   832          86.6          44          215.3
    2  1477         132.5          66          192.4
    3  1344         179.5          92          271.8
    4   804         223.4          28          367.0
    5   342         264.3          45          445.5
    6   134         305.0          29          555.9
    7    47         331.5          26          642.5
    8    17         226.9          26          660.7

== Lima transaksi bernilai terbesar ==
 id_transaksi  nominal_ribu  jam_transaksi  durasi_detik  jumlah_item
          336        8268.3             18            89            1
          427        8260.1             14           112            1
           87        7938.6             21            96            2
         1534        7491.7             13           118            1
          370        7428.9             14            71            1

Bedah kode skrip eksplorasi:

  1. Baris 13–17 (muat dan segel label): drop(columns=["id_transaksi", "is_fraud"]) memisahkan enam fitur dari penanda dan label — disiplin unsupervised diterapkan sejak baris pertama.
  2. Baris 20–33 (gerbang kualitas): nilai hilang dan id ganda dihitung, lalu between(0, 23) dan kawan-kawannya memastikan tiap kolom di rentang wajar — data bersih agar deteksi tidak salah sasaran.
  3. Baris 36–41 (statistik ringkas): mean, std, minimum, median, dan maksimum enam fitur dicetak; std nominal 405,5 yang membengkak sudah menjadi petunjuk ekor kanan.
  4. Baris 44–59 (distribusi per jam): loop 24 jam menghitung jumlah transaksi dan rata-rata nominal; batang teks '#' * round(jumlah / 10) menggantikan plot agar pola terbaca di terminal.
  5. Baris 62–67 (kemiringan): df[kolom].skew() mengukur asimetri; nominal +14,28 versus log1p nominal +0,33 — transformasi log meratakan ekor kanan.
  6. Baris 70–77 (durasi per item): rata-rata dan minimum durasi dikelompokkan per jumlah item; inilah tabel yang membocorkan anomali tipe C tanpa label.
  7. Baris 80–84 (nominal terbesar): nlargest(5, "nominal_ribu") memperlihatkan lima transaksi teratas — semuanya bernilai jutaan rupiah dengan satu-dua item.

Temuan yang Menuntun Desain Deteksi #

Kelompok jamJumlah transaksiPorsi data
Jam sibuk 10–214.17283,44%
Jam peralihan (00–01, 06–09, 22–23)79415,88%
Dini hari 02–05340,68%

Empat temuan keluar dari eksplorasi. Pertama, 83,44% transaksi terjadi pada jam 10–21 sementara jendela 02–05 hanya menampung 34 transaksi — kelangkaan yang menjadi dasar aturan jam dini hari di Bagian 2. Kedua, nominal miring kanan berat (mean 278,9 ribu, median 232,1 ribu, skewness +14,28) sehingga std 405,5 ribu terdistorsi; transformasi log akan dipakai model Bagian 3. Ketiga, durasi normal sebanding item — rata-rata naik dari 86,6 detik (satu item) ke 331,5 detik (tujuh item) — namun minimum kelompok item 4–8 hanya 26–45 detik; pelanggaran hubungan inilah yang diangkat menjadi fitur rasio durasi_per_item. Keempat, lima nominal terbesar (7.428,9–8.268,3 ribu) semuanya beritem satu-dua — ekor ekstrem yang akan dijaring aturan z-score.

Karakter data kini terpetakan. Langkah berikutnya mengubah kebiasaan tersebut menjadi aturan keras — z-score, kuantil ekor, dan jam dini hari — lalu menghitung berapa transaksi yang tertandai, pada Bagian 2: Deteksi Statistik.

Referensi Resmi #