Bagian 3 menghasilkan pemenang teknis — pipeline SMOTE dengan PR-AUC 0,636 — tetapi seluruh angkanya dihitung pada ambang bawaan 0,50, angka yang tidak pernah diajarkan siapa pun ke kita dan tidak punya alasan bisnis. Bagian penutup proyek ini mengganti pertanyaan teknis dengan pertanyaan bisnis: kepada berapa pelanggan sebaiknya penawaran retensi dikirim, dan berapa nilai rupiah yang dipertaruhkan pada tiap keputusan. Jawabannya ditemukan lewat kurva precision-recall, satu fungsi biaya sederhana, dan ditutup dengan laporan teks yang merangkum seluruh keputusan proyek.
Kurva Precision-Recall sebagai Peta Keputusan #
Sebuah classifier biner sebenarnya memproduksi probabilitas; label “churn” atau “bertahan” baru muncul setelah probabilitas dibandingkan dengan sebuah ambang. Setiap ambang menghasilkan pasangan presisi-recall berbeda: ambang rendah menandai banyak pelanggan (recall naik, presisi turun), ambang tinggi hanya menandai yang paling meyakinkan (presisi naik, recall jatuh). Kurva precision-recall memetakan seluruh pasangan tersebut, dan PR-AUC — angka 0,636 dari Bagian 3 — adalah luas di bawah peta itu: ukuran seberapa baik model mengurutkan churner di atas pelanggan yang bertahan, sebelum ambang mana pun dipilih.
Pada data timpang, peta ini jauh lebih informatif daripada kurva ROC karena sumbu presisi langsung merasakan kelangkaan kelas churn. Tabel pada keluaran skrip di bawah menyampel kurva tersebut pada sembilan ambang dari 0,10 sampai 0,90 lengkap dengan jumlah penawaran retensi dan biaya totalnya — sekilas kita sudah melihat trade-off-nya: ambang 0,10 menawarkan 112 pelanggan tanpa melewat satu churner pun, ambang 0,90 hanya menawarkan satu pelanggan dan melewatkan 33.
Biaya dalam Rupiah Menentukan Ambang #
Pemilihan ambang membutuhkan dua angka dari tim bisnis, bukan dari data: biaya satu penawaran retensi (diskon, panggilan telepon, waktu staf) dan nilai satu pelanggan yang berhasil dipertahankan. Proyek ini memakai Rp500.000 per penawaran dan Rp4.000.000 sebagai nilai pelanggan — rasio 1 berbanding 8. Total biaya satu ambang lalu dihitung dengan rumus sederhana: jumlah penawaran dikali Rp500.000, ditambah jumlah churner yang terlewat dikali Rp4.000.000. Dua asumsi menyertainya: penawaran kepada churner sejati selalu berhasil mempertahankan pelanggan, dan setiap penawaran — tepat sasaran maupun tidak — tetap menimbulkan biaya.
# Evaluasi bisnis: pilih ambang keputusan berbasis biaya, lalu susun
# laporan teks proyek pada laporan/laporan-churn.txt.
# Asumsi biaya: penawaran retensi Rp500.000 per pelanggan; nilai satu
# pelanggan (customer lifetime value) Rp4.000.000.
# Model terbaik dari Bagian 3: pipeline imblearn pra-pemrosesan ->
# SMOTE(random_state=42) -> LogisticRegression.
# Jalankan dari root proyek: python3 src/05_laporan.py
import os
import numpy as np
import pandas as pd
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, precision_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
BIAYA_RETENSI = 500_000 # rupiah per penawaran retensi
NILAI_PELANGGAN = 4_000_000 # rupiah per pelanggan yang berhasil dipertahankan
def rupiah(n):
return "Rp" + f"{n:,.0f}".replace(",", ".")
# ---------- Data dan model (identik dengan Bagian 3) ----------
df = pd.read_csv("data/churn.csv")
X = df.drop(columns="churn")
y = df["churn"]
X_latih, X_uji, y_latih, y_uji = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42
)
y_uji_np = y_uji.to_numpy()
fitur_numerik = ["usia", "tenure_bulan", "tagihan_bulanan_ribu",
"komplain_12bulan"]
fitur_kategorikal = ["layanan"]
pra = ColumnTransformer(
transformers=[
(
"angka",
Pipeline(
steps=[
("imputasi", SimpleImputer(strategy="median")),
("skala", StandardScaler()),
]
),
fitur_numerik,
),
(
"kategori",
OneHotEncoder(handle_unknown="ignore"),
fitur_kategorikal,
),
]
)
pipeline = ImbPipeline(
steps=[
("pra", pra),
("smote", SMOTE(random_state=42)),
("model", LogisticRegression(max_iter=1000, random_state=42)),
]
)
pipeline.fit(X_latih, y_latih)
proba = pipeline.predict_proba(X_uji)[:, 1]
n_churn_uji = int((y_uji_np == 1).sum())
pr_auc = average_precision_score(y_uji, proba)
# ---------- Evaluasi satu ambang ----------
def evaluasi_ambang(ambang):
tawaran = proba >= ambang
n_tawaran = int(tawaran.sum())
tp = int((tawaran & (y_uji_np == 1)).sum())
fn = int(((~tawaran) & (y_uji_np == 1)).sum())
presisi = tp / n_tawaran if n_tawaran > 0 else 0.0
recall = tp / n_churn_uji
biaya = n_tawaran * BIAYA_RETENSI + fn * NILAI_PELANGGAN
return presisi, recall, n_tawaran, fn, biaya
# ---------- Kurva precision-recall tersampel tiap ambang ----------
print("== Kurva precision-recall dan biaya per ambang (data uji) ==")
print(f"{'ambang':>6s} {'presisi':>8s} {'recall':>7s} "
f"{'tawaran':>8s} {'FN':>3s} {'biaya':>14s}")
for i in range(1, 10):
ambang = round(0.1 * i, 1)
p, r, n_t, fn, b = evaluasi_ambang(ambang)
print(f"{ambang:6.2f} {p:8.3f} {r:7.3f} {n_t:8d} {fn:3d} "
f"{rupiah(b):>14s}")
# ---------- Ambang optimal berbasis biaya ----------
kisi = np.round(np.arange(0.02, 0.99, 0.01), 2)
biaya_kisi = [evaluasi_ambang(float(t))[4] for t in kisi]
i_opt = 0
for i, b in enumerate(biaya_kisi):
if b <= biaya_kisi[i_opt]:
i_opt = i
ambang_opt = float(kisi[i_opt])
p_opt, r_opt, n_t_opt, fn_opt, biaya_opt = evaluasi_ambang(ambang_opt)
print("\n== Ambang optimal berbasis biaya ==")
print(f"ambang terpilih : {ambang_opt:.2f} (kisi 0.02-0.98 langkah 0.01)")
print(f"presisi : {p_opt:.3f}")
print(f"recall : {r_opt:.3f}")
print(f"total biaya : {rupiah(biaya_opt)}")
# ---------- Perbandingan strategi ----------
pred_05 = pipeline.predict(X_uji)
presisi_05 = precision_score(y_uji, pred_05, zero_division=0)
recall_05 = float((pred_05 == 1)[y_uji_np == 1].mean())
_, _, _, _, biaya_05 = evaluasi_ambang(0.50)
biaya_tanpa_model = n_churn_uji * NILAI_PELANGGAN
biaya_menyeluruh = len(y_uji) * BIAYA_RETENSI
print("\n== Perbandingan strategi pada data uji ==")
print(f"{'strategi':32s} {'biaya':>14s}")
baris_strategi = [
("tanpa model", biaya_tanpa_model),
("retensi menyeluruh", biaya_menyeluruh),
("model + ambang 0.50", biaya_05),
("model + ambang terpilih", biaya_opt),
]
for nama, b in baris_strategi:
print(f"{nama:32s} {rupiah(b):>14s}")
hemat = 1.0 - biaya_opt / biaya_tanpa_model
print(f"\npenurunan biaya vs tanpa model : {hemat:.1%}")
# ---------- Tulis laporan teks ----------
os.makedirs("laporan", exist_ok=True)
baris = [
"=" * 64,
"LAPORAN PROYEK: KLASIFIKASI CHURN PELANGGAN TELECOM",
"=" * 64,
"",
"[RINGKASAN DATA]",
f"- Pelanggan : {len(df)} baris dari data/churn.csv",
"- Fitur : usia, tenure_bulan, layanan, "
"tagihan_bulanan_ribu, komplain_12bulan",
"- Label : churn (1 = berhenti berlangganan)",
"- Pembagian : 375 latih / 125 uji "
"(stratified, random_state=42)",
"",
"[KETIMPANGAN KELAS]",
f"- Pelanggan churn : {int((y == 1).sum())} dari {len(df)} "
f"({(y == 1).mean():.1%})",
"- Rasio kelas : 2.7 : 1 (bertahan : churn)",
"- Akibat : model yang menebak 'bertahan' untuk semua",
" pelanggan langsung mencapai akurasi 0.728",
"",
"[MODEL TERBAIK]",
"- Konfigurasi : pipeline imblearn = pra-pemrosesan",
" (imputasi median + StandardScaler + OneHotEncoder)",
" -> SMOTE(random_state=42) -> LogisticRegression",
"- Alasan : PR-AUC tertinggi di data uji "
f"({pr_auc:.3f})",
f"- Metrik data uji : presisi {presisi_05:.3f} | recall "
f"{recall_05:.3f} | PR-AUC {pr_auc:.3f} (ambang 0.50)",
"",
"[AMBANG TERPILIH]",
f"- Ambang : {ambang_opt:.2f}",
"- Kriteria : meminimalkan total biaya dengan asumsi",
" biaya retensi Rp500.000 per penawaran dan nilai pelanggan",
" Rp4.000.000",
f"- Kinerja ambang : presisi {p_opt:.3f} | recall {r_opt:.3f} | "
f"{n_t_opt} penawaran | {fn_opt} churner terlewat",
"",
"[DAMPAK BISNIS] (125 pelanggan uji, 34 churn)",
f"- Tanpa model : {rupiah(biaya_tanpa_model)}",
f"- Retensi menyeluruh : {rupiah(biaya_menyeluruh)}",
f"- Model, ambang 0.50 : {rupiah(biaya_05)}",
f"- Model, ambang terpilih : {rupiah(biaya_opt)}",
f"- Penurunan biaya vs tanpa model : {hemat:.1%}",
"",
"[ASUMSI DAN CATATAN]",
"- Penawaran retensi pada churner sejati dianggap selalu berhasil",
" mempertahankan pelanggan (TP tidak menimbulkan kerugian nilai).",
"- Setiap penawaran retensi tetap menimbulkan biaya Rp500.000,",
" baik kepada churner sejati maupun pelanggan yang akan bertahan.",
"- Angka dihitung pada data sintetis 500 pelanggan untuk keperluan",
" pembelajaran, bukan proyeksi operasional nyata.",
"",
"Tiga strategi penanganan imbalance dibandingkan pada Bagian 3;",
"laporan ini merangkum keputusan akhir proyek.",
]
with open("laporan/laporan-churn.txt", "w", encoding="utf-8") as f:
f.write("\n".join(baris) + "\n")
print("\n== Laporan ditulis ke laporan/laporan-churn.txt ==")
print("\n== Isi laporan/laporan-churn.txt ==")
with open("laporan/laporan-churn.txt", encoding="utf-8") as f:
print(f.read().rstrip("\n"))Output yang Dihasilkan:
== Kurva precision-recall dan biaya per ambang (data uji) ==
ambang presisi recall tawaran FN biaya
0.10 0.304 1.000 112 0 Rp56.000.000
0.20 0.370 1.000 92 0 Rp46.000.000
0.30 0.403 0.912 77 3 Rp50.500.000
0.40 0.429 0.794 63 7 Rp59.500.000
0.50 0.511 0.706 47 10 Rp63.500.000
0.60 0.525 0.618 40 13 Rp72.000.000
0.70 0.586 0.500 29 17 Rp82.500.000
0.80 0.667 0.294 15 24 Rp103.500.000
0.90 1.000 0.029 1 33 Rp132.500.000
== Ambang optimal berbasis biaya ==
ambang terpilih : 0.25 (kisi 0.02-0.98 langkah 0.01)
presisi : 0.395
recall : 1.000
total biaya : Rp43.000.000
== Perbandingan strategi pada data uji ==
strategi biaya
tanpa model Rp136.000.000
retensi menyeluruh Rp62.500.000
model + ambang 0.50 Rp63.500.000
model + ambang terpilih Rp43.000.000
penurunan biaya vs tanpa model : 68.4%
== Laporan ditulis ke laporan/laporan-churn.txt ==
== Isi laporan/laporan-churn.txt ==
================================================================
LAPORAN PROYEK: KLASIFIKASI CHURN PELANGGAN TELECOM
================================================================
[RINGKASAN DATA]
- Pelanggan : 500 baris dari data/churn.csv
- Fitur : usia, tenure_bulan, layanan, tagihan_bulanan_ribu, komplain_12bulan
- Label : churn (1 = berhenti berlangganan)
- Pembagian : 375 latih / 125 uji (stratified, random_state=42)
[KETIMPANGAN KELAS]
- Pelanggan churn : 135 dari 500 (27.0%)
- Rasio kelas : 2.7 : 1 (bertahan : churn)
- Akibat : model yang menebak 'bertahan' untuk semua
pelanggan langsung mencapai akurasi 0.728
[MODEL TERBAIK]
- Konfigurasi : pipeline imblearn = pra-pemrosesan
(imputasi median + StandardScaler + OneHotEncoder)
-> SMOTE(random_state=42) -> LogisticRegression
- Alasan : PR-AUC tertinggi di data uji (0.636)
- Metrik data uji : presisi 0.511 | recall 0.706 | PR-AUC 0.636 (ambang 0.50)
[AMBANG TERPILIH]
- Ambang : 0.25
- Kriteria : meminimalkan total biaya dengan asumsi
biaya retensi Rp500.000 per penawaran dan nilai pelanggan
Rp4.000.000
- Kinerja ambang : presisi 0.395 | recall 1.000 | 86 penawaran | 0 churner terlewat
[DAMPAK BISNIS] (125 pelanggan uji, 34 churn)
- Tanpa model : Rp136.000.000
- Retensi menyeluruh : Rp62.500.000
- Model, ambang 0.50 : Rp63.500.000
- Model, ambang terpilih : Rp43.000.000
- Penurunan biaya vs tanpa model : 68.4%
[ASUMSI DAN CATATAN]
- Penawaran retensi pada churner sejati dianggap selalu berhasil
mempertahankan pelanggan (TP tidak menimbulkan kerugian nilai).
- Setiap penawaran retensi tetap menimbulkan biaya Rp500.000,
baik kepada churner sejati maupun pelanggan yang akan bertahan.
- Angka dihitung pada data sintetis 500 pelanggan untuk keperluan
pembelajaran, bukan proyeksi operasional nyata.
Tiga strategi penanganan imbalance dibandingkan pada Bagian 3;
laporan ini merangkum keputusan akhir proyek.Mari kita bedah skrip evaluasi bisnis:
- Baris 23–24 (
BIAYA_RETENSI,NILAI_PELANGGAN): dua asumsi bisnis dinyatakan sebagai konstanta di puncak skrip. Mengubah nilai pelanggan menjadi Rp6.000.000, misalnya, cukup mengubah satu baris — seluruh analisis mengikuti. - Baris 26–27 (
rupiah): pemformat angka menjadiRp500.000dengan pemisah ribuan gaya Indonesia. - Baris 33–36 (
train_test_split): pembagian identik dengan Bagian 2 dan Bagian 3, sehingga 125 pelanggan uji dan 34 churner di antaranya sama persis. - Baris 42–70 (pipeline dan
proba): pemenang Bagian 3 dibangun ulang tanpa perubahan, lalupredict_probamengambil probabilitas churn untuk seluruh data uji — bahan baku kurva precision-recall. - Baris 76–84 (
evaluasi_ambang): jantung skrip. Satu fungsi menghitung lima hal untuk ambang mana pun: jumlah penawaran, presisi, recall, churner terlewat (FN), dan total biaya. Fungsi ini murni aritmetika atas vektor boolean — tidak ada estimator yang di-fit ulang. - Baris 90–94 (sampel kurva): sembilan ambang dicetak sebagai jendela ke kurva precision-recall.
- Baris 97–104 (pencarian ambang optimal): kisi 0,02 sampai 0,98 dievaluasi satu per satu; perbandingan
<=memilih kemunculan minimum terakhir sehingga bila beberapa ambang seri, ambang tertinggi — dengan penawaran paling sedikit — yang menang. Hasilnya 0,25. - Baris 113–131 (perbandingan strategi): tiga strategi tanpa model atau tanpa tuning dihitung sebagai pembanding: tanpa model sama sekali, menawarkan retensi ke seluruh pelanggan, dan model pada ambang default 0,50.
- Baris 134–196 (laporan): seluruh temuan ditulis ke
laporan/laporan-churn.txtlalu isi berkas dicetak kembali ke layar sebagai bukti.
Membaca Laporan Bisnis #
Empat strategi pada keluaran memperlihatkan rentang keputusan yang tersedia:
| Strategi | Total biaya | Catatan |
|---|---|---|
| Tanpa model | Rp136.000.000 | seluruh 34 churner hilang |
| Retensi menyeluruh | Rp62.500.000 | penawaran ke seluruh 125 pelanggan |
| Model, ambang 0,50 | Rp63.500.000 | ambang default |
| Model, ambang terpilih | Rp43.000.000 | 86 penawaran, 0 churner terlewat |
Dua pelajaran menonjol. Pertama, ambang default 0,50 menghasilkan biaya Rp63.500.000 — lebih mahal daripada menawarkan retensi ke seluruh pelanggan tanpa model sama sekali. Model yang sama, dipakai dengan ambang yang salah, nyaris tidak menambah nilai; baru setelah ambang dipilih dari sisi biaya, model menghasilkan Rp43.000.000 dan menutup 68,4% kerugian skenario tanpa model. Kedua, rasio biaya 1 berbanding 8 menjelaskan mengapa ambang optimal turun sampai 0,25: presisi 0,395 terlihat rendah untuk mata teknis, tetapi satu churner yang tertangani menutup biaya delapan penawaran yang meleset — dan pada ambang 0,25 seluruh 34 churner masih tertandai.
Proyek ini menutup siklusnya: data dibangkitkan dan dipahami (Bagian 1), preprocessing dan pembagian dirancang aman dari leakage (Bagian 2), ketimpangan kelas dihadapi dan diukur (Bagian 3), dan keputusan akhir diterjemahkan ke bahasa rupiah (Bagian 4). Langkah alami berikutnya berpindah dari pertanyaan “siapa yang akan pergi?” ke “siapa pelanggan kita, sebenarnya?” — pertanyaan tanpa label yang dijawab pada proyek berikutnya, Proyek: Segmentasi Pelanggan.
Referensi Resmi #
- sklearn.metrics.precision_recall_curve — API — menghitung pasangan presisi-recall untuk seluruh ambang.
- sklearn.metrics.average_precision_score — API — luas di bawah kurva precision-recall.
- Classification metrics — scikit-learn User Guide — precision-recall pada kelas positif dan interpretasinya.