Bagian 1 menutup dengan tiga catatan yang kini harus ditindaklanjuti: total_belanja_ribu miring kanan dengan skewness 1,016, rentang fitur yang timpang (197–21.340 berbanding 1–44), dan pendapatan yang terkonsentrasi pada segelintir pelanggan. KMeans adalah algoritma berbasis jarak Euclidean — algoritma tersebut menghitung kedekatan pelanggan dari akar jumlah selisih kuadrat tiap fitur, tanpa tahu bahwa satu fitur berdenominal jutaan dan lainnya berhitungan satuan. Pada bagian ini kita menyiapkan ruang fitur yang adil bagi keempat dimensi RFM: transformasi log1p untuk fitur miring, StandardScaler untuk menyamakan skala, lalu bukti empiris bahwa keputusan ini mengubah segmen akhir secara menyeluruh.
Mengapa Scaling Wajib untuk KMeans #
Mekanismenya sederhana. Jarak Euclidean menjumlahkan selisih kuadrat dari seluruh fitur dalam satuan apa adanya. Pada data mentah kita, varians total_belanja_ribu menyumbang 99,95% dari total varians keempat fitur — selisih total belanja sebesar 12.910 antar dua pelanggan menghasilkan kontribusi jarak kuadrat 166,7 juta, sementara selisih 32 pesanan hanya menyumbang 1.024. Akibatnya KMeans pada data mentah praktis menjadi clustering satu fitur: pembagian klaster mengikuti total belanja, dan sinyal perilaku pada recency maupun frekuensi nyaris tidak terbaca.
Regresi linear pada proyek sebelumnya relatif tahan terhadap perbedaan skala karena koefisien menyesuaikan magnitudo tiap fitur. KMeans tidak punya mekanisme kompensasi tersebut — centroid diperbarui langsung dari rata-rata posisi, sehingga fitur bervarians raksasa menentukan hampir seluruh pergerakan centroid. Inilah alasan scaling diperlakukan sebagai bagian wajib pipeline clustering, bukan opsional.
Memilih Transformasi dan Scaler #
Transformasi log dipilih untuk total_belanja_ribu karena skewness fitur tersebut 1,016 — jauh di atas ambang praktis 0,9 — dan np.log1p menahan nilai nol tanpa perlu menggeser data. Tiga fitur lain sengaja tidak di-log: rata_belanja_ribu yang hanya berskewness 0,320 justru berubah menjadi miring kiri (−0,532) bila di-log, tanda transformasi berlebihan pada fitur yang sudah simetris. Empat pilihan scaler yang umum dibandingkan berikut:
| Scaler | Mekanisme | Cocok saat | Risiko |
|---|---|---|---|
StandardScaler | kurangi mean, bagi simpangan baku | distribusi kira-kira simetris | pencilan menarik mean dan std |
MinMaxScaler | petakan ke rentang [0, 1] | butuh rentang pasti (mis. citra) | sangat sensitif terhadap pencilan |
RobustScaler | median dan IQR | data banyak pencilan | skala hasil kurang lazim dibaca |
PowerTransformer | Box-Cox / Yeo-Johnson | kemiringan kuat multi-fitur | makna satuan berubah drastis |
Kita memakai kombinasi paling transparan: log1p manual pada satu fitur miring, lalu StandardScaler pada keempatnya. Kombinasi ini mudah dibalik dengan np.expm1 dan centroid tetap dapat dibaca setelah dipetakan balik ke satuan asli.
# Pra-pemrosesan untuk clustering: transformasi log1p pada fitur yang
# miring, lalu StandardScaler pada seluruh fitur. Skrip juga membuktikan
# mengapa scaling wajib untuk KMeans: membandingkan KMeans k=4 pada data
# mentah vs data terskala, dengan centroid dicetak dalam satuan asli.
# Data ini murni numerik - tidak ada encoding kategorikal, sehingga
# tidak ada risiko dummy trap.
# Jalankan dari root proyek: python3 src/03_preprocessing.py
import numpy as np
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler
df = pd.read_csv("data/pelanggan.csv")
FITUR = [
"total_belanja_ribu",
"frekuensi_pesanan",
"hari_sejak_transaksi",
"rata_belanja_ribu",
]
K = 4
# ---------- Transformasi log1p pada fitur miring ----------
print("== Kemiringan (skewness) sebelum dan sesudah log1p ==")
print(f"{'fitur':22s} {'mentah':>8s} {'log1p':>8s}")
for kolom in FITUR:
mentah = df[kolom].skew()
log = np.log1p(df[kolom]).skew() if df[kolom].min() >= 0 else float("nan")
print(f"{kolom:22s} {mentah:8.3f} {log:8.3f}")
# Hanya total_belanja_ribu yang miring kuat (skewness > 0.9); tiga fitur
# lain cukup simetris sehingga cukup distandarkan apa adanya.
X_log = df[FITUR].copy()
X_log["total_belanja_ribu"] = np.log1p(X_log["total_belanja_ribu"])
skala = StandardScaler()
X_terskala = skala.fit_transform(X_log)
# ---------- Dominasi varians pada data mentah ----------
print("\n== Kontribusi tiap fitur terhadap total varians (data mentah) ==")
varian = df[FITUR].var()
pangsa = varian / varian.sum()
for kolom in FITUR:
print(f"{kolom:22s}: {pangsa[kolom]:8.4f}")
# ---------- Jarak dua pelanggan: mentah vs terskala ----------
# Pelanggan A = baris pertama, pelanggan B = baris kelima.
idx_a, idx_b = 0, 4
print(f"\n== Jarak Euclidean dua pelanggan (A=baris {idx_a}, B=baris {idx_b}) ==")
print(f"{'pelanggan':10s} {'total':>7s} {'frek':>5s} {'hari':>5s} {'rata':>6s}")
for nama, idx in [("A", idx_a), ("B", idx_b)]:
b = df.iloc[idx]
print(
f"{nama:10s} {b['total_belanja_ribu']:7.0f}"
f" {b['frekuensi_pesanan']:5.0f}"
f" {b['hari_sejak_transaksi']:5.0f}"
f" {b['rata_belanja_ribu']:6.0f}"
)
beda_mentah = (df.iloc[idx_b][FITUR] - df.iloc[idx_a][FITUR]) ** 2
total_mentah = beda_mentah.sum()
print("\npada data mentah : kontribusi tiap fitur terhadap jarak kuadrat")
for kolom in FITUR:
print(f" {kolom:22s}: {beda_mentah[kolom]:14.1f} ({beda_mentah[kolom]/total_mentah:6.2%})")
beda_skala = (X_terskala[idx_b] - X_terskala[idx_a]) ** 2
total_skala = beda_skala.sum()
print("pada data terskala: kontribusi tiap fitur terhadap jarak kuadrat")
for i, kolom in enumerate(FITUR):
print(f" {kolom:22s}: {beda_skala[i]:14.3f} ({beda_skala[i]/total_skala:6.2%})")
# ---------- KMeans pada data mentah vs data terskala ----------
km_mentah = KMeans(n_clusters=K, random_state=42, n_init=10).fit(df[FITUR].values)
km_skala = KMeans(n_clusters=K, random_state=42, n_init=10).fit(X_terskala)
def profil_klaster(label_klaster):
prof = df.copy()
prof["klaster"] = label_klaster
return prof.groupby("klaster").agg(
n=("klaster", "size"),
total=("total_belanja_ribu", "mean"),
frek=("frekuensi_pesanan", "mean"),
hari=("hari_sejak_transaksi", "mean"),
rata=("rata_belanja_ribu", "mean"),
)
print(f"\n== Centroid KMeans k={K} dalam satuan asli: data MENTAH ==")
print(profil_klaster(km_mentah.labels_).round(1).to_string())
print(f"\n== Centroid KMeans k={K} dalam satuan asli: data TERSKALA ==")
print(profil_klaster(km_skala.labels_).round(1).to_string())
# Silhouette kedua pelabelan diukur di ruang fitur terskala yang sama
# agar dapat dibandingkan secara adil.
sil_mentah = silhouette_score(X_terskala, km_mentah.labels_)
sil_skala = silhouette_score(X_terskala, km_skala.labels_)
print("\n== Silhouette kedua pelabelan (diukur di ruang terskala) ==")
print(f"KMeans data mentah : {sil_mentah:.4f}")
print(f"KMeans data terskala: {sil_skala:.4f}")Output yang Dihasilkan:
== Kemiringan (skewness) sebelum dan sesudah log1p ==
fitur mentah log1p
total_belanja_ribu 1.016 0.136
frekuensi_pesanan 0.819 0.230
hari_sejak_transaksi 0.725 -0.061
rata_belanja_ribu 0.320 -0.532
== Kontribusi tiap fitur terhadap total varians (data mentah) ==
total_belanja_ribu : 0.9995
frekuensi_pesanan : 0.0000
hari_sejak_transaksi : 0.0000
rata_belanja_ribu : 0.0005
== Jarak Euclidean dua pelanggan (A=baris 0, B=baris 4) ==
pelanggan total frek hari rata
A 1020 3 5 340
B 13930 35 1 398
pada data mentah : kontribusi tiap fitur terhadap jarak kuadrat
total_belanja_ribu : 166668100.0 (100.00%)
frekuensi_pesanan : 1024.0 ( 0.00%)
hari_sejak_transaksi : 16.0 ( 0.00%)
rata_belanja_ribu : 3364.0 ( 0.00%)
pada data terskala: kontribusi tiap fitur terhadap jarak kuadrat
total_belanja_ribu : 4.494 (39.96%)
frekuensi_pesanan : 6.513 (57.91%)
hari_sejak_transaksi : 0.012 ( 0.11%)
rata_belanja_ribu : 0.228 ( 2.03%)
== Centroid KMeans k=4 dalam satuan asli: data MENTAH ==
n total frek hari rata
klaster
0 166 1067.0 3.1 43.0 367.7
1 45 11702.3 30.2 9.5 391.6
2 55 5212.2 14.5 58.7 362.3
3 34 15797.4 34.4 9.1 462.2
== Centroid KMeans k=4 dalam satuan asli: data TERSKALA ==
n total frek hari rata
klaster
0 82 1241.1 2.5 5.5 492.6
1 60 5103.0 14.2 58.0 362.2
2 80 807.4 3.4 81.1 241.2
3 78 13525.1 32.2 8.8 421.4
== Silhouette kedua pelabelan (diukur di ruang terskala) ==
KMeans data mentah : 0.2331
KMeans data terskala: 0.5544Mari kita bedah skrip pra-pemrosesan baris-per-baris:
- Baris 27–30 (loop skewness): kemiringan tiap fitur dicetak sebelum dan sesudah
log1p. Hasil 1,016 menjadi 0,136 padatotal_belanja_ribumenegaskan transformasi bekerja; sebaliknyarata_belanja_ribuyang berubah dari 0,320 menjadi −0,532 menegaskan fitur tersebut tidak boleh di-log. - Baris 34–35 (
X_log): hanya kolomtotal_belanja_ribuyang di-log1p— keputusan selektif berdasarkan ambang skewness, bukan transformasi rata kanan pada seluruh kolom. - Baris 37–38 (
StandardScaler): scaler di-fit pada keempat fitur hasil log sekaligus, menghasilkan matriksX_terskaladengan mean 0 dan simpangan 1 per kolom — ruang tempat seluruh clustering berikutnya dihitung. - Baris 42–45 (pangsa varians): varians tiap fitur dibagi total varians. Angka 0,9995 pada
total_belanja_ribuadalah bukti kuantitatif dominasi yang dibahas di awal bagian. - Baris 49–59 (dua pelanggan): pelanggan A (total Rp1.020.000, 3 pesanan) dan pelanggan B (total Rp13.930.000, 35 pesanan) dicetak sebagai contoh konkret sebelum jarak dihitung.
- Baris 61–71 (dekomposisi jarak): selisih kuadrat tiap fitur dibagi total jarak kuadrat — di data mentah maupun terskala — sehingga kita melihat persis fitur mana yang menentukan kedekatan pada masing-masing ruang.
- Baris 74–75 (dua KMeans): dua model identik (
random_state=42, n_init=10) dilatih, satu pada data mentah dan satu pada data terskala; tidak ada parameter lain yang berbeda sehingga seluruh perbedaan hasil murni akibat pra-pemrosesan. - Baris 96–100 (silhouette ruang sama): silhouette kedua pelabelan diukur di ruang terskala yang sama. Mengukur silhouette pelabelan mentah di ruang mentah akan menyesatkan karena ruang tersebut memang memuja satu fitur.
Membaca Hasil: Jarak, Centroid, dan Silhouette #
Dekomposisi jarak pada pasangan A–B memperlihatkan efek scaling secara paling telanjang. Pada data mentah, 100,00% jarak kuadrat datang dari selisih total belanja; selisih frekuensi 32 pesanan hanya menyumbang 0,00%. Pada data terskala, kontribusi berubah menjadi 39,96% dari total belanja dan 57,91% dari frekuensi — jarak kini membaca bahwa A dan B memang berbeda kelas pelanggan di dua dimensi sekaligus.
Tabel centroid melanjutkan cerita ke tingkat segmen. KMeans pada data mentah membelah 79 pelanggan besar menjadi dua klaster yang nyaris kembar secara perilaku — frekuensi 30,2 vs 34,4, recency 9,5 vs 9,1 — semata karena total belanja 11,7 juta vs 15,8 juta; dua klaster tersebut tidak menghasilkan strategi pemasaran yang berbeda. Sebaliknya, 166 pelanggan kecil dilebur menjadi satu klaster campuran. KMeans pada data terskala menghasilkan empat profil yang berbeda pada keempat dimensi: klaster total 13.525 ribu dengan 32 pesanan, klaster menengah 5.103 ribu dengan recency 58 hari, klaster kecil aktif recency 5,5 hari, dan klaster kecil lama tak kembali recency 81 hari. Silhouette yang diukur di ruang yang sama mengonfirmasi: 0,2331 untuk pelabelan mentah berbanding 0,5544 untuk pelabelan terskala.
Ruang fitur kini adil dan matriks X_terskala siap dipakai. Pertanyaan berikutnya adalah pertanyaan struktur: berapa banyak klaster yang seharusnya dicari? Kita akan menguji k=2 sampai k=8 dengan dua kacamata — silhouette yang mengukur kualitas pemisahan dan kurva inertia yang mencari siku — lalu menamai tiap klaster dengan label bisnis. Semua itu dikerjakan pada Bagian 3: Clustering dan Profil.
Referensi Resmi #
- Preprocessing data — scikit-learn User Guide — keluarga scaler dan aturan standardisasi fitur.
- sklearn.preprocessing.StandardScaler — API — parameter dan perilaku fit-transform.
- numpy.log1p — API — logaritma natural yang aman untuk nilai nol.
- sklearn.cluster.KMeans — API
— parameter
n_initdanrandom_state.