Bagian 4 menutup dengan temuan yang menentukan arah proyek: total belanja tidak membedakan pelanggan, sedangkan hasil bagi belanja per kunjungan langsung memunculkan tiga gerombolan. Pada bagian ini kita mengangkat hasil bagi itu menjadi fitur resmi belanja_per_kunjungan, lalu menyiapkan kedua fitur agar adil bagi algoritme. Persiapan kedua itu — penyekalaan dengan StandardScaler — sering dilewati pemula padahal tanpa penyekalaan, K-Means pada data ini praktis hanya membaca satu fitur. Kita tutup dengan catatan kebocoran data yang akan sangat relevan pada proyek berikutnya.

Fitur baru lahir dari dua kolom lama: total belanja dibagi jumlah kunjungan. Kita terapkan pada data bersih, tampilkan tiga pelanggan yang sudah kita kenal dari Bagian 1, lalu ukur sebaran kedua fitur:

python
import pandas as pd


def muat_bersih(path):
    """Muat data mentah pelanggan dan terapkan seluruh langkah pembersihan."""
    df = pd.read_csv(path)
    df = df.drop_duplicates().reset_index(drop=True)
    df["kunjungan_30hari"] = pd.to_numeric(df["kunjungan_30hari"], errors="coerce")
    df = df.dropna(subset=["kunjungan_30hari"]).reset_index(drop=True)
    df["kota"] = df["kota"].str.strip().str.title()
    df["kunjungan_30hari"] = df["kunjungan_30hari"].astype(int)
    return df


bersih = muat_bersih("../data/pelanggan.csv")
bersih["belanja_per_kunjungan"] = (
    bersih["belanja_ribu"] / bersih["kunjungan_30hari"]
)

kolom = ["pelanggan", "kunjungan_30hari", "belanja_ribu", "belanja_per_kunjungan"]
pilihan = bersih[bersih["pelanggan"].isin(
    ["Siti Rahmawati", "Bambang Sutrisno", "Nurul Aini"]
)]
print("Tiga pelanggan, total belanja nyaris sama:")
print(pilihan[kolom].to_string(index=False))

std_kunjungan = bersih["kunjungan_30hari"].std()
std_belanja = bersih["belanja_per_kunjungan"].std()
print(f"\nSebaran kunjungan_30hari      : std {std_kunjungan:.2f}")
print(f"Sebaran belanja_per_kunjungan : std {std_belanja:.2f}")

# Jarak Euclidean tanpa penyekalaan: kontribusi tiap fitur ke kuadrat jarak.
siti = bersih.loc[bersih["pelanggan"] == "Siti Rahmawati"].iloc[0]
bambang = bersih.loc[bersih["pelanggan"] == "Bambang Sutrisno"].iloc[0]
kontribusi_k = (siti["kunjungan_30hari"] - bambang["kunjungan_30hari"]) ** 2
kontribusi_b = (siti["belanja_per_kunjungan"]
                - bambang["belanja_per_kunjungan"]) ** 2
total = kontribusi_k + kontribusi_b
print("\nJarak Siti-Bambang tanpa penyekalaan:")
print(f"  kontribusi kunjungan          : {100 * kontribusi_k / total:.1f}%")
print(f"  kontribusi belanja/kunjungan  : {100 * kontribusi_b / total:.1f}%")

Output yang Dihasilkan:

text
Tiga pelanggan, total belanja nyaris sama:
       pelanggan  kunjungan_30hari  belanja_ribu  belanja_per_kunjungan
  Siti Rahmawati                25           500                   20.0
Bambang Sutrisno                 4           480                  120.0
      Nurul Aini                12           420                   35.0

Sebaran kunjungan_30hari      : std 7.05
Sebaran belanja_per_kunjungan : std 40.12

Jarak Siti-Bambang tanpa penyekalaan:
  kontribusi kunjungan          : 4.2%
  kontribusi belanja/kunjungan  : 95.8%

Mari kita bedah hasilnya:

  1. Fitur baru berhasil memisahkan yang total sama. Siti, Bambang, dan Nurul yang tadinya menyatu sebagai “pelanggan Rp 400-500 ribu” kini terbaca 20,0; 120,0; dan 35,0 ribu per kunjungan — tiga perilaku yang berbeda tegas.
  2. Sebaran kedua fitur jauh tidak seimbang: simpangan baku kunjungan 7,05 sedangkan belanja per kunjungan 40,12 — hampir enam kali lipat.
  3. Ukuran jarak membuktikan akibatnya. K-Means mengelompokkan berdasarkan jarak Euclidean, dan jarak mengkuadratkan selisih tiap dimensi lalu menjumlahkannya. Selisih kunjungan Siti-Bambang (25 vs 4) menyumbang 21² = 441, sedangkan selisih belanja per kunjungan (20 vs 120) menyumbang 100² = 10.000. Tanpa penyekalaan, 95,8% jarak mereka ditentukan oleh satu fitur saja — kunjungan nyaris tidak diperhitungkan.

Mengapa K-Means Menuntut Penyekalaan #

Solusinya adalah menyekalakan kedua fitur ke skala yang setara. StandardScaler mengubah tiap kolom menjadi z-score: nilai dikurangi rata-rata kolom lalu dibagi simpangan bakunya, sehingga kedua kolom berakhir di rata-rata 0 dan simpangan baku 1. Konsepnya dibahas tuntas pada chapter Normalisasi ; di sini kita terapkan dan periksa hasilnya:

python
import pandas as pd
from sklearn.preprocessing import StandardScaler


def muat_bersih(path):
    """Muat data mentah pelanggan dan terapkan seluruh langkah pembersihan."""
    df = pd.read_csv(path)
    df = df.drop_duplicates().reset_index(drop=True)
    df["kunjungan_30hari"] = pd.to_numeric(df["kunjungan_30hari"], errors="coerce")
    df = df.dropna(subset=["kunjungan_30hari"]).reset_index(drop=True)
    df["kota"] = df["kota"].str.strip().str.title()
    df["kunjungan_30hari"] = df["kunjungan_30hari"].astype(int)
    return df


bersih = muat_bersih("../data/pelanggan.csv")
bersih["belanja_per_kunjungan"] = (
    bersih["belanja_ribu"] / bersih["kunjungan_30hari"]
)
fitur = bersih[["kunjungan_30hari", "belanja_per_kunjungan"]]

skala = StandardScaler().fit_transform(fitur)

tabel = pd.DataFrame({
    "pelanggan": bersih["pelanggan"].head(5),
    "kunjungan": fitur["kunjungan_30hari"].head(5),
    "bpk_ribu": fitur["belanja_per_kunjungan"].head(5).round(2),
    "kunjungan_z": skala[:5, 0].round(3),
    "bpk_z": skala[:5, 1].round(3),
})
print("Lima pelanggan pertama, sebelum dan sesudah penyekalaan:")
print(tabel.to_string(index=False))

z_pandas = (fitur["kunjungan_30hari"].iloc[0] - fitur["kunjungan_30hari"].mean()) \
    / fitur["kunjungan_30hari"].std()
print(f"\nz-score Siti versi pandas (ddof=1)       : {z_pandas:.3f}")
print(f"z-score Siti versi StandardScaler (ddof=0): {skala[0, 0]:.3f}")

Output yang Dihasilkan:

text
Lima pelanggan pertama, sebelum dan sesudah penyekalaan:
       pelanggan  kunjungan  bpk_ribu  kunjungan_z  bpk_z
  Siti Rahmawati         25      20.0        1.793 -0.892
Bambang Sutrisno          4     120.0       -1.235  1.643
 Andi Firmansyah         10      40.0       -0.370 -0.385
    Budi Santoso         22      21.0        1.360 -0.867
      Nurul Aini         12      35.0       -0.082 -0.512

z-score Siti versi pandas (ddof=1)       : 1.762
z-score Siti versi StandardScaler (ddof=0): 1.793

Mari kita baca hasil penyekalaan tersebut:

  1. Kedua kolom kini berbicara dengan bobot setara. Kunjungan 25 menjadi +1,793 dan belanja per kunjungan 20,0 menjadi -0,892 — keduanya berada pada skala yang sama, sehingga selisih pada dimensi mana pun berkontribusi sebanding pada jarak.
  2. fit_transform melakukan dua hal sekaligus: menghitung rata-rata dan simpangan baku dari data (fit), lalu menerapkan transformasi ke setiap baris (transform). Hasilnya berupa larik numpy, bukan DataFrame.
  3. Angka z-score pandas dan StandardScaler sedikit berbeda (1,762 vs 1,793) karena keduanya memakai konvensi simpangan baku yang berbeda: Series.std() pandas memakai ddof=1 (versi sampel), sedangkan StandardScaler memakai ddof=0 (versi populasi). Keduanya benar pada konteksnya; yang penting satu metode dipakai konsisten untuk seluruh data.

Catatan Kebocoran Data untuk Tahap Berikutnya #

Ada satu hal yang perlu dicatat jujur. StandardScaler pada blok di atas di-fit pada seluruh 30 pelanggan sekaligus — dan untuk proyek ini tidak masalah, karena clustering memang menganalisis seluruh populasi tanpa target yang diprediksi. Tapi bayangkan tahap berikutnya: proyek keempat kita akan membangun model supervised yang memprediksi harga, lengkap dengan pemisahan data latih dan data uji. Pada situasi itu, scaler yang di-fit pada seluruh data akan membocorkan informasi data uji ke proses pelatihan — kebocoran data (data leakage) yang membuat hasil evaluasi terlihat lebih baik dari kenyataannya. Aturan yang akan kita pegang sejak sekarang: transformer apa pun di-fit hanya pada data latih, lalu dipakai mentransformasi data uji. Disiplin ini tidak dibutuhkan hari ini, tetapi membiasakannya mulai dari sekarang jauh lebih murah daripada memperbaikinya nanti.

Dua fitur kini siap: kunjungan_30hari dan belanja_per_kunjungan, keduanya ternormalisasi dengan rata-rata 0 dan simpangan baku 1. Panggung sudah lengkap untuk bintang utamanya — algoritme K-Means yang akan menemukan kelompok secara otomatis pada Bagian 6: Menerapkan Clustering .