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.

python
# 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:

text
== 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:

  1. 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.
  2. Baris 26–27 (rupiah): pemformat angka menjadi Rp500.000 dengan pemisah ribuan gaya Indonesia.
  3. 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.
  4. Baris 42–70 (pipeline dan proba): pemenang Bagian 3 dibangun ulang tanpa perubahan, lalu predict_proba mengambil probabilitas churn untuk seluruh data uji — bahan baku kurva precision-recall.
  5. 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.
  6. Baris 90–94 (sampel kurva): sembilan ambang dicetak sebagai jendela ke kurva precision-recall.
  7. 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.
  8. 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.
  9. Baris 134–196 (laporan): seluruh temuan ditulis ke laporan/laporan-churn.txt lalu isi berkas dicetak kembali ke layar sebagai bukti.

Membaca Laporan Bisnis #

Empat strategi pada keluaran memperlihatkan rentang keputusan yang tersedia:

StrategiTotal biayaCatatan
Tanpa modelRp136.000.000seluruh 34 churner hilang
Retensi menyeluruhRp62.500.000penawaran ke seluruh 125 pelanggan
Model, ambang 0,50Rp63.500.000ambang default
Model, ambang terpilihRp43.000.00086 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 #