10  Time Series & Forecasting

Tujuan pembelajaran. Setelah menyelesaikan bab ini, kita akan mampu: 1. Membedakan data cross-sectional dari data time series, dan memahami mengapa keduanya membutuhkan pendekatan berbeda. 2. Mendekomposisi time series menjadi tren, musiman, dan residual. 3. Menerapkan metode forecasting klasik—exponential smoothing dan ARIMA—pada data nyata. 4. Memvalidasi model time series dengan rolling-origin cross-validation, bukan random split. 5. Mengevaluasi forecast dengan metrik yang tepat (MAE, RMSE, MAPE). 6. Menerapkan analisis survival untuk menjawab “kapan” pelanggan churn, bukan hanya “apakah”. 7. Memahami mengapa forecasting adalah fondasi untuk simulasi keputusan di Bagian III.

10.1 Mengapa Kita Beralih Dataset

Sampai Bab 9, semua analisis kita menggunakan Telco Customer Churn—dataset cross-sectional dengan satu baris per pelanggan dan tanpa dimensi waktu eksplisit. Dataset ini sangat cocok untuk klasifikasi: kita memprediksi apakah seorang pelanggan akan churn berdasarkan profilnya saat ini.

Tetapi tidak semua pertanyaan analytics dapat dijawab dengan struktur data seperti itu. Pertanyaan-pertanyaan berikut membutuhkan dimensi waktu:

  • Berapa permintaan produk bulan depan?
  • Kapan pelanggan akan churn (bukan hanya apakah)?
  • Bagaimana pola musiman penjualan sepanjang tahun?
  • Berapa banyak staf yang perlu dijadwalkan minggu depan?

Untuk menjawabnya, kita membutuhkan data time series: urutan observasi yang diambil pada interval waktu teratur.

Important

Dataset pendamping lapis kedua. Di awal buku, kita menyepakati strategi dua lapis data: - Lapis 1 — Telco Churn: dataset utama untuk Bab 1–9, 11, 13–15, 18. - Lapis 2 — Retail Store Inventory Forecasting: dataset pendamping untuk modul yang membutuhkan struktur waktu dan aliran—yaitu Bab 10, 12, 16, 17.

Bab ini adalah perkenalan pertama dengan dataset retail. Kita akan kembali ke Telco di akhir bab untuk analisis survival, tetapi fokus utama bab ini adalah retail.

10.2 Memuat Dataset Retail

Dataset Retail Store Inventory Forecasting berisi data harian penjualan dan inventori dari 5 toko dan 20 produk selama dua tahun (1 Januari 2022 – 1 Januari 2024). Total sekitar 73.100 baris.

Jika belum diunduh, ikuti instruksi di README.md atau unduh dari Kaggle:

Code
# Jika belum ada, unduh dari Kaggle:
# https://www.kaggle.com/datasets/anirudhchauhan/retail-store-inventory-forecasting-dataset
Code
import sys
sys.path.insert(0, ".")
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings("ignore")

from decanal.utils import load_retail

df_retail = load_retail()
print(f"Bentuk: {df_retail.shape[0]:,} baris × {df_retail.shape[1]} kolom")
print(f"\nKolom: {df_retail.columns.tolist()}")
print(f"\nRentang tanggal: {df_retail['Date'].min()} → {df_retail['Date'].max()}")
print(f"Jumlah toko    : {df_retail['Store ID'].nunique()}")
print(f"Jumlah produk  : {df_retail['Product ID'].nunique()}")
Bentuk: 73,100 baris × 15 kolom

Kolom: ['Date', 'Store ID', 'Product ID', 'Category', 'Region', 'Inventory Level', 'Units Sold', 'Units Ordered', 'Demand Forecast', 'Price', 'Discount', 'Weather Condition', 'Holiday/Promotion', 'Competitor Pricing', 'Seasonality']

Rentang tanggal: 2022-01-01 → 2024-01-01
Jumlah toko    : 5
Jumlah produk  : 20

10.2.1 Inspeksi Awal

Code
df_retail["Date"] = pd.to_datetime(df_retail["Date"])

# Beberapa baris pertama
df_retail[["Date", "Store ID", "Product ID", "Category", "Region",
           "Inventory Level", "Units Sold", "Units Ordered",
           "Price", "Discount", "Weather Condition", "Holiday/Promotion"]].head()
Date Store ID Product ID Category Region Inventory Level Units Sold Units Ordered Price Discount Weather Condition Holiday/Promotion
0 2022-01-01 S001 P0001 Groceries North 231 127 55 33.50 20 Rainy 0
1 2022-01-01 S001 P0002 Toys South 204 150 66 63.01 20 Sunny 0
2 2022-01-01 S001 P0003 Toys West 102 65 51 27.99 10 Sunny 1
3 2022-01-01 S001 P0004 Toys North 469 61 164 32.72 10 Cloudy 1
4 2022-01-01 S001 P0005 Electronics East 166 14 135 73.64 0 Sunny 0
Code
# Ringkasan numerik
df_retail[["Inventory Level", "Units Sold", "Units Ordered",
           "Price", "Discount", "Competitor Pricing"]].describe().round(2)
Inventory Level Units Sold Units Ordered Price Discount Competitor Pricing
count 73100.00 73100.00 73100.00 73100.00 73100.00 73100.00
mean 274.47 136.46 110.00 55.14 10.01 55.15
std 129.95 108.92 52.28 26.02 7.08 26.19
min 50.00 0.00 20.00 10.00 0.00 5.03
25% 162.00 49.00 65.00 32.65 5.00 32.68
50% 273.00 107.00 110.00 55.05 10.00 55.01
75% 387.00 203.00 155.00 77.86 15.00 77.82
max 500.00 499.00 200.00 100.00 20.00 104.94
Tip

Perhatikan struktur data. Berbeda dari Telco: - Satu baris = kombinasi (tanggal, toko, produk), bukan satu pelanggan. - Target potensial = Units Sold (kontinu), bukan churn (biner). - Dimensi waktu = Date—inilah yang memungkinkan analisis time series.

Dengan struktur ini, kita bisa menjawab pertanyaan seperti: “Berapa unit produk X yang akan terjual di toko Y bulan depan?”

10.3 Dari Cross-Sectional ke Time Series

Sebelum masuk ke teknik, penting memahami mengapa time series memerlukan pendekatan berbeda.

Aspek Cross-sectional (Telco) Time series (Retail)
Unit observasi Pelanggan (independen) Titik waktu (berurutan)
Asumsi independensi Observasi saling independen Observasi saling bergantung (autokorelasi)
Split data Random train/test split Chronological split (train = masa lalu, test = masa depan)
Validasi Cross-validation biasa Rolling-origin cross-validation
Metrik AUC, precision, recall MAE, RMSE, MAPE
Pertanyaan “Apakah pelanggan ini akan churn?” “Berapa permintaan minggu depan?”
Warning

Kesalahan paling umum di time series. Menggunakan train_test_split acak untuk data time series. Ini data leakage—kita melatih model pada data dari masa depan untuk memprediksi masa lalu. Model akan tampak sangat baik selama pengujian, tetapi gagal total di dunia nyata karena di produksi kita hanya punya data masa lalu.

Aturan emas: selalu split berdasarkan waktu. Train = periode lebih awal, test = periode lebih akhir.

10.4 Agregasi: Dari Transaksi ke Deret Waktu

Dataset retail berisi data granular per (tanggal, toko, produk). Untuk forecasting permintaan total, kita perlu mengagregasi ke level yang tepat. Mari kita lihat total unit terjual per hari di semua toko:

Code
daily = (df_retail
         .groupby("Date", as_index=False)
         .agg(total_sold=("Units Sold", "sum"),
              total_ordered=("Units Ordered", "sum"),
              n_transactions=("Units Sold", "size")))

print(f"Jumlah hari: {len(daily)}")
print(f"Rentang    : {daily['Date'].min().date()} → {daily['Date'].max().date()}")
daily.head()
Jumlah hari: 731
Rentang    : 2022-01-01 → 2024-01-01
Date total_sold total_ordered n_transactions
0 2022-01-01 14484 10921 100
1 2022-01-02 13415 10770 100
2 2022-01-03 13681 11096 100
3 2022-01-04 14084 11572 100
4 2022-01-05 12572 10919 100
Code
fig, ax = plt.subplots(figsize=(11, 4))
ax.plot(daily["Date"], daily["total_sold"], color="#4C72B0", linewidth=1, alpha=0.7)
ax.set_xlabel("Tanggal")
ax.set_ylabel("Total unit terjual")
ax.set_title("Penjualan Harian — Semua Toko (2022–2024)")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Figure 10.1: Total unit terjual harian di semua toko. Perhatikan pola berulang dan tren yang mungkin.

Amati grafik ini. Tiga pola yang perlu kita kenali:

  1. Tren jangka panjang — apakah penjualan cenderung naik, turun, atau datar sepanjang dua tahun?
  2. Pola musiman — apakah ada siklus berulang (mingguan, bulanan, tahunan)?
  3. Noise / residual — variasi acak yang tersisa setelah tren dan musiman dihilangkan.

10.5 Dekomposisi Time Series

Dekomposisi adalah proses memisahkan time series menjadi tiga komponen:

\[y_t = T_t + S_t + R_t \quad \text{(aditif)}\] \[y_t = T_t \times S_t \times R_t \quad \text{(multiplikatif)}\]

  • \(T_t\) (Tren): arah jangka panjang.
  • \(S_t\) (Musiman): pola berulang dengan periode tetap.
  • \(R_t\) (Residual): sisa yang tidak dijelaskan.

10.5.1 Dekomposisi Aditif vs Multiplikatif

Model Kapan dipakai
Aditif Amplitudo musiman konstan sepanjang waktu
Multiplikatif Amplitudo musiman proporsional terhadap level tren

Untuk data penjualan yang tumbuh, model multiplikatif sering lebih tepat karena fluktuasi musiman juga membesar seiring pertumbuhan.

10.5.2 Melakukan Dekomposisi

Code
from statsmodels.tsa.seasonal import seasonal_decompose

# Set index ke Date untuk time series analysis
ts = daily.set_index("Date")["total_sold"].asfreq("D")

# Dekomposisi aditif dengan periode 7 (mingguan)
decomp_add = seasonal_decompose(ts, model="additive", period=7)
decomp_mult = seasonal_decompose(ts, model="multiplicative", period=7)
Code
fig, axes = plt.subplots(4, 1, figsize=(10, 8), sharex=True)
decomp_add.observed.plot(ax=axes[0], color="#4C72B0")
axes[0].set_ylabel("Observasi")
axes[0].set_title("Dekomposisi Aditif — Penjualan Harian")

decomp_add.trend.plot(ax=axes[1], color="#DD8452")
axes[1].set_ylabel("Tren")

decomp_add.seasonal.plot(ax=axes[2], color="#55A868")
axes[2].set_ylabel("Musiman")

decomp_add.resid.plot(ax=axes[3], color="#8172B3")
axes[3].set_ylabel("Residual")
axes[3].set_xlabel("Tanggal")

plt.tight_layout()
plt.show()
Figure 10.2: Dekomposisi aditif: observasi asli, tren, komponen musiman (mingguan), dan residual.

Cara membaca dekomposisi:

  • Tren: pola jangka panjang—apakah penjualan naik, turun, atau datar.
  • Musiman: pola berulang dengan periode 7 hari—menunjukkan hari apa penjualan tinggi/rendah. Jika pola musiman menunjukkan puncak di akhir pekan, itu adalah insight penting untuk staf scheduling dan inventory.
  • Residual: sisa setelah tren dan musiman dihilangkan—harusnya terlihat seperti white noise (acak tanpa pola). Jika ada pola tersisa di residual, berarti ada komponen lain yang belum ditangkap.
Tip

Mengapa dekomposisi berguna? Ia memisahkan sinyal dari noise. Tren memberi tahu kita arah bisnis; musiman memberi tahu kita pola operasional; residual memberi tahu kita seberapa banyak variasi yang tidak dapat dijelaskan model. Ketiga komponen ini akan menjadi input untuk forecasting dan—lebih penting—untuk simulasi ketidakpastian di Bab 12.

10.6 Uji Stasioneritas

Sebelum menerapkan model seperti ARIMA, kita perlu memeriksa apakah time series stasioner—artinya, properti statistiknya (mean, variance) tidak berubah sepanjang waktu. Banyak model time series klasik mengasumsikan stasioneritas.

Augmented Dickey-Fuller (ADF) test adalah uji yang paling umum:

  • H₀: time series memiliki unit root (tidak stasioner).
  • H₁: time series stasioner.
Code
from statsmodels.tsa.stattools import adfuller

result = adfuller(ts.dropna(), autolag="AIC")
print(f"ADF Statistic : {result[0]:.4f}")
print(f"p-value       : {result[1]:.4f}")
print(f"Kesimpulan    : ", end="")
if result[1] < 0.05:
    print("Tolak H₀ — series STASIONER")
else:
    print("Gagal tolak H₀ — series TIDAK STASIONER (perlu differencing)")
ADF Statistic : -26.3795
p-value       : 0.0000
Kesimpulan    : Tolak H₀ — series STASIONER

Jika series tidak stasioner, kita bisa melakukan differencing—menghitung selisih antar observasi berturut-turut:

Code
ts_diff = ts.diff().dropna()

result_diff = adfuller(ts_diff, autolag="AIC")
print(f"Setelah differencing:")
print(f"ADF Statistic : {result_diff[0]:.4f}")
print(f"p-value       : {result_diff[1]:.4f}")
Setelah differencing:
ADF Statistic : -10.7756
p-value       : 0.0000
Code
fig, axes = plt.subplots(2, 1, figsize=(10, 5), sharex=True)
axes[0].plot(ts.index, ts.values, color="#4C72B0")
axes[0].set_ylabel("Total terjual")
axes[0].set_title("Series Asli")

axes[1].plot(ts_diff.index, ts_diff.values, color="#DD8452")
axes[1].set_ylabel("Δ Total terjual")
axes[1].set_title("Setelah Differencing (order 1)")
axes[1].set_xlabel("Tanggal")
plt.tight_layout()
plt.show()
Figure 10.3: Perbandingan series asli dan setelah differencing. Differencing menghilangkan tren dan membuat mean konstan.

10.7 Forecasting: Meramalkan Masa Depan

Sekarang kita masuk ke inti bab: forecasting. Kita akan membandingkan tiga metode dengan kompleksitas yang berbeda.

10.7.1 Metode 1: Naive Forecast (Baseline)

Naive forecast memprediksi nilai besok = nilai hari ini. Sederhana, tetapi sering menjadi baseline yang sulit dikalahkan.

Code
# Split berdasarkan waktu: 80% train, 20% test
n = len(ts)
split_idx = int(n * 0.8)
train, test = ts.iloc[:split_idx], ts.iloc[split_idx:]

print(f"Train: {train.index.min().date()} → {train.index.max().date()} ({len(train)} hari)")
print(f"Test : {test.index.min().date()} → {test.index.max().date()} ({len(test)} hari)")

# Naive: prediksi = nilai terakhir train, konstan sepanjang horizon test
naive_pred = pd.Series([train.iloc[-1]] * len(test), index=test.index)
Train: 2022-01-01 → 2023-08-07 (584 hari)
Test : 2023-08-08 → 2024-01-01 (147 hari)

10.7.2 Metode 2: Exponential Smoothing (Holt-Winters)

Holt-Winters memperluas exponential smoothing dengan komponen tren dan musiman. Ia cocok untuk data dengan pola musiman berulang.

Code
from statsmodels.tsa.holtwinters import ExponentialSmoothing

hw_model = ExponentialSmoothing(
    train,
    trend="add",
    seasonal="add",
    seasonal_periods=7,  # mingguan
).fit()

hw_pred = hw_model.forecast(len(test))

10.7.3 Metode 3: ARIMA

ARIMA (AutoRegressive Integrated Moving Average) adalah model klasik yang menggabungkan: - AR (AutoRegressive): regresi terhadap nilai masa lalu. - I (Integrated): differencing untuk stasioneritas. - MA (Moving Average): regresi terhadap error masa lalu.

Code
from statsmodels.tsa.arima.model import ARIMA

arima_model = ARIMA(train, order=(1, 1, 1)).fit()
arima_pred = arima_model.forecast(len(test))

10.7.4 Membandingkan Ketiga Metode

Code
fig, ax = plt.subplots(figsize=(11, 5))
ax.plot(train.index[-60:], train.values[-60:], color="#4C72B0", label="Train (60 hari terakhir)", linewidth=1.5)
ax.plot(test.index, test.values, color="black", label="Aktual (test)", linewidth=1.5)
ax.plot(test.index, naive_pred, color="#C44E52", linestyle="--", label="Naive", linewidth=1.5)
ax.plot(test.index, hw_pred, color="#DD8452", label="Holt-Winters", linewidth=2)
ax.plot(test.index, arima_pred, color="#55A868", linestyle=":", label="ARIMA(1,1,1)", linewidth=2)
ax.set_xlabel("Tanggal")
ax.set_ylabel("Total unit terjual")
ax.set_title("Perbandingan Metode Forecasting")
ax.legend(loc="upper left")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Figure 10.4: Perbandingan tiga metode forecasting pada test set. Holt-Winters menangkap pola musiman; naive hanya mengulang nilai terakhir.

10.8 Evaluasi Forecast: Metrik yang Berbeda

Tiga metrik paling umum untuk time series:

Metrik Formula Interpretasi Kapan dipakai
MAE \(\frac{1}{n}\sum \|y - \hat{y}\|\) Rata-rata kesalahan absolut (satuan target) Ketika outlier tidak terlalu penting
RMSE \(\sqrt{\frac{1}{n}\sum (y - \hat{y})^2}\) Akar rata-rata kuadrat kesalahan Ketika kesalahan besar sangat mahal
MAPE \(\frac{1}{n}\sum \left\|\frac{y - \hat{y}}{y}\right\| \times 100\) Rata-rata persentase kesalahan Ketika skala target bervariasi
Code
from sklearn.metrics import mean_absolute_error, mean_squared_error

def mape(y_true, y_pred):
    return np.mean(np.abs((y_true - y_pred) / y_true)) * 100

metrics = pd.DataFrame({
    "Metode": ["Naive", "Holt-Winters", "ARIMA(1,1,1)"],
    "MAE": [
        mean_absolute_error(test, naive_pred),
        mean_absolute_error(test, hw_pred),
        mean_absolute_error(test, arima_pred),
    ],
    "RMSE": [
        np.sqrt(mean_squared_error(test, naive_pred)),
        np.sqrt(mean_squared_error(test, hw_pred)),
        np.sqrt(mean_squared_error(test, arima_pred)),
    ],
    "MAPE (%)": [
        mape(test.values, naive_pred.values),
        mape(test.values, hw_pred.values),
        mape(test.values, arima_pred.values),
    ],
}).round(2)

metrics.set_index("Metode")
MAE RMSE MAPE (%)
Metode
Naive 974.97 1202.78 7.47
Holt-Winters 926.15 1138.11 7.03
ARIMA(1,1,1) 842.13 1050.34 6.26

Cara membaca:

  • MAE paling mudah diinterpretasikan: “rata-rata, prediksi kita meleset X unit per hari”.
  • RMSE lebih besar dari MAE jika ada kesalahan besar—karena ia mengkuadratkan kesalahan sebelum merata-ratakan.
  • MAPE berguna ketika skala target berubah-ubah, tetapi gagal jika ada nilai nol atau mendekati nol.
Warning

MAPE bukan selalu tepat. Jika data kita memiliki nilai nol atau sangat kecil, MAPE bisa meledak menjadi tak hingga. Untuk data penjualan dengan hari-hari sepi (nilai mendekati 0), MAE atau RMSE lebih aman. Gunakan MAPE hanya jika semua nilai target jauh dari nol.

10.9 Rolling-Origin Cross-Validation

Pembagian train/test tunggal bisa menyesatkan—terutama jika test set jatuh pada periode yang tidak biasa (mis. libur panjang). Rolling-origin cross-validation mengatasi ini dengan menggeser jendela pelatihan dan pengujian ke depan secara berurutan.

Code
fig, ax = plt.subplots(figsize=(9, 3.5))

n_windows = 4
window_size = 10
horizon = 3

for i in range(n_windows):
    start = i * window_size
    train_end = start + window_size
    test_end = train_end + horizon
    ax.barh(i, window_size, left=start, color="#4C72B0", edgecolor="white")
    ax.barh(i, horizon, left=train_end, color="#DD8452", edgecolor="white")
    ax.text(train_end + horizon + 0.3, i, f"iterasi {i+1}", va="center", fontsize=8)

ax.set_yticks(range(n_windows))
ax.set_yticklabels([f"Fold {i+1}" for i in range(n_windows)])
ax.set_xlabel("Waktu (indeks hari)")
ax.set_title("Rolling-Origin Cross-Validation")
ax.invert_yaxis()

# Legend manual
from matplotlib.patches import Patch
ax.legend(handles=[Patch(color="#4C72B0", label="Train"),
                    Patch(color="#DD8452", label="Test (horizon)")],
          loc="upper right")
plt.tight_layout()
plt.show()
Figure 10.5: Ilustrasi rolling-origin cross-validation: model dilatih pada data hingga titik t, diuji pada h+1 hingga h+k, lalu jendela digeser ke depan.
Code
from statsmodels.tsa.holtwinters import ExponentialSmoothing

def rolling_origin_cv(series, n_splits=5, horizon=7, seasonal_periods=7):
    """Rolling-origin cross-validation untuk Holt-Winters."""
    n = len(series)
    fold_size = (n - horizon) // n_splits
    maes = []

    for i in range(n_splits):
        train_end = (i + 1) * fold_size
        train_fold = series.iloc[:train_end]
        test_fold = series.iloc[train_end:train_end + horizon]

        if len(test_fold) < horizon:
            break

        try:
            model = ExponentialSmoothing(
                train_fold, trend="add", seasonal="add",
                seasonal_periods=seasonal_periods
            ).fit()
            pred = model.forecast(horizon)
            maes.append(mean_absolute_error(test_fold, pred))
        except Exception as e:
            print(f"Fold {i+1} gagal: {e}")

    return np.array(maes)

maes = rolling_origin_cv(ts, n_splits=5, horizon=7)
print(f"MAE per fold: {[f'{m:.2f}' for m in maes]}")
print(f"Rata-rata   : {maes.mean():.2f} ± {maes.std():.2f}")
MAE per fold: ['1095.49', '879.34', '837.36', '705.59', '1119.10']
Rata-rata   : 927.38 ± 157.88
Important

Mengapa rolling-origin lebih baik dari single split? Ia menguji model pada beberapa periode berbeda, sehingga estimasi performa lebih stabil dan lebih representatif. Model yang baik harus konsisten di seluruh fold—jika MAE sangat bervariasi antar fold, model mungkin tidak stabil.

10.10 Analisis Survival: Kembali ke Telco

Sekarang kita kembali ke dataset Telco. Di Bab 9, model klasifikasi menjawab “apakah pelanggan ini akan churn?” — tetapi tidak menjawab “kapan?”.

Analisis survival menjawab pertanyaan kedua—dan memberi kita pemahaman yang lebih kaya tentang churn.

10.10.1 Konsep Dasar

Analisis survival memodelkan waktu sampai kejadian (dalam kasus kita: bulan sampai churn). Ada tiga konsep inti:

  • Survival function \(S(t)\): probabilitas bertahan lebih lama dari waktu \(t\).
  • Hazard function \(h(t)\): probabilitas churn pada waktu \(t\), dengan asumsi bertahan sampai \(t\).
  • Censoring: pelanggan yang belum churn sampai akhir pengamatan (mereka “disensor”—kita tahu mereka bertahan setidaknya selama tenure mereka, tetapi tidak tahu kapan mereka akan churn).

10.10.2 Membangun Kurva Survival

Code
from decanal.utils import load_churn_clean

df_telco = load_churn_clean()

# Konvensi: event=1 jika churn, 0 jika disensor (belum churn)
# Duration = tenure (bulan)
df_surv = pd.DataFrame({
    "duration": df_telco["tenure"],
    "event": (df_telco["Churn"] == "Yes").astype(int),
    "contract": df_telco["Contract"],
})

print(f"Total pelanggan   : {len(df_surv):,}")
print(f"Churn (event=1)   : {df_surv['event'].sum():,} ({df_surv['event'].mean():.1%})")
print(f"Disensor (event=0): {(1 - df_surv['event']).sum():,}")
print(f"Median tenure     : {df_surv['duration'].median():.0f} bulan")
Total pelanggan   : 7,043
Churn (event=1)   : 1,869 (26.5%)
Disensor (event=0): 5,174
Median tenure     : 29 bulan
Code
from lifelines import KaplanMeierFitter

fig, ax = plt.subplots(figsize=(9, 5))

for contract, color in [("Month-to-month", "#DD8452"),
                        ("One year", "#55A868"),
                        ("Two year", "#4C72B0")]:
    subset = df_surv[df_surv["contract"] == contract]
    kmf = KaplanMeierFitter()
    kmf.fit(subset["duration"], subset["event"], label=contract)
    kmf.plot_survival_function(ax=ax, color=color, ci_show=True, alpha=0.7)

ax.set_xlabel("Tenure (bulan)")
ax.set_ylabel("Probabilitas bertahan")
ax.set_title("Kurva Survival Kaplan-Meier per Tipe Kontrak")
ax.grid(True, alpha=0.3)
ax.legend(title="Kontrak")
plt.tight_layout()
plt.show()
Figure 10.6: Kurva survival Kaplan-Meier per tipe kontrak. Kontrak bulanan jauh lebih cepat menurun daripada kontrak tahunan.

Cara membaca kurva survival:

  • Sumbu Y = probabilitas masih bertahan pada waktu t.
  • Sumbu X = waktu (bulan sejak bergabung).
  • Kurva turun tajam di awal = banyak churn di awal (churn dini).
  • Kurva melandai = pelanggan yang bertahan cukup lama cenderung bertahan lebih lama.

Perhatikan: kurva kontrak bulanan turun jauh lebih cepat daripada kontrak tahunan dan dua tahun. Ini mengkonfirmasi temuan Bagian I dengan cara yang lebih kaya—kita tidak hanya tahu apakah kontrak memengaruhi churn, tetapi juga kapan efeknya paling kuat.

Important

Censoring adalah kunci. Pelanggan yang belum churn sampai akhir pengamatan bukan “tidak churn”—mereka hanya belum churn sampai sekarang. Analisis survival menangani mereka dengan benar, sedangkan analisis proporsi sederhana (churn rate) mengabaikan dimensi waktu. Inilah mengapa analisis survival lebih informatif.

10.10.3 Uji Log-Rank: Apakah Kurva Berbeda Secara Signifikan?

Code
from lifelines.statistics import logrank_test

# Bandingkan kontrak bulanan vs dua tahun
monthly = df_surv[df_surv["contract"] == "Month-to-month"]
two_year = df_surv[df_surv["contract"] == "Two year"]

result = logrank_test(
    monthly["duration"], two_year["duration"],
    event_observed_A=monthly["event"],
    event_observed_B=two_year["event"],
)

print(f"Uji log-rank — bulanan vs dua tahun:")
print(f"  Statistic: {result.test_statistic:.2f}")
print(f"  p-value  : {result.p_value:.2e}")
if result.p_value < 0.05:
    print("  → Kurva survival BERBEDA secara signifikan")
else:
    print("  → Tidak cukup bukti bahwa kurva berbeda")
Uji log-rank — bulanan vs dua tahun:
  Statistic: 1550.51
  p-value  : 0.00e+00
  → Kurva survival BERBEDA secara signifikan

10.10.4 Implikasi untuk Keputusan

Analisis survival memberi kita parameter yang lebih kaya daripada model klasifikasi saja:

  • Timing intervensi. Hazard function yang tinggi di bulan-bulan awal menunjukkan bahwa intervensi retensi paling efektif dilakukan segera setelah onboarding.
  • Ekspektasi durasi. Median survival per segmen memberi tahu kita berapa lama pelanggan rata-rata bertahan, yang memengaruhi perhitungan Lifetime Value (LTV).
  • Prioritas segmen. Segmen dengan hazard tinggi di awal membutuhkan intervensi cepat; segmen dengan hazard rendah di awal tetapi naik belakangan membutuhkan intervensi berbeda.
Note

Analisis survival formal dengan model Cox Proportional Hazards—yang menguji efek multivariabel terhadap hazard—akan sangat berguna untuk keputusan retensi. Namun, ia membutuhkan asumsi proportional hazards yang tidak selalu terpenuhi. Untuk buku ini, kita cukup menggunakan Kaplan-Meier dan log-rank test sebagai pengantar. Pembaca yang tertarik dapat mengeksplorasi lifelines.CoxPHFitter secara mandiri.

10.11 Menghubungkan Kembali ke Dataset Retail

Sekarang kita memiliki dua kerangka time series: - Forecasting (retail): memprediksi nilai kontinu di masa depan. - Survival analysis (Telco): memodelkan waktu sampai kejadian.

Keduanya akan menjadi fondasi untuk simulasi Monte Carlo di Bab 12—di mana kita akan menggunakan distribusi probabilitas dari data historis untuk mensimulasikan skenario masa depan.

10.12 Latihan Terbimbing

Latihan 10.1 — Dekomposisi multiplikatif. Gunakan seasonal_decompose dengan model="multiplicative" pada daily["total_sold"]. Bandingkan plot residual aditif dan multiplikatif. Mana yang residualnya lebih “seperti noise”? Mengapa?

Latihan 10.2 — Agregasi mingguan. Alih-alih harian, agregasi data retail ke mingguan (resample("W").sum()). Lakukan dekomposisi dengan period=52. Apakah pola musiman tahunan terlihat? Bandingkan dengan dekomposisi harian.

Latihan 10.3 — ARIMA tuning. Coba beberapa kombinasi (p, d, q) untuk ARIMA—misalnya (1,1,1), (2,1,1), (1,1,2), (2,1,2). Bandingkan MAE pada test set. Model mana yang terbaik? Berapa lama waktu fitting untuk masing-masing?

Latihan 10.4 — Rolling-origin untuk ARIMA. Modifikasi fungsi rolling_origin_cv agar menggunakan ARIMA, bukan Holt-Winters. Bandingkan MAE rata-rata antara ARIMA dan Holt-Winters pada 5 fold. Metode mana yang lebih stabil?

Latihan 10.5 — Survival per segmen. Buat kurva Kaplan-Meier yang dipisah berdasarkan InternetService (bukan Contract). Apakah ada perbedaan survival antara pelanggan fiber optic, DSL, dan tanpa internet? Gunakan log-rank test untuk menguji signifikansi.

Latihan 10.6 — Hazard function. Plot hazard function (bukan survival function) dari KaplanMeierFitter menggunakan .cumulative_density_. Pada bulan berapa hazard paling tinggi? Apa implikasinya untuk strategi retensi?

Petunjuk Latihan 10.3. ARIMA dengan order lebih tinggi tidak selalu lebih baik. Model dengan terlalu banyak parameter bisa overfitting—sama seperti di Bab 7. Perhatikan juga waktu fitting: ARIMA(2,1,2) jauh lebih lambat dari ARIMA(1,1,1). Gunakan AIC (dari model.aic) sebagai panduan awal, tetapi validasi dengan test MAE.

10.13 Kotak “Jembatan”: Dari Prediksi ke Simulasi

Important

Jembatan. Bab ini memberi kita dua kemampuan baru:

  1. Forecasting—memprediksi nilai kontinu di masa depan (retail). Ini akan menjadi input untuk inventory optimization di Bab 16 dan simulasi keputusan di Bab 17.

  2. Survival analysis—memodelkan kapan kejadian terjadi (Telco). Ini memberi kita distribusi waktu churn, yang dapat digunakan untuk:

    • Menghitung ekspektasi revenue loss per segmen.
    • Menentukan timing intervensi retensi.
    • Memasukkan ketidakpastian waktu ke dalam model optimasi di Bagian III.

Tetapi forecasting memberi kita satu titik prediksi—bukan distribusi. Padahal, keputusan nyata selalu menghadapi ketidakpastian: - Permintaan bisa lebih tinggi atau lebih rendah dari forecast. - Churn bisa terjadi lebih cepat atau lebih lambat.

Simulasi Monte Carlo—topik Bab 12—akan menjembatani gap ini: ia menggunakan distribusi dari data historis untuk menghasilkan ribuan skenario masa depan, sehingga kita dapat mengevaluasi keputusan berdasarkan distribusi hasil, bukan hanya satu titik prediksi.

Bab 11 akan terlebih dahulu memperkenalkan unsupervised learning—clustering dan PCA—untuk menemukan segmen tersembunyi dalam data. Ini adalah jembatan terakhir sebelum Bagian III: setelah kita dapat memprediksi dan mengelompokkan, kita akan mensimulasikan dan mengoptimalkan.

10.14 Rangkuman

Dalam bab ini kita telah:

  • Beralih dari dataset Telco (cross-sectional) ke dataset retail (time series), memahami bahwa setiap dataset memiliki pertanyaan yang cocok—dan buku ini menggunakan strategi dua lapis untuk menutupi seluruh spektrum analytics.
  • Membedakan data cross-sectional dari time series, dan memahami bahwa time series membutuhkan chronological split, bukan random split.
  • Mendekomposisi time series menjadi tren, musiman, dan residual—memisahkan sinyal dari noise.
  • Menguji stasioneritas dengan ADF test dan melakukan differencing jika diperlukan.
  • Membangun tiga model forecasting—naive, Holt-Winters, ARIMA—dan membandingkannya dengan MAE, RMSE, dan MAPE.
  • Menerapkan rolling-origin cross-validation untuk estimasi performa yang lebih stabil dan representatif.
  • Menerapkan analisis survival pada dataset Telco, memahami perbedaan survival function, hazard function, dan censoring.
  • Menghubungkan survival analysis ke keputusan—timing intervensi, ekspektasi durasi, prioritas segmen.

10.15 Jembatan ke Bab 11

Sampai di sini, kita telah memprediksi nilai (Bab 8), kelas (Bab 9), dan waktu (Bab 10). Tetapi kita belum mengeksplorasi struktur data itu sendiri—apakah ada kelompok alami di dalamnya yang tidak kita ketahui?

Unsupervised learning—topik Bab 11—akan mengajarkan kita untuk menemukan segmen tersembunyi dalam data tanpa label:

  • Clustering (K-means, hierarchical): mengelompokkan pelanggan berdasarkan kemiripan profil.
  • PCA (Principal Component Analysis): mereduksi dimensi untuk visualisasi dan pemodelan.
  • Association rules: menemukan pola “jika pelanggan membeli X, mereka juga membeli Y”.

Bab 11 akan menjadi jembatan terakhir sebelum Bagian III. Setelah kita dapat memprediksi dan mengelompokkan, kita akan mensimulasikan dan mengoptimalkan—itulah inti dari prescriptive analytics.