Chapter sebelumnya meninggalkan satu pertanyaan yang sengaja digantung: seluruh angka evaluasi ARIMA dan RandomForest berasal dari satu pemisahan latih-uji tunggal — satu jendela waktu, satu musim, satu kebetulan. Bagaimana kualitas model sepanjang musim lain, misalnya pekan ketika kampanye promo sedang berlangsung dan model belum pernah melihat pola seperti itu? Pertanyaan semacam ini tidak bisa dijawab dengan validasi silang acak yang biasa kita pakai pada data tabular, karena pengacakan justru merusak satu-satunya struktur yang dimiliki deret waktu: urutan. Chapter penutup Section ini membangun protokol evaluasi yang bergerak maju mengikuti arah waktu — walk-forward validation — lalu mengukur dengan angka nyata seberapa optimis kesalahan yang dihasilkan protokol yang keliru.
Mengapa Validasi Silang Acak Merusak Deret Waktu #
KFold(shuffle=True) menempatkan baris-baris ke dalam lipatan latih dan uji secara acak. Pada data tabular yang independen, itu adil. Pada deret waktu, keacakan itu membuka dua jalur kebocoran sekaligus. Jalur pertama adalah kebocoran level masa depan: baris latih bisa berasal dari pekan sesudah baris uji, sehingga model sempat “membaca masa depan” sebelum ditanya. Jika pekan uji berada di tengah kampanye promo yang menaikkan penjualan 45 unit per hari, baris-baris promo lain tersebar di lipatan latih akan membocorkan level kampanye itu — model tampak pintar padahal cuma mengingat jawaban.
Jalur kedua lebih halus dan sudah kita tandai di chapter fitur lag: jendela lag yang saling tumpang-tindih. Dengan tujuh fitur lag, dua baris yang berdekatan berbagi enam dari tujuh nilai yang sama. Lipatan uji dan lipatan latih yang diacak akan mengandung baris-baris kembar-nyaris-identik ini, sehingga hutan acak cukup menghafal tetangga untuk menjawab uji dengan benar. Kebocoran jenis ini tetap terjadi meskipun tanggalnya disembunyikan — struktur lag_k itu sendiri yang menjadi pintu.
Skema Walk-Forward: Jendela yang Bergerak Maju #
Walk-forward meniru cara model dipakai di dunia nyata: dilatih hanya dengan data lampau, diuji pada data yang belum terjadi, lalu jendela bergeser maju dan proses berulang. Bentuk paling umum adalah expanding window: data latih tumbuh dari lipatan ke lipatan. Skema rolling window menjadi alternatif ketika data lama diduga kedaluwarsa — ukuran jendela latih tetap, hanya posisinya yang bergerak, sehingga model melupakan masa lalu yang jauh. Keduanya menghasilkan satu angka akhir berupa rata-rata galat lintas lipatan, tetapi setiap lipatan kini benar-benar out-of-sample: model belum pernah melihat satu pun baris dari periode ujinya.
| Protokol | Memakai masa depan saat latih? | Baris uji pernah dilihat model? | Cocok untuk |
|---|---|---|---|
| Holdout kronologis tunggal | Tidak | Tidak | Evaluasi cepat, satu musim |
| KFold acak (shuffle=True) | Ya, lewat baris latih | Tidak, tapi tetangganya iya | Data tabular iid saja |
| Walk-forward expanding | Tidak | Tidak | Evaluasi forecasting standar |
| Rolling window | Tidak | Tidak | Data dengan pergeseran pola (drift) |
Tiga parameter menentukan bentuk protokol: jendela latih awal (harus cukup panjang untuk menangkap musiman terpendek — di sini sembilan pekan agar pola mingguan terlihat), langkah (berapa hari jendela bergeser antar lipatan), dan horizon (berapa hari ke depan model meramalkan per lipatan). Ketika langkah sama dengan horizon, setiap hari di periode uji dievaluasi tepat sekali — tidak ada hari yang dihitung dobel atau terlewat.
Contoh Kode: Empat Belas Lipatan Musim yang Berbeda #
Kita menutup kisah Toko Ceria dengan protokol lengkap: mulai dari pekan ke-10, RandomForestRegressor dilatih ulang setiap pekan dengan seluruh data lampau yang tersedia, lalu meramalkan tujuh hari ke depan dan diadu dengan baseline naive (ramalan besok = nilai hari ini, alias kolom lag_1):
# Walk-forward expanding window: evaluasi out-of-sample yang mengikuti
# arah waktu. Model dilatih ulang di tiap lipatan lalu meramalkan 7 hari.
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error
def buat_deret_penjualan(n_hari=168):
rng = np.random.default_rng(42)
idx = pd.date_range(start="2026-01-05", periods=n_hari, freq="D")
t = np.arange(n_hari)
tren = 120.0 + 0.45 * t
hari = np.asarray(idx.dayofweek)
efek_hari = np.select(
[hari == 4, hari == 5, hari == 6],
[10.0, 32.0, 40.0],
default=0.0,
)
promo = ((t >= 70) & (t < 84)).astype(float) * 45.0
kilat = (t == 105).astype(float) * 85.0
derau = rng.normal(0.0, 12.0, n_hari)
y = tren + efek_hari + promo + kilat + derau
return pd.Series(np.round(y, 1), index=idx, name="penjualan")
penjualan = buat_deret_penjualan()
df = pd.DataFrame({"y": penjualan})
for k in range(1, 8):
df[f"lag_{k}"] = penjualan.shift(k)
df["hari"] = penjualan.index.dayofweek
df["akhir_pekan"] = (penjualan.index.dayofweek >= 5).astype(int)
df = df.dropna()
fitur = [kolom for kolom in df.columns if kolom != "y"]
latih_awal = 63 # sampai 2026-03-15 (9 minggu)
langkah = 7 # maju satu minggu tiap lipatan
horizon = 7 # meramalkan satu minggu ke depan
print(f"Skema: expanding window, data latih awal {latih_awal} baris, "
f"langkah {langkah} hari, horizon {horizon} hari")
print(f"\n{'periode uji':26s} {'RMSE RF':>8s} {'RMSE naive':>10s}")
rmse_model, rmse_naive, tanggal_mulai = [], [], []
for posisi in range(latih_awal, len(df) - horizon + 1, langkah):
uji = df.iloc[posisi:posisi + horizon]
latih = df.iloc[:posisi]
model = RandomForestRegressor(n_estimators=300, random_state=42, n_jobs=1)
model.fit(latih[fitur], latih["y"])
prediksi = model.predict(uji[fitur])
naive = uji["lag_1"]
rmse_m = np.sqrt(mean_squared_error(uji["y"], prediksi))
rmse_n = np.sqrt(mean_squared_error(uji["y"], naive))
rmse_model.append(rmse_m)
rmse_naive.append(rmse_n)
tanggal_mulai.append(uji.index[0])
print(f"{uji.index[0].date()} s.d. {uji.index[-1].date()} {rmse_m:8.2f} {rmse_n:10.2f}")
terburuk = int(np.argmax(rmse_model))
print(f"\nRata-rata {len(rmse_model)} lipatan: RF {np.mean(rmse_model):.2f} "
f"naive {np.mean(rmse_naive):.2f}")
print(f"Lipatan tersulit bagi RF: minggu {tanggal_mulai[terburuk].date()} "
f"(RMSE {rmse_model[terburuk]:.2f})")
print(f"Model RF unggul pada {sum(m < n for m, n in zip(rmse_model, rmse_naive))} "
f"dari {len(rmse_model)} lipatan")Empat belas lipatan menempatkan model pada musim yang benar-benar berbeda-beda. Output yang Dihasilkan:
Skema: expanding window, data latih awal 63 baris, langkah 7 hari, horizon 7 hari
periode uji RMSE RF RMSE naive
2026-03-16 s.d. 2026-03-22 47.18 13.51
2026-03-23 s.d. 2026-03-29 12.45 17.14
2026-03-30 s.d. 2026-04-05 29.58 38.48
2026-04-06 s.d. 2026-04-12 8.30 22.87
2026-04-13 s.d. 2026-04-19 18.41 19.67
2026-04-20 s.d. 2026-04-26 31.82 45.90
2026-04-27 s.d. 2026-05-03 26.16 25.28
2026-05-04 s.d. 2026-05-10 9.90 26.59
2026-05-11 s.d. 2026-05-17 11.39 21.35
2026-05-18 s.d. 2026-05-24 20.67 26.66
2026-05-25 s.d. 2026-05-31 10.93 31.45
2026-06-01 s.d. 2026-06-07 13.20 17.84
2026-06-08 s.d. 2026-06-14 12.05 19.02
2026-06-15 s.d. 2026-06-21 15.74 29.95
Rata-rata 14 lipatan: RF 19.13 naive 25.41
Lipatan tersulit bagi RF: minggu 2026-03-16 (RMSE 47.18)
Model RF unggul pada 12 dari 14 lipatanStruktur skrip melanjutkan pola chapter sebelumnya sehingga cukup dibedah pada bagian yang baru. Baris 47 menjadi jantung protokol: range(latih_awal, len(df) - horizon + 1, langkah) melangkah dari posisi 63 sampai batas terakhir yang masih menyisakan tujuh baris uji utuh — tanpa + 1, lipatan terakhir akan terpotong dan RMSE-nya tak sebanding dengan yang lain. Baris 48-49 memotong lipatan: uji selalu tujuh baris di depan, latih = df.iloc[:posisi] selalu seluruh masa lalu — itulah expanding window, tanpa satu pun baris masa depan yang lolos. Baris 50-51 melatih model segar di tiap lipatan; inilah biaya komputasi protokol yang jujur, empat belas kali pelatihan penuh. Baris 53 menempatkan baseline pada posisi yang setara — naive memakai lag_1 yang memang tersedia di tiap baris uji, jadi keduanya diuji pada informasi yang sama.
Angka-angkanya layak dibaca seperti kisah. Rata-rata 14 lipatan memberi RF keunggulan nyata: RMSE 19,13 lawan 25,41 milik naive, dengan kemenangan pada 12 dari 14 pekan. Tetapi lihat lipatan pertama — 2026-03-16, RMSE 47,18, terburuk dari semuanya. Itu bukan kebetulan buruk: pekan itu adalah pekan pertama kampanye promo, dan model yang dilatih hanya pada data pra-promo belum punya satu pun contoh level penjualan baru tersebut. Inilah nilai jujur walk-forward: ia menunjukkan dengan tepat kapan model kesulitan, alih-alih meratakannya menjadi satu angka yang manis.
Contoh Kode: Mengukur Seberapa Optimis KFold Acak #
Klaim bahwa KFold acak menipu akan lebih meyakinkan bila dihitung, bukan hanya dinyatakan. Skrip kedua mengevaluasi model yang sama pada baris-baris uji yang sama persis — wilayah 2026-03-16 sampai 2026-06-21 — dengan dua protokol berbeda, lalu membedah satu lipatan promo untuk melihat kebocorannya dari dekat:
# Mengukur kebocoran validasi silang acak pada data deret waktu:
# KFold dengan shuffle vs walk-forward pada baris uji yang sama,
# lalu bedah satu lipatan kampanye promo secara terpisah.
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import KFold, cross_val_predict
def buat_deret_penjualan(n_hari=168):
rng = np.random.default_rng(42)
idx = pd.date_range(start="2026-01-05", periods=n_hari, freq="D")
t = np.arange(n_hari)
tren = 120.0 + 0.45 * t
hari = np.asarray(idx.dayofweek)
efek_hari = np.select(
[hari == 4, hari == 5, hari == 6],
[10.0, 32.0, 40.0],
default=0.0,
)
promo = ((t >= 70) & (t < 84)).astype(float) * 45.0
kilat = (t == 105).astype(float) * 85.0
derau = rng.normal(0.0, 12.0, n_hari)
y = tren + efek_hari + promo + kilat + derau
return pd.Series(np.round(y, 1), index=idx, name="penjualan")
penjualan = buat_deret_penjualan()
df = pd.DataFrame({"y": penjualan})
for k in range(1, 8):
df[f"lag_{k}"] = penjualan.shift(k)
df["hari"] = penjualan.index.dayofweek
df["akhir_pekan"] = (penjualan.index.dayofweek >= 5).astype(int)
df = df.dropna()
fitur = [kolom for kolom in df.columns if kolom != "y"]
# Baris 2026-03-16 dan sesudahnya: wilayah yang diuji walk-forward pada contoh pertama.
baris_uji_wf = df.iloc[63:]
model_acak = RandomForestRegressor(n_estimators=300, random_state=42, n_jobs=1)
lipatan_acak = KFold(n_splits=5, shuffle=True, random_state=42)
prediksi_acak = cross_val_predict(model_acak, baris_uji_wf[fitur], baris_uji_wf["y"], cv=lipatan_acak)
rmse_acak = np.sqrt(mean_squared_error(baris_uji_wf["y"], prediksi_acak))
rmse_jujur = []
for posisi in range(63, len(df) - 6, 7):
uji = df.iloc[posisi:posisi + 7]
latih = df.iloc[:posisi]
model = RandomForestRegressor(n_estimators=300, random_state=42, n_jobs=1)
model.fit(latih[fitur], latih["y"])
prediksi = model.predict(uji[fitur])
rmse_jujur.append(np.sqrt(mean_squared_error(uji["y"], prediksi)))
rmse_wf = float(np.mean(rmse_jujur))
print(f"Baris uji: {baris_uji_wf.index[0].date()} s.d. {baris_uji_wf.index[-1].date()} "
f"({len(baris_uji_wf)} baris)")
print(f"KFold acak (5 lipatan, shuffle=True) : RMSE {rmse_acak:.2f}")
print(f"Walk-forward expanding (14 lipatan) : RMSE {rmse_wf:.2f}")
print(f"Selisih : {rmse_wf - rmse_acak:.2f} "
f"({100.0 * (rmse_wf - rmse_acak) / rmse_wf:.1f}% lebih optimis)")
# Bedah satu lipatan: pekan pertama kampanye promo (16-22 Maret).
uji_promo = df.iloc[63:70]
latih_masa_lalu = df.iloc[:63]
latih_bocor = pd.concat([df.iloc[:63], df.iloc[70:]]) # semua baris kecuali blok uji
model_a = RandomForestRegressor(n_estimators=300, random_state=42, n_jobs=1).fit(
latih_masa_lalu[fitur], latih_masa_lalu["y"])
model_b = RandomForestRegressor(n_estimators=300, random_state=42, n_jobs=1).fit(
latih_bocor[fitur], latih_bocor["y"])
rmse_a = np.sqrt(mean_squared_error(uji_promo["y"], model_a.predict(uji_promo[fitur])))
rmse_b = np.sqrt(mean_squared_error(uji_promo["y"], model_b.predict(uji_promo[fitur])))
print(f"\nLipatan kampanye promo 16-22 Maret (model belum pernah melihat promo):")
print(f" dilatih masa lalu saja : RMSE {rmse_a:.2f}")
print(f" dilatih tanpa blok uji (bocor) : RMSE {rmse_b:.2f}")
print(f" baris latihan masa depan membocorkan level kampanye kepada model")Selisih dua protokol pada baris uji identik inilah harga kejujuran. Output yang Dihasilkan:
Baris uji: 2026-03-16 s.d. 2026-06-21 (98 baris)
KFold acak (5 lipatan, shuffle=True) : RMSE 17.34
Walk-forward expanding (14 lipatan) : RMSE 19.13
Selisih : 1.79 (9.3% lebih optimis)
Lipatan kampanye promo 16-22 Maret (model belum pernah melihat promo):
dilatih masa lalu saja : RMSE 47.18
dilatih tanpa blok uji (bocor) : RMSE 31.11
baris latihan masa depan membocorkan level kampanye kepada modelPerbandingannya dijaga tetap adil lewat baris 41: baris_uji_wf = df.iloc[63:] memastikan KFold acak dievaluasi pada baris-baris yang juga diuji walk-forward, sehingga selisih RMSE murni berasal dari protokol, bukan dari wilayah data yang berbeda. Baris 43-46 menjalankan cross_val_predict dengan KFold(shuffle=True, random_state=42) — lima lipatan acak pada 98 baris uji; baris 48-56 menjalankan walk-forward yang telah kita bangun di atas sebagai pembanding jujur. Bagian bedah pada baris 66-75 lalu mengisolasi mekanisme kebocorannya: latih_masa_lalu berisi 63 baris pra-uji, latih_bocor berisi semua baris kecuali blok uji — termasuk pekan-pekan promo sesudahnya.
Hasilnya memberi angka pada klaim awal chapter. KFold acak melaporkan RMSE 17,34 sementara walk-forward yang jujur berkata 19,13 — protokol acak terlihat 9,3% lebih optimis pada kasus ini, dan itu pada data yang jinak. Bedah lipatan promo lebih telanjang lagi: model jujur yang tak pernah melihat promo menanggung RMSE 47,18, sedangkan model bocor yang baris latihannya menyertakan masa depan turun ke 31,11. Selisih 16 poin itu bukan kecerdasan model — murni kabocoran level kampanye dari baris masa depan.
Keputusan Desain yang Menentukan Protokol #
Empat pilihan desain menentukan seberapa bisa dipercaya angka akhir walk-forward. Pertama, ukuran jendela awal: terlalu pendek dan musiman tak tertangkap (jendela 9 pekan di sini adalah batas bawah yang masih memuat pola mingguan); terlalu panjang dan jumlah lipatan menyusut hingga rata-rata menjadi rapuh. Kedua, rasio langkah terhadap horizon: langkah = horizon memberi cakupan penuh tanpa tumpang-tindih; langkah lebih kecil meningkatkan jumlah lipatan tetapi baris uji bertetangga mulai berbagi informasi lagi — mirror kecil dari masalah yang kita hindari. Ketiga, frekuensi pelatihan ulang: melatih ulang di tiap lipatan paling jujur namun termahal; pada model yang lambat, praktik lazim melatih ulang tiap beberapa lipatan dan menerima angka yang sedikit usang. Keempat, bentuk jendela: expanding memenuhi seluruh sejarah, rolling melupakan — jika penjualan Toko Ceria berubah strukturnya (misalnya musiman baru setelah ekspansi), rolling window akan menunjukkan penurunan kualitas lebih dulu dan itu justru informasi berharga.
Satu catatan penutup untuk seluruh Section: walk-forward memberi tahu kita seberapa baik model meramalkan sepanjang waktu, tetapi tidak pernah menjawab mengapa model mengambil keputusan tertentu pada hari tertentu. RandomForest yang menang 12 dari 14 lipatan tetap berupa kotak dengan 300 pohon di dalamnya. Membuka kotak itu — menanyakan fitur mana yang menentukan, seberapa besar kontribusinya, dan sampai sejauh mana penjelasan itu bisa dipercaya — adalah pekerjaan Section berikutnya, dimulai dari Chapter Konsep Interpretabilitas.
Referensi Resmi #
- TimeSeriesSplit — scikit-learn API — pemecah CV bawaan yang membagi data mengikuti urutan waktu, kerabat dekat protokol manual pada chapter ini.
- scikit-learn — Model evaluation: cross-validation — panduan resmi validasi silang beserta subbagian Time-series cross-validation.
- pandas —
pandas.DataFrame.iloc— API resmi pemotongan posisional yang dipakai loop walk-forward untuk memisahkan masa lalu dan masa uji.