Setiap proyek klasifikasi dimulai dari kebiasaan yang sama: muat data, periksa kualitasnya, dan pahami distribusi label sebelum satu pun model disentuh. Pada proyek churn, langkah ini lebih penting dari biasanya karena jawaban atas pertanyaan “seberapa timpang kelasnya?” akan menentukan hampir semua keputusan di bagian-bagian berikutnya. Pada bagian ini kita bangkitkan dataset pelanggan telecom, memverifikasi bahwa data bersih, lalu mengukur laju churn per segmen untuk menemukan pelanggan seperti apa yang paling rawan pergi.

Membangkitkan Dataset Pelanggan #

Proyek ini memakai data sintetis demi reproduksibilitas penuh: satu generator np.random.default_rng(42) menentukan seluruh nilai, sehingga siapa pun yang menjalankan skrip mendapat berkas CSV identik. Struktur datanya meniru data pelanggan telecom: lima fitur pelanggan (usia, masa berlangganan, jenis layanan, tagihan bulanan, jumlah komplain) dan satu label biner churn.

Label tidak diundi begitu saja. Skrip menghitung skor logistik dari fitur — tenure pendek, komplain banyak, layanan Mobile, dan tagihan di atas rata-rata sama-sama menaikkan skor — lalu mengubah skor menjadi peluang melalui fungsi sigmoid, dan menarik label churn dari peluang tersebut. Mekanisme ini sengaja dipilih karena bentuknya sama dengan LogisticRegression yang akan kita latih di Bagian 3: sinyalnya nyata, tetapi tetap tertutup derau pengambilan sampel, persis seperti data pelanggan sungguhan yang tidak pernah bisa diprediksi sempurna.

python
# Bangkitkan dataset pelanggan telecom sintetis untuk proyek churn.
# Deterministik penuh: satu generator rng berseed 42, tanpa sumber acak lain.
# Jalankan dari root proyek: python3 src/01_bangun_data.py
# Keluaran: data/churn.csv (500 baris, kolom usia, tenure_bulan, layanan,
# tagihan_bulanan_ribu, komplain_12bulan, churn).

import os

import numpy as np
import pandas as pd

# ---------- Parameter proses pembangkitan ----------
SEED = 42
N = 500

# Koefisien model logistik yang dipakai untuk menghasilkan label churn:
# logit = B0 - 0.045*tenure + 0.80*komplain + 0.75*(layanan Mobile)
#         + 0.004*(tagihan - 175)
# Maknanya: tenure pendek, komplain banyak, layanan Mobile, dan tagihan
# di atas rata-rata menaikkan peluang churn.
B0 = -0.40
B_TENURE = -0.045
B_KOMPLAIN = 0.80
B_MOBILE = 0.75
B_TAGIHAN = 0.004

rng = np.random.default_rng(SEED)

# ---------- Fitur pelanggan ----------
usia = rng.integers(19, 66, size=N)                        # tahun
tenure_bulan = rng.integers(0, 73, size=N)                 # 0..72 bulan
layanan = rng.choice(
    ["Mobile", "Internet", "Bundle"], size=N, p=[0.40, 0.35, 0.25]
)
tagihan_bulanan_ribu = np.clip(
    rng.normal(175, 45, size=N), 60, 450
).round(0)                                                  # ribu rupiah
komplain_12bulan = np.minimum(
    rng.poisson(0.8, size=N), 6
)                                                           # hitungan

# ---------- Label churn dari model logistik ----------
logit = (
    B0
    + B_TENURE * tenure_bulan
    + B_KOMPLAIN * komplain_12bulan
    + B_MOBILE * (layanan == "Mobile")
    + B_TAGIHAN * (tagihan_bulanan_ribu - 175)
)
peluang = 1.0 / (1.0 + np.exp(-logit))
churn = (rng.random(size=N) < peluang).astype(int)

pelanggan = pd.DataFrame(
    {
        "usia": usia,
        "tenure_bulan": tenure_bulan,
        "layanan": layanan,
        "tagihan_bulanan_ribu": tagihan_bulanan_ribu,
        "komplain_12bulan": komplain_12bulan,
        "churn": churn,
    }
)

os.makedirs("data", exist_ok=True)
pelanggan.to_csv("data/churn.csv", index=False)

# ---------- Ringkasan untuk pemeriksaan ----------
print("== Ringkasan pembangkitan data ==")
print(f"jumlah baris : {len(pelanggan)}")
print(f"jumlah kolom : {pelanggan.shape[1]}")
print(f"kolom        : {list(pelanggan.columns)}")
print(f"berkas       : data/churn.csv")

print("\n== Distribusi jenis layanan ==")
for nama, jumlah in pelanggan["layanan"].value_counts().items():
    print(f"{nama:8s}: {jumlah:3d} ({jumlah / len(pelanggan):.1%})")

print("\n== Distribusi label churn ==")
for nilai, jumlah in pelanggan["churn"].value_counts().sort_index().items():
    label = "bertahan" if nilai == 0 else "churn"
    print(f"{label:9s}: {jumlah:3d} ({jumlah / len(pelanggan):.1%})")

print("\n== Laju churn per jenis layanan ==")
for nama, laju in pelanggan.groupby("layanan")["churn"].mean().items():
    print(f"{nama:8s}: {laju:.3f}")

print("\n== Lima baris pertama ==")
print(pelanggan.head(5).to_string(index=False))

Output yang Dihasilkan:

text
== Ringkasan pembangkitan data ==
jumlah baris : 500
jumlah kolom : 6
kolom        : ['usia', 'tenure_bulan', 'layanan', 'tagihan_bulanan_ribu', 'komplain_12bulan', 'churn']
berkas       : data/churn.csv

== Distribusi jenis layanan ==
Mobile  : 206 (41.2%)
Internet: 161 (32.2%)
Bundle  : 133 (26.6%)

== Distribusi label churn ==
bertahan : 365 (73.0%)
churn    : 135 (27.0%)

== Laju churn per jenis layanan ==
Bundle  : 0.226
Internet: 0.236
Mobile  : 0.325

== Lima baris pertama ==
 usia  tenure_bulan  layanan  tagihan_bulanan_ribu  komplain_12bulan  churn
   23             8 Internet                 142.0                 1      1
   55            47   Bundle                 217.0                 1      0
   49            70   Bundle                 177.0                 0      0
   39            63 Internet                 162.0                 2      0
   39            20 Internet                 170.0                 0      0

Mari kita bedah skrip pembangkit data baris-per-baris:

  1. Baris 13–14 (SEED = 42, N = 500): konstanta ukuran dataset. Seed 42 dipakai satu kali di baris 27 untuk membangun generator; tidak ada sumber acak lain di seluruh skrip.
  2. Baris 21–25 (B0 sampai B_TAGIHAN): lima koefisien model logistik pembangkit label. Tanda minus pada B_TENURE berarti tenure panjang menurunkan skor churn; tanda positif pada B_KOMPLAIN dan B_MOBILE berarti keduanya menaikkannya.
  3. Baris 30–31 (rng.integers): usia diambil seragam dari 19 sampai 65 tahun; tenure dari 0 sampai 72 bulan. Distribusi seragam menjaga setiap kelompok tenure terwakili.
  4. Baris 32–34 (rng.choice): jenis layanan dimainkan dengan proporsi 40% Mobile, 35% Internet, 25% Bundle — proporsi khas operator yang mayoritas pelanggannya berlangganan paket data seluler.
  5. Baris 35–40 (rng.normal dan rng.poisson): tagihan mengikuti distribusi normal rata-rata Rp175.000 (disimpan dalam ribuan) yang dipangkas ke rentang wajar; komplain mengikuti distribusi Poisson dengan rata-rata 0,8 per tahun dan dibatasi maksimum 6.
  6. Baris 43–51 (logit hingga churn): skor logistik dihitung dari kelima koefisien, diubah menjadi peluang lewat 1 / (1 + np.exp(-logit)), lalu label ditarik dengan membandingkan nilai acak seragam terhadap peluang tersebut — cara standar mensimulasikan Bernoulli. Hasilnya 135 dari 500 pelanggan (27,0%) tergolong churn.
  7. Baris 53–65 (pd.DataFrame dan to_csv): keenam kolom dirakit menjadi DataFrame lalu ditulis ke data/churn.csv tanpa indeks.
  8. Baris 74–88 (ringkasan): distribusi layanan, distribusi label, laju churn per layanan, dan lima baris pertama dicetak agar kita bisa memeriksa hasil tanpa membuka berkas secara manual.

Memeriksa Kualitas dan Distribusi Churn #

Dataset yang baru dibangkitkan atau baru diterima dari pihak lain wajib melewati gerbang kualitas yang sama: berapa baris dan kolomnya, apakah ada nilai hilang, apakah ada baris ganda, dan apa tipe tiap kolom. Skrip eksplorasi kemudian melangkah ke pertanyaan yang lebih substantif — bagaimana churn tersebar per segmen pelanggan — dengan memakai pd.cut untuk membentuk kelompok tenure dan kelompok jumlah komplain.

python
# Eksplorasi data churn: muat data/churn.csv, periksa kualitasnya, lalu
# ukur distribusi churn per segmen pelanggan. Semua angka dihitung dari
# berkas CSV yang sama dengan hasil src/01_bangun_data.py.
# Jalankan dari root proyek: python3 src/02_eksplorasi.py

import pandas as pd

df = pd.read_csv("data/churn.csv")

# ---------- Kualitas data ----------
print("== Muat data ==")
print(f"bentuk tabel : {df.shape[0]} baris x {df.shape[1]} kolom")
print(f"nilai hilang : {int(df.isna().sum().sum())}")
print(f"baris ganda : {int(df.duplicated().sum())}")
print("\n== Tipe kolom ==")
for nama, tipe in df.dtypes.items():
    print(f"{nama:22s}: {str(tipe)}")

print("\n== Statistik fitur numerik ==")
for kolom in ["usia", "tenure_bulan", "tagihan_bulanan_ribu",
              "komplain_12bulan"]:
    s = df[kolom]
    print(f"{kolom:22s}: min {s.min():6.1f}  rata-rata {s.mean():6.1f}"
          f"  maks {s.max():6.1f}")

# ---------- Distribusi churn keseluruhan ----------
print("\n== Distribusi churn keseluruhan ==")
jumlah = df["churn"].value_counts().sort_index()
for nilai, n in jumlah.items():
    label = "bertahan" if nilai == 0 else "churn"
    print(f"{label:9s}: {n:3d} pelanggan ({n / len(df):.1%})")
rasio = jumlah[0] / jumlah[1]
print(f"rasio kelas (bertahan : churn) = {rasio:.1f} : 1")

# ---------- Churn per segmen ----------
def laju_churn_per_segmen(kolom_segmen, urutan, label_segmen):
    print(f"\n== Laju churn per {label_segmen} ==")
    print(f"{'segmen':10s} {'n':>4s} {'churn':>7s}")
    for seg in urutan:
        sub = df[kolom_segmen == seg]
        if len(sub) == 0:
            continue
        print(f"{str(seg):10s} {len(sub):4d} {sub['churn'].mean():7.3f}")

laju_churn_per_segmen(
    df["layanan"],
    ["Mobile", "Internet", "Bundle"],
    "jenis layanan",
)

segmen_tenure = pd.cut(
    df["tenure_bulan"],
    bins=[-1, 12, 24, 48, 72],
    labels=["0-12", "13-24", "25-48", "49-72"],
)
laju_churn_per_segmen(segmen_tenure, ["0-12", "13-24", "25-48", "49-72"],
                      "kelompok tenure (bulan)")

segmen_komplain = pd.cut(
    df["komplain_12bulan"],
    bins=[-1, 0, 2, 6],
    labels=["0", "1-2", "3+"],
)
laju_churn_per_segmen(segmen_komplain, ["0", "1-2", "3+"],
                      "komplain 12 bulan")

# ---------- Perbandingan profil churn vs bertahan ----------
print("\n== Rata-rata fitur: churn vs bertahan ==")
print(f"{'fitur':22s} {'bertahan':>9s} {'churn':>9s}")
for kolom in ["tenure_bulan", "komplain_12bulan",
              "tagihan_bulanan_ribu", "usia"]:
    m0 = df.loc[df["churn"] == 0, kolom].mean()
    m1 = df.loc[df["churn"] == 1, kolom].mean()
    print(f"{kolom:22s} {m0:9.2f} {m1:9.2f}")

Output yang Dihasilkan:

text
== Muat data ==
bentuk tabel : 500 baris x 6 kolom
nilai hilang : 0
baris ganda : 0

== Tipe kolom ==
usia                  : int64
tenure_bulan          : int64
layanan               : object
tagihan_bulanan_ribu  : float64
komplain_12bulan      : int64
churn                 : int64

== Statistik fitur numerik ==
usia                  : min   19.0  rata-rata   42.0  maks   65.0
tenure_bulan          : min    0.0  rata-rata   36.5  maks   72.0
tagihan_bulanan_ribu  : min   60.0  rata-rata  176.0  maks  301.0
komplain_12bulan      : min    0.0  rata-rata    0.8  maks    5.0

== Distribusi churn keseluruhan ==
bertahan : 365 pelanggan (73.0%)
churn    : 135 pelanggan (27.0%)
rasio kelas (bertahan : churn) = 2.7 : 1

== Laju churn per jenis layanan ==
segmen        n   churn
Mobile      206   0.325
Internet    161   0.236
Bundle      133   0.226

== Laju churn per kelompok tenure (bulan) ==
segmen        n   churn
0-12        100   0.510
13-24        67   0.373
25-48       167   0.263
49-72       166   0.090

== Laju churn per komplain 12 bulan ==
segmen        n   churn
0           234   0.184
1-2         244   0.316
3+           22   0.682

== Rata-rata fitur: churn vs bertahan ==
fitur                   bertahan     churn
tenure_bulan               41.19     23.76
komplain_12bulan            0.66      1.10
tagihan_bulanan_ribu      174.37    180.55
usia                       41.53     43.14

Bedah kode skrip eksplorasi:

  1. Baris 8 (pd.read_csv): memuat berkas yang dihasilkan Bagian sebelumnya. Seluruh angka pada bagian ini dihitung dari CSV yang sama — bukan dari array di memori — sehingga analisis dan pembangkitan data terpisah bersih.
  2. Baris 13–14 (isna dan duplicated): dua gerbang kualitas. Nilai hilang dan baris ganda sama-sama nol; data sintetis memang bersih, tetapi kebiasaan memeriksanya tetap wajib karena data produksi tidak pernah sebersih ini.
  3. Baris 28–33 (value_counts dan rasio): distribusi label dihitung lalu diringkas menjadi rasio kelas 2,7 berbanding 1 — angka yang akan dikutip ulang di Bagian 2 dan Bagian 3 saat menilai strategi penanganan imbalance.
  4. Baris 36–43 (fungsi laju_churn_per_segmen): satu fungsi dipakai ulang untuk tiga segmentasi berbeda. Pemeriksaan len(sub) == 0 menjaga fungsi tetap hidup saat sebuah segmen kosong.
  5. Baris 51–55 dan 59–63 (pd.cut): tenure dibelah menjadi empat kelompok (0–12, 13–24, 25–48, 49–72 bulan) dan komplain menjadi tiga (0, 1–2, 3+). Batas kelompok dipilih sebelum melihat hasil agar tidak tergoda menyetel batas sesuai keinginan.
  6. Baris 72–73 (df.loc[...]): rata-rata tiap fitur dihitung terpisah untuk pelanggan bertahan dan pelanggan churn — profil ringkas kedua kelompok.

Temuan yang Membentuk Keputusan Berikutnya #

Empat temuan dari output di atas akan menuntun seluruh proyek. Tabel berikut merangkum laju churn per segmen terpenting:

SegmenJumlah pelangganLaju churn
Tenure 0–12 bulan1000,510
Tenure 13–24 bulan670,373
Tenure 25–48 bulan1670,263
Tenure 49–72 bulan1660,090
Komplain 0 kali2340,184
Komplain 1–2 kali2440,316
Komplain 3+ kali220,682
Layanan Mobile2060,325

Pertama, tenure adalah prediktor terkuat: pelanggan baru (0–12 bulan) churn 0,510 — hampir enam kali laju pelanggan setia (49–72 bulan, 0,090). Kedua, komplain menunjukkan gradien yang sama arah: pelanggan dengan tiga komplain atau lebih churn 0,682. Ketiga, layanan Mobile mencatat laju churn tertinggi (0,325) dibanding Internet dan Bundle. Keempat — dan inilah temuan yang paling menentukan — kelas churn hanya 27% dari data dengan rasio 2,7 : 1.

Data sudah ada dan karakternya dikenal. Langkah berikutnya adalah menyiapkan mesin pemrosesannya: memisahkan fitur dari label, merakit pra-pemrosesan numerik dan kategorikal dalam satu Pipeline, dan membagi data secara stratified — dengan satu aturan mutlak bahwa pembagian data harus terjadi sebelum resampling apa pun. Semua itu dikerjakan pada Bagian 2: Preprocessing dan Split.

Referensi Resmi #