Model Bagian 3 mengalahkan seasonal-naive pada satu split 14 hari, tetapi satu split hanyalah satu contoh. Model bisa saja beruntung pada periode yang kebetulan ramah, atau sebaliknya dihukum oleh satu event ekstrem — dan kita tidak bisa membedakan keduanya dari satu angka. Obatnya adalah walk-forward validation: evaluasi diulang pada beberapa jendela uji berurutan, dan pada tiap jendela model dilatih ulang dari nol memakai seluruh history sebelum jendela tersebut — siklus retraining yang akan dijalankan produksi. Pada bagian ini kita mengeksekusi lima jendela bergulir yang menutup 70 hari terakhir deret, membaca stabilitas model lintas waktu, lalu merangkai temuan proyek menjadi laporan teks final.

Walk-Forward dengan Jendela Bergulir #

Mekanisme evaluasinya meniru kalender operasional. Lima jendela uji masing-masing 14 hari disusun berurutan dari 2024-10-22 sampai 2024-12-30, tanpa celah dan tanpa tumpang tindih. Jendela pertama dilatih dari 660 hari history, jendela kedua dari 674, demikian seterusnya hingga jendela kelima dari 716 hari — data latih terus membesar, itulah makna expanding window. Setiap jendela menjalankan siklus sama: bangun ulang tabel fitur, latih GradientBoostingRegressor dari awal, ramal 14 hari rekursif, lalu bandingkan MAE-nya dengan seasonal-naive mingguan pada jendela yang sama.

Prinsip ini sekeluarga dengan TimeSeriesSplit pada scikit-learn: fold diiris menurut waktu dan fold latih selalu mendahului fold uji. Walk-forward di sini memakai horizon 14 hari utuh per jendela agar tiap angka MAE langsung bermakna sebagai galat ramalan dua pekan ke depan — ukuran yang bisa dibaca tim perencanaan stok.

python
# Validasi walk-forward dan laporan akhir proyek forecasting penjualan.
# Lima jendela uji bergulir (expanding window) masing-masing 14 hari
# menutup 70 hari terakhir deret. Pada tiap jendela, model dilatih ulang
# dari seluruh history sebelum jendela (data latih terus membesar),
# meramal 14 hari secara rekursif, lalu MAE-nya dibandingkan dengan
# baseline terbaik (seasonal-naive mingguan) pada jendela yang sama.
# Seluruh angka dirakit menjadi laporan/laporan-forecasting.txt yang
# kemudian dicetak isinya. Jalankan dari root proyek:
# python3 src/05_laporan.py

import os

import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingRegressor

HORIZON = 14
N_WINDOW = 5
HARI = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"]

df = pd.read_csv("data/penjualan.csv", parse_dates=["tanggal"])
y = df["penjualan_unit"].to_numpy().astype(float)
dow = df["tanggal"].dt.dayofweek.to_numpy()
tanggal = df["tanggal"].dt.strftime("%Y-%m-%d").to_list()
n = len(y)


def mae(aktual, prediksi):
    return float(np.mean(np.abs(aktual - prediksi)))


def rmse(aktual, prediksi):
    return float(np.sqrt(np.mean((aktual - prediksi) ** 2)))


def latih_model(n_latih):
    """Latih GradientBoostingRegressor pada hari ke-14 s/d n_latih-1."""
    s = pd.Series(y)
    fitur = pd.DataFrame(
        {
            "lag_1": s.shift(1),
            "lag_7": s.shift(7),
            "lag_14": s.shift(14),
            "rolling_mean_7": s.shift(1).rolling(7).mean(),
        }
    )
    for d, nama in enumerate(HARI):
        fitur[f"hari_{nama}"] = (dow == d).astype(float)
    X = fitur.dropna().to_numpy()
    target = s[14:].to_numpy()
    model = GradientBoostingRegressor(
        n_estimators=150,
        learning_rate=0.1,
        max_depth=3,
        random_state=42,
    )
    model.fit(X[: n_latih - 14], target[: n_latih - 14])
    return model, list(fitur.columns)


def ramal_rekursif(model, n_latih, panjang):
    """Ramal `panjang` hari mulai indeks n_latih secara rekursif:
    prediksi hari t menjadi masukan lag untuk hari t+1."""
    riwayat = list(y[:n_latih])
    prediksi = []
    for t in range(n_latih, n_latih + panjang):
        r = np.asarray(riwayat)
        baris = [r[-1], r[-7], r[-14], float(r[-7:].mean())]
        baris += [1.0 if dow[t] == d else 0.0 for d in range(7)]
        p = float(model.predict(np.array([baris]))[0])
        prediksi.append(p)
        riwayat.append(p)
    return np.array(prediksi)


# ---------- Skor split akhir (14 hari terakhir) ----------
n_latih_akhir = n - HORIZON
uji_akhir = y[n_latih_akhir:]
mae_naive = mae(uji_akhir, np.repeat(y[n_latih_akhir - 1], HORIZON))
mae_ma7 = mae(uji_akhir, np.repeat(y[n_latih_akhir - 7:n_latih_akhir].mean(), HORIZON))
pred_seas_akhir = np.tile(y[n_latih_akhir - 7:n_latih_akhir], 2)
mae_seas_akhir = mae(uji_akhir, pred_seas_akhir)
model_akhir, kolom = latih_model(n_latih_akhir)
pred_model_akhir = ramal_rekursif(model_akhir, n_latih_akhir, HORIZON)
mae_model_akhir = mae(uji_akhir, pred_model_akhir)
urut_penting = np.argsort(model_akhir.feature_importances_)[::-1]

# ---------- Validasi walk-forward ----------
baris_jendela = []
for w in range(N_WINDOW):
    n_latih_w = n - (N_WINDOW - w) * HORIZON
    model_w, _ = latih_model(n_latih_w)
    pred_m = ramal_rekursif(model_w, n_latih_w, HORIZON)
    pred_s = np.tile(y[n_latih_w - 7:n_latih_w], 2)
    uji_w = y[n_latih_w:n_latih_w + HORIZON]
    baris_jendela.append(
        {
            "jendela": w + 1,
            "rentang": f"{tanggal[n_latih_w]}..{tanggal[n_latih_w + HORIZON - 1]}",
            "mae_model": mae(uji_w, pred_m),
            "mae_seas": mae(uji_w, pred_s),
            "n_latih": n_latih_w,
        }
    )

print("== Validasi walk-forward: 5 jendela expanding, horizon 14 hari ==")
print(f"{'jendela':>7s} {'rentang uji':23s} {'latih':>6s} {'MAE model':>10s} {'MAE seas':>9s}")
for b in baris_jendela:
    print(
        f"{b['jendela']:7d} {b['rentang']:23s} {b['n_latih']:6d}"
        f" {b['mae_model']:10.2f} {b['mae_seas']:9.2f}"
    )
menang = sum(1 for b in baris_jendela if b["mae_model"] < b["mae_seas"])
rata_model = float(np.mean([b["mae_model"] for b in baris_jendela]))
rata_seas = float(np.mean([b["mae_seas"] for b in baris_jendela]))
print(f"{'rata-rata':>7s} {'':23s} {'':6s} {rata_model:10.2f} {rata_seas:9.2f}")
print(f"model unggul di {menang} dari {N_WINDOW} jendela")

# ---------- Rakit laporan ----------
b_terberat = max(baris_jendela, key=lambda b: b["mae_model"])
persen_level = rata_model / y.mean() * 100.0
baris_laporan = []
baris_laporan.append("=" * 64)
baris_laporan.append(" LAPORAN PROYEK: FORECASTING PENJUALAN HARIAN")
baris_laporan.append(" Deret sintetis 730 hari: "
                     f"{tanggal[0]} s/d {tanggal[-1]}")
baris_laporan.append("=" * 64)
baris_laporan.append("")
baris_laporan.append("1. RINGKASAN EKSEKUTIF")
baris_laporan.append(
    f"   GradientBoostingRegressor pada fitur lag 1/7/14, rolling mean 7,"
    " dan hari-dalam-minggu one-hot"
)
baris_laporan.append(
    f"   mengalahkan baseline terbaik (seasonal-naive mingguan) dengan MAE"
    f" {mae_model_akhir:.2f} vs {mae_seas_akhir:.2f}"
)
baris_laporan.append(
    f"   unit/hari pada 14 hari terakhir, dan unggul di {menang} dari"
    f" {N_WINDOW} jendela walk-forward"
)
baris_laporan.append(
    f"   (rata-rata MAE {rata_model:.2f} vs {rata_seas:.2f})."
)
baris_laporan.append("")
baris_laporan.append("2. DATA")
baris_laporan.append(f"   Baris                : {n} hari")
baris_laporan.append(f"   Rata-rata penjualan  : {y.mean():.1f} unit/hari")
baris_laporan.append(f"   Rentang nilai        : {y.min():.0f} - {y.max():.0f} unit")
baris_laporan.append("   Struktur             : tren naik ringan, musiman")
baris_laporan.append("                          mingguan (akhir pekan tinggi),")
baris_laporan.append("                          musiman tahunan ringan, spike")
baris_laporan.append("                          promo/libur, derau.")
baris_laporan.append("")
baris_laporan.append("3. BASELINE STATISTIK (14 hari terakhir)")
baris_laporan.append(f"   naive (nilai terakhir)      : MAE {mae_naive:6.2f}")
baris_laporan.append(f"   rata-rata 7 hari            : MAE {mae_ma7:6.2f}")
baris_laporan.append(f"   seasonal-naive mingguan     : MAE {mae_seas_akhir:6.2f}  <- terbaik")
baris_laporan.append("")
baris_laporan.append("4. MODEL MACHINE LEARNING (split kronologis sama)")
baris_laporan.append(f"   GradientBoostingRegressor   : MAE {mae_model_akhir:6.2f}"
                     f"  RMSE {rmse(uji_akhir, pred_model_akhir):6.2f}")
baris_laporan.append(f"   Perbaikan vs seasonal-naive : "
                     f"{(mae_seas_akhir - mae_model_akhir) / mae_seas_akhir * 100:+.1f}%")
fitur_penting = ", ".join(
    f"{kolom[i]} ({model_akhir.feature_importances_[i]:.2f})"
    for i in urut_penting[:3]
)
baris_laporan.append(f"   Fitur terpenting            : {fitur_penting}")
baris_laporan.append("")
baris_laporan.append("5. VALIDASI WALK-FORWARD (5 jendela expanding, horizon 14)")
baris_laporan.append("   jendela  rentang uji            latih  MAE model  MAE seas")
for b in baris_jendela:
    baris_laporan.append(
        f"   {b['jendela']:6d}  {b['rentang']:23s} {b['n_latih']:5d}"
        f"  {b['mae_model']:9.2f}  {b['mae_seas']:9.2f}"
    )
baris_laporan.append(
    f"   rata-rata MAE model {rata_model:.2f} vs seasonal-naive "
    f"{rata_seas:.2f}; model unggul di {menang}/{N_WINDOW} jendela."
)
baris_laporan.append("")
baris_laporan.append("6. TEMUAN")
baris_laporan.append(
    "   a. lag_7 menjadi fitur terdominan: pola mingguan adalah sinyal"
    " terkuat deret."
)
baris_laporan.append(
    "   b. Error model sekitar "
    f"{persen_level:.0f}% dari level penjualan harian - memadai untuk"
    " perencanaan stok mingguan."
)
baris_laporan.append(
    f"   c. Jendela terberat: {b_terberat['rentang']} "
    f"(MAE {b_terberat['mae_model']:.2f}); jendela tersebut memuat"
    " promo besar 2024-11-11 yang bukan fitur model."
)
baris_laporan.append(
    "   d. Hari libur 2024-12-25 dan promo 2024-12-27 terlewat semua"
    " model karena fitur kalender belum dipakai."
)
baris_laporan.append("")
baris_laporan.append("7. REKOMENDASI")
baris_laporan.append(
    "   a. Tambahkan fitur kalender (dummy promo dan hari libur) agar"
    " event termodifikasi."
)
baris_laporan.append(
    "   b. Latih ulang model secara berkala dengan data terbaru;"
    " walk-forward meniru jadwal retraining ini."
)
baris_laporan.append(
    "   c. Pantau selisih prediksi vs aktual; jika MAE 14 hari melampaui"
    " seasonal-naive, tinjau ulang fitur."
)
baris_laporan.append("")
baris_laporan.append("=" * 64)

isi_laporan = "\n".join(baris_laporan) + "\n"

os.makedirs("laporan", exist_ok=True)
with open("laporan/laporan-forecasting.txt", "w", encoding="utf-8") as f:
    f.write(isi_laporan)

print("\n== Laporan ditulis: laporan/laporan-forecasting.txt ==")
print(isi_laporan, end="")

Output yang Dihasilkan:

text
== Validasi walk-forward: 5 jendela expanding, horizon 14 hari ==
jendela rentang uji              latih  MAE model  MAE seas
      1 2024-10-22..2024-11-04     660       8.56     10.71
      2 2024-11-05..2024-11-18     674      20.24     20.36
      3 2024-11-19..2024-12-02     688      11.69     11.79
      4 2024-12-03..2024-12-16     702       9.48      9.00
      5 2024-12-17..2024-12-30     716      16.09     17.29
rata-rata                                     13.21     13.83
model unggul di 4 dari 5 jendela

== Laporan ditulis: laporan/laporan-forecasting.txt ==
================================================================
 LAPORAN PROYEK: FORECASTING PENJUALAN HARIAN
 Deret sintetis 730 hari: 2023-01-01 s/d 2024-12-30
================================================================

1. RINGKASAN EKSEKUTIF
   GradientBoostingRegressor pada fitur lag 1/7/14, rolling mean 7, dan hari-dalam-minggu one-hot
   mengalahkan baseline terbaik (seasonal-naive mingguan) dengan MAE 16.09 vs 17.29
   unit/hari pada 14 hari terakhir, dan unggul di 4 dari 5 jendela walk-forward
   (rata-rata MAE 13.21 vs 13.83).

2. DATA
   Baris                : 730 hari
   Rata-rata penjualan  : 139.6 unit/hari
   Rentang nilai        : 88 - 265 unit
   Struktur             : tren naik ringan, musiman
                          mingguan (akhir pekan tinggi),
                          musiman tahunan ringan, spike
                          promo/libur, derau.

3. BASELINE STATISTIK (14 hari terakhir)
   naive (nilai terakhir)      : MAE  29.29
   rata-rata 7 hari            : MAE  27.78
   seasonal-naive mingguan     : MAE  17.29  <- terbaik

4. MODEL MACHINE LEARNING (split kronologis sama)
   GradientBoostingRegressor   : MAE  16.09  RMSE  28.38
   Perbaikan vs seasonal-naive : +6.9%
   Fitur terpenting            : lag_7 (0.52), lag_14 (0.28), rolling_mean_7 (0.11)

5. VALIDASI WALK-FORWARD (5 jendela expanding, horizon 14)
   jendela  rentang uji            latih  MAE model  MAE seas
        1  2024-10-22..2024-11-04    660       8.56      10.71
        2  2024-11-05..2024-11-18    674      20.24      20.36
        3  2024-11-19..2024-12-02    688      11.69      11.79
        4  2024-12-03..2024-12-16    702       9.48       9.00
        5  2024-12-17..2024-12-30    716      16.09      17.29
   rata-rata MAE model 13.21 vs seasonal-naive 13.83; model unggul di 4/5 jendela.

6. TEMUAN
   a. lag_7 menjadi fitur terdominan: pola mingguan adalah sinyal terkuat deret.
   b. Error model sekitar 9% dari level penjualan harian - memadai untuk perencanaan stok mingguan.
   c. Jendela terberat: 2024-11-05..2024-11-18 (MAE 20.24); jendela tersebut memuat promo besar 2024-11-11 yang bukan fitur model.
   d. Hari libur 2024-12-25 dan promo 2024-12-27 terlewat semua model karena fitur kalender belum dipakai.

7. REKOMENDASI
   a. Tambahkan fitur kalender (dummy promo dan hari libur) agar event termodifikasi.
   b. Latih ulang model secara berkala dengan data terbaru; walk-forward meniru jadwal retraining ini.
   c. Pantau selisih prediksi vs aktual; jika MAE 14 hari melampaui seasonal-naive, tinjau ulang fitur.

================================================================

Mari kita bedah skrip validasi dan laporan baris-per-baris:

  1. Baris 17–19 (konstanta): HORIZON = 14 dan N_WINDOW = 5 membekukan desain evaluasi — lima jendela dua pekan yang menutup 70 hari terakhir.
  2. Baris 28–33 (metrik): fungsi mae dan rmse didefinisikan sekali dan dipakai di seluruh skrip — satu definisi galat, tidak ada versi ganda.
  3. Baris 36–58 (latih_model): fungsi ini membangun ulang tabel fitur lag dan one-hot, lalu melatih model baru setiap kali dipanggil — jaminan bahwa tiap jendela benar-benar dilatih dari nol tanpa sisa state jendela sebelumnya.
  4. Baris 61–73 (ramal_rekursif): ramalan 14 hari rekursif dari Bagian 3 dibungkus menjadi fungsi; jendela mana pun cukup memanggilnya dengan titik potong history yang sesuai.
  5. Baris 77–86 (skor split akhir): skor model dan baseline pada 14 hari terakhir dihitung lebih dulu karena angka-angka tersebut menjadi isi laporan; urutan kepentingan fitur ikut disiapkan.
  6. Baris 89–104 (loop walk-forward): rumus n_latih_w = n - (N_WINDOW - w) * HORIZON menggeser titik potong 14 hari per jendela — data latih membesar dari 660 ke 716 hari sementara jendela uji bergerak maju.
  7. Baris 106–117 (tabel hasil): MAE model dan seasonal-naive dicetak berdampingan per jendela, lalu rata-rata dan jumlah jendela kemenangan dirangkum sebagai angka stabilitas.
  8. Baris 120–121 (diagnostik laporan): jendela terberat dipilih lewat max atas MAE model, dan rata-rata galat diubah menjadi persentase level penjualan — dua angka yang akan masuk bagian temuan.
  9. Baris 122–219 (perakitan laporan): tujuh bagian laporan dirakit dari f-string berisi angka hidup hasil komputasi — tidak ada satu pun angka yang disalin manual, sehingga laporan tidak mungkin basi atau salah salin.
  10. Baris 221–226 (tulis dan cetak): laporan ditulis ke laporan/laporan-forecasting.txt lalu seluruh isinya dicetak kembali sebagai bukti bahwa berkas dan tampilan konsisten.

Hasil Lintas Lima Jendela #

JendelaPeriode ujiHari latihMAE modelMAE seasonal-naive
122 Okt – 4 Nov 20246608,5610,71
25 – 18 Nov 202467420,2420,36
319 Nov – 2 Des 202468811,6911,79
43 – 16 Des 20247029,489,00
517 – 30 Des 202471616,0917,29
Rata-rata——13,2113,83

Model unggul di empat dari lima jendela dengan rata-rata MAE 13,21 lawan 13,83 — perbaikan sekitar 4,5% yang konsisten dengan margin Bagian 3. Tiga pola layak dibaca. Pertama, jendela terberat adalah jendela kedua (MAE 20,24): periode 5–18 November memuat promo 2024-11-11 sebesar +100 unit yang bukan fitur model, sehingga kedua peserta sama-sama meleset besar dan model hanya menang tipis 20,24 lawan 20,36. Kedua, satu-satunya kekalahan model terjadi di jendela keempat (9,48 lawan 9,00) — periode tenang tanpa event, saat seasonal-naive bermain di keahliannya sendiri: mengulang pekan yang stabil. Ketiga, tidak ada jendela dengan galat tak terkendali — MAE model selalu di bawah 21 unit pada level deret 140–165 unit, kira-kira 9% dari level harian.

Walk-forward menjawab pertanyaan stabilitas yang tak bisa dijawab satu split: keunggulan model bukan kebetulan satu periode, dan kelemahannya pun terpetakan jelas — event kalender. Jendela kedua dan kelima menunjukkan bahwa promo besar serta hari libur adalah sumber galat terbesar, dan laporan final menuntaskan proyek dengan tiga rekomendasi berurutan: tambahkan fitur kalender (dummy promo dan hari libur) agar event termodifikasi, latih ulang model secara berkala mengikuti ritme yang dicontohkan walk-forward, dan pasang seasonal-naive sebagai pembanding pemantauan — bila MAE 14 hari model melampaui baseline tersebut, saatnya meninjau fitur.

Laporan teks laporan/laporan-forecasting.txt menutup proyek ini dengan struktur tujuh bagian — ringkasan eksekutif, data, baseline, model, walk-forward, temuan, dan rekomendasi — yang seluruh angkanya dirakit langsung dari hasil komputasi. Pola besar perjalanan kita juga tuntas: deret dibaca, baseline ditetapkan, model dibangun, dan keandalannya diuji lintas waktu. Pertanyaan berikutnya membalik arah pandang — alih-alih meramal hari normal dari pola, kita mencari hari yang justru mematahkan pola: transaksi yang menyimpang dari kebiasaan deretnya. Tugas itu dikerjakan pada proyek lanjutan, Proyek: Deteksi Anomali Transaksi.

Referensi Resmi #