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.
# 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:
== 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:
- Baris 17–19 (konstanta):
HORIZON = 14danN_WINDOW = 5membekukan desain evaluasi — lima jendela dua pekan yang menutup 70 hari terakhir. - Baris 28–33 (metrik): fungsi
maedanrmsedidefinisikan sekali dan dipakai di seluruh skrip — satu definisi galat, tidak ada versi ganda. - 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. - 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. - 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.
- Baris 89–104 (loop walk-forward): rumus
n_latih_w = n - (N_WINDOW - w) * HORIZONmenggeser titik potong 14 hari per jendela — data latih membesar dari 660 ke 716 hari sementara jendela uji bergerak maju. - 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.
- Baris 120–121 (diagnostik laporan): jendela terberat dipilih lewat
maxatas MAE model, dan rata-rata galat diubah menjadi persentase level penjualan — dua angka yang akan masuk bagian temuan. - 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.
- Baris 221–226 (tulis dan cetak): laporan ditulis ke
laporan/laporan-forecasting.txtlalu seluruh isinya dicetak kembali sebagai bukti bahwa berkas dan tampilan konsisten.
Hasil Lintas Lima Jendela #
| Jendela | Periode uji | Hari latih | MAE model | MAE seasonal-naive |
|---|---|---|---|---|
| 1 | 22 Okt – 4 Nov 2024 | 660 | 8,56 | 10,71 |
| 2 | 5 – 18 Nov 2024 | 674 | 20,24 | 20,36 |
| 3 | 19 Nov – 2 Des 2024 | 688 | 11,69 | 11,79 |
| 4 | 3 – 16 Des 2024 | 702 | 9,48 | 9,00 |
| 5 | 17 – 30 Des 2024 | 716 | 16,09 | 17,29 |
| Rata-rata | — | — | 13,21 | 13,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 #
- Time series cross-validation — scikit-learn User Guide — kerangka fold kronologis yang menjadi keluarga walk-forward.
- sklearn.model_selection.TimeSeriesSplit — API — implementasi resmi split deret waktu dengan fold latih mendahului fold uji.
- sklearn.metrics.mean_absolute_error — API — metrik utama evaluasi lintas jendela.
- pandas.DataFrame.groupby — API — agregasi yang dipakai saat merangkum statistik data untuk laporan.