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.
# 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:
== 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 0Mari kita bedah skrip pembangkit data baris-per-baris:
- Baris 32–36 (konstanta):
SEED = 42, 4.940 transaksi normal, 60 anomali — tanpa jam sistem, CSV identik di mesin mana pun. - Baris 42–48 (
BOBOT_JAM): dua puluh empat bobot jam belanja; jam 18 paling ramai (bobot 14), jam 03 tersepi (0,12). - 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. - 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).
- Baris 71–77 (tipe B): jam 02–05 dengan nominal normal(280, 90) — semua fitur lain menyamar normal.
- Baris 79–83 (tipe C): item 4–8 dengan durasi 25–70 detik; nominal 350–700 ribu sengaja lolos dari aturan nominal.
- Baris 86–90 (posisi acak):
rng.choicetanpa penggantian memilih 60 posisi yang dibagi menjadi tiga kelompok 20 baris. - Baris 92–121 (perakitan array): keenam array diisi nilai normal, lalu tiga kelompok posisi ditimpa nilai anomali — lokasinya tersebar, bukan menumpuk di ekor berkas.
- Baris 123–137 (tulis CSV): delapan kolom termasuk
is_fraudhasilmask_anomali.astype(int)— label audit yang disegel sampai Bagian 4. - 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.
# 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:
== 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 1Bedah kode skrip eksplorasi:
- 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. - 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. - 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.
- 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. - Baris 62–67 (kemiringan):
df[kolom].skew()mengukur asimetri; nominal +14,28 versus log1p nominal +0,33 — transformasi log meratakan ekor kanan. - Baris 70–77 (durasi per item): rata-rata dan minimum durasi dikelompokkan per jumlah item; inilah tabel yang membocorkan anomali tipe C tanpa label.
- 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 jam | Jumlah transaksi | Porsi data |
|---|---|---|
| Jam sibuk 10–21 | 4.172 | 83,44% |
| Jam peralihan (00–01, 06–09, 22–23) | 794 | 15,88% |
| Dini hari 02–05 | 34 | 0,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 #
- pandas.DataFrame.skew — API — definisi kemiringan tak bias yang dipakai menilai asimetri nominal.
- pandas.Series.value_counts — API — penghitungan frekuensi per jam transaksi.
- numpy.random.Generator — dokumentasi — generator berseed untuk dataset sintetis yang reproducible.