Semua teknik pada chapter-chapter sebelumnya menghasilkan angka — akurasi, recall, f1 — tetapi angka tidak bercerita tentang apa yang salah. Padahal di situlah petunjuk perbaikan tersimpan: baris mana yang salah prediksi, kesalahan seperti apa yang dominan, dan kelompok pelanggan mana yang paling tersulitkan model. Analisis kesalahan (error analysis) adalah kebiasaan membaca baris-baris FP dan FN secara langsung sebelum memutuskan langkah berikutnya — sering kali satu jam membaca kesalahan lebih menentukan arah proyek daripada sekian jam menambah hyperparameter. Pada chapter ini kita bedah kesalahan model churn per baris, mencari polanya per kelompok, lalu memilah mana kesalahan model dan mana kesalahan label.

Membaca Baris FP dan FN dari Hasil Prediksi #

Kita kembali ke kasus churn pelanggan Toko Kopi Kita, kini dengan 60 pelanggan dari tiga kota: Bandung, Surabaya, dan Medan. Fiturnya frekuensi kunjungan per bulan dan rata-rata belanja (ribu rupiah); target churn menandai pelanggan yang berhenti. Model regresi logistik hanya menerima kedua fitur numerik tersebut — kolom kota tidak ikut dilatih, dan hal ini penting untuk kisah di bawah. Setelah pelatihan pada data latih ter-stratifikasi, kita saring baris data uji yang prediksinya meleset:

python
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression

rng = np.random.default_rng(20)
frek = np.concatenate([rng.integers(1, 15, 20), rng.integers(1, 15, 18),
                       rng.integers(1, 15, 20)])
belanja = np.concatenate([rng.integers(50, 950, 20), rng.integers(50, 950, 18),
                          rng.integers(50, 950, 20)])
kota = np.array(["Bandung"] * 20 + ["Surabaya"] * 18 + ["Medan"] * 20)
churn = np.where(kota == "Surabaya", frek >= 9, frek < 7).astype(int)
kota = np.concatenate([kota, ["Surabaya", "Surabaya"]])
frek = np.concatenate([frek, [12, 12]])
belanja = np.concatenate([belanja, [640, 640]])
churn = np.concatenate([churn, [1, 0]])
df = pd.DataFrame({"kota": kota, "frekuensi": frek.astype(int),
                   "belanja": belanja.astype(int), "churn": churn})

X = df[["frekuensi", "belanja"]].to_numpy()
y = df["churn"].to_numpy()
idx = np.arange(len(df))
idx_latih, idx_uji = train_test_split(idx, test_size=0.34, stratify=y,
                                      random_state=42)
X_latih, X_uji = X[idx_latih], X[idx_uji]
y_latih, y_uji = y[idx_latih], y[idx_uji]

model = LogisticRegression(max_iter=1000).fit(X_latih, y_latih)
y_pred = model.predict(X_uji)

print("data uji:", len(y_uji), "baris | akurasi:", round((y_pred == y_uji).mean(), 3))

uji = df.iloc[idx_uji].copy()
uji["prediksi"] = y_pred
salah = uji[y_pred != y_uji].copy()
salah["jenis"] = np.where(salah["churn"] == 1, "FN", "FP")
print("jumlah kesalahan:", len(salah))
print()
print(salah[["kota", "frekuensi", "belanja", "churn", "prediksi", "jenis"]]
      .to_string(index=False))

Output yang Dihasilkan:

text
data uji: 21 baris | akurasi: 0.762
jumlah kesalahan: 5

    kota  frekuensi  belanja  churn  prediksi jenis
Surabaya          1      677      0         1    FP
Surabaya         12      620      1         0    FN
Surabaya         10      501      1         0    FN
Surabaya         13      468      1         0    FN
 Bandung          4      321      1         0    FN

Mari kita telaah cara membacanya:

  1. Baris 24-25 (mask kesalahan): y_pred != y_uji menghasilkan boolean mask yang menyaring hanya baris yang salah; mask inilah pintu masuk seluruh analisis kesalahan — tanpa menyentuh baris yang benar.
  2. Baris 27 (kolom jenis): baris dengan churn == 1 tetapi diprediksi 0 adalah false negative (FN) — pelanggan pergi tanpa terdeteksi; sebaliknya, churn == 0 diprediksi 1 adalah false positive (FP) — pelanggan setia yang nyaris terbuang karena dianggap akan pergi.
  3. Lima baris terakhir (tabel kesalahan): empat dari lima kesalahan datang dari Surabaya, dan polanya mencolok — tiga FN Surabaya justru pelanggan paling aktif (frekuensi 10-13), sementara satu FP-nya pelanggan yang hampir tak pernah datang (frekuensi 1) tetapi bertahan.

Apa yang sebenarnya terjadi? Model belajar pola dari mayoritas data: pelanggan jarang datang cenderung berhenti (pola Bandung-Medan). Namun di Surabaya terjadi hal yang tidak terlihat oleh fitur: kompetitor baru membuka gerai dan justru merangkul pelanggan paling aktif dengan promo besar — pola yang terbalik. Model kita tidak bisa mengetahuinya karena kolom kota tidak pernah diberikan kepadanya.

Pola Kesalahan per Kelompok #

Lima baris kesalahan tadi bisa dibaca mata dalam hitungan detik, tetapi pada ribuan baris kita butuh agregasi. Pertanyaan pembuka yang paling produktif: apakah tingkat kesalahan merata di semua kelompok, atau menumpuk di kelompok tertentu? groupby menjawabnya dalam beberapa baris:

python
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression

rng = np.random.default_rng(20)
frek = np.concatenate([rng.integers(1, 15, 20), rng.integers(1, 15, 18),
                       rng.integers(1, 15, 20)])
belanja = np.concatenate([rng.integers(50, 950, 20), rng.integers(50, 950, 18),
                          rng.integers(50, 950, 20)])
kota = np.array(["Bandung"] * 20 + ["Surabaya"] * 18 + ["Medan"] * 20)
churn = np.where(kota == "Surabaya", frek >= 9, frek < 7).astype(int)
kota = np.concatenate([kota, ["Surabaya", "Surabaya"]])
frek = np.concatenate([frek, [12, 12]])
belanja = np.concatenate([belanja, [640, 640]])
churn = np.concatenate([churn, [1, 0]])
df = pd.DataFrame({"kota": kota, "frekuensi": frek.astype(int),
                   "belanja": belanja.astype(int), "churn": churn})

X = df[["frekuensi", "belanja"]].to_numpy()
y = df["churn"].to_numpy()
idx = np.arange(len(df))
idx_latih, idx_uji = train_test_split(idx, test_size=0.34, stratify=y,
                                      random_state=42)
model = LogisticRegression(max_iter=1000).fit(X[idx_latih], y[idx_latih])
y_pred = model.predict(X[idx_uji])

uji = df.iloc[idx_uji].copy()
uji["salah"] = y_pred != y[idx_uji]

ringkas = uji.groupby("kota")["salah"].agg(jumlah="count", salah="sum")
ringkas["tingkat_kesalahan"] = (ringkas["salah"] / ringkas["jumlah"]).round(3)
print(ringkas.to_string())

Output yang Dihasilkan:

text
          jumlah  salah  tingkat_kesalahan
kota                                      
Bandung        5      1              0.200
Medan          9      0              0.000
Surabaya       7      4              0.571

Tabel ini mengubah dugaan menjadi bukti: akurasi keseluruhan 0,762 ternyata menyembunyikan jurang antar kota — Medan sempurna (0,000), Bandung baik (0,200), tetapi Surabaya salah lebih dari separuh waktu (0,571). Rata-rata yang tampak wajar membuat masalah satu kelompok tidak terlihat; pemecahan per kelompok inilah alasan mengapa satu angka akurasi global tidak pernah cukup untuk keputusan. Pemeriksaan serupa bisa dijalankan pada fitur apa pun — bin usia, kategori produk, rentang nilai transaksi — atau memakai pd.cut() untuk fitur kontinu sebelum groupby.

Kesalahan Label atau Kesalahan Model? #

Tidak semua kesalahan berasal dari model. Kadang datanya sendiri yang bermasalah: label salah input, definisi target berganti di tengah pengumpulan, atau dua baris benar-benar identik tetapi berlabel berbeda — sesuatu yang mustahil dipelajari model mana pun. Detektornya sederhana: cari baris dengan fitur serupa namun label bertentangan.

python
import numpy as np
import pandas as pd

rng = np.random.default_rng(20)
frek = np.concatenate([rng.integers(1, 15, 20), rng.integers(1, 15, 18),
                       rng.integers(1, 15, 20)])
belanja = np.concatenate([rng.integers(50, 950, 20), rng.integers(50, 950, 18),
                          rng.integers(50, 950, 20)])
kota = np.array(["Bandung"] * 20 + ["Surabaya"] * 18 + ["Medan"] * 20)
churn = np.where(kota == "Surabaya", frek >= 9, frek < 7).astype(int)
kota = np.concatenate([kota, ["Surabaya", "Surabaya"]])
frek = np.concatenate([frek, [12, 12]])
belanja = np.concatenate([belanja, [640, 640]])
churn = np.concatenate([churn, [1, 0]])
df = pd.DataFrame({"kota": kota, "frekuensi": frek.astype(int),
                   "belanja": belanja.astype(int), "churn": churn})

serupa = df[df.duplicated(subset=["frekuensi", "belanja"], keep=False)]
print(serupa.sort_values(["frekuensi", "belanja", "churn"]).to_string(index=False))

Output yang Dihasilkan:

text
    kota  frekuensi  belanja  churn
Surabaya         12      640      0
Surabaya         12      640      1

Kode ini memakai duplicated(subset=[...], keep=False) untuk menampilkan seluruh baris yang berbagi nilai fitur identik — dan menemukan sepasang pelanggan Surabaya dengan frekuensi kunjungan dan belanja persis sama (12 kali, 640 ribu) tetapi satu berlabel bertahan dan satu berlabel berhenti. Bagi model, dua baris ini saling meniadakan: menaikkan bobot di titik itu menaikkan kesalahan yang lain. Tindak lanjutnya bukan melatih ulang, melainkan memeriksa sumber data — misalnya memastikan apakah definisi “berhenti” di cabang Surabaya memang konsisten dengan kota lain. Tabel berikut merangkum pembacaan umumnya:

Pola pada data kesalahanKemungkinan penyebabTindakan pertama
Kelompok fitur identik, label berbedaKesalahan label: input manual keliru atau definisi target tidak konsistenAudit label bersama pemilik data; samakan definisi
Kesalahan menumpuk pada satu kelompokFitur pembeda kelompok tidak tersedia bagi modelTambahkan fitur kelompok (mis. kota) atau latih model per kelompok
Kesalahan menyebar merata tanpa polaFitur yang ada kurang menjelaskan targetKumpulkan fitur baru; coba model lebih kuat; terima batas bila memang acak
Kesalahan pada nilai ekstrem sajaSampel langka di ujung distribusiPerbanyak data di wilayah tersebut; periksa rentang fitur

Pada kasus kita, dua temuan tadi mengarah ke tindakan yang jelas: pasangan berlabel ganda perlu diaudit, dan kota perlu diikutkan sebagai fitur (dengan encoding kategorikal dari Section Feature Engineering) karena pola churn-nya memang berbeda antar kota.

Daftar Periksa Perbaikan #

Temuan analisis kesalahan sebaiknya ditutup dengan daftar periksa yang bisa dieksekusi, diurutkan dari yang termurah:

  1. Perbaiki data dulu — audit label konflik, samakan definisi target, periksa baris ganda. Termurah, sering paling berdampak.
  2. Tambahkan fitur penjelas — kelompok yang gagal (Surabaya) biasanya punya informasi yang belum masuk model; kota, jarak ke kompetitor, atau interaksi antar fitur adalah kandidatnya.
  3. Perluas cakupan sampel kelompok lemah — bila Surabaya hanya diwakili sedikit baris, tambah datanya sebelum menambah kompleksitas model.
  4. Baru pertimbangkan model/hyperparameter — tuning atau ganti model setelah tiga langkah di atas diperiksa, bukan sebagai refleks pertama.
  5. Evaluasi ulang dengan protokol yang sama — split dan lipatan yang identik, agar perbaikan terukur adil dan tidak sekadar berbeda undian.

Rangkuman #

  • Analisis kesalahan dimulai dari mask y_pred != y_uji: saring baris salah, beri label FN/FP, lalu baca satu per satu.
  • Kesalahan jarang menyebar merata; groupby pada fitur kelompok membuka konsentrasi yang disembunyikan rata-rata global — di kasus kita Surabaya salah 57% sementara Medan 0%.
  • Kelompok yang gagal menunjukkan informasi yang hilang dari model; solusinya sering kali fitur baru (encoding kota), bukan model baru.
  • duplicated() menemukan fitur identik dengan label berbeda — penanda kesalahan label yang mustahil dipelajari model mana pun.
  • Daftar periksa perbaikan berurutan: data dulu, fitur kemudian, sampel berikutnya, model paling akhir, lalu evaluasi ulang dengan protokol sama.

Kini kita tahu cara membaca kelemahan satu model secara jujur. Pertanyaan berikutnya alami: bila tersedia beberapa kandidat model, bagaimana mengadunya secara adil sehingga perbandingannya tidak dimenangkan oleh kebetulan? Chapter Membandingkan Model menyiapkan arena pertandingannya.

Referensi #

  1. scikit-learn — confusion_matrix
  2. scikit-learn — Metrics and scoring: model evaluation
  3. pandas — DataFrame.groupby