Angka MAE 3,06 milik Holt-Winters pada chapter sebelumnya dihitung dari satu pekan uji: 10 sampai 16 Februari. Geser pekan itu ke mana pun, angkanya hampir pasti berubah — tiap pekan membawa kombinasi tren, musiman, dan keberuntungan yang berbeda. Evaluasi pada satu jendela uji memang rapuh, dan obatnya bukan mencoba-coba pekan uji sampai ketemu angka yang bagus, melainkan mengulang evaluasi secara disiplin pada banyak jendela. Syaratnya satu dan tidak bisa ditawar: setiap pengulangan tetap menghormati arah waktu — model berlatih di masa lalu, diuji di masa depan. Pada chapter ini kita pahami mengapa pembagian acak dilarang keras pada deret waktu, lalu mempraktikkan walk-forward validation dengan TimeSeriesSplit dari scikit-learn.
Mengapa Pembagian Acak Membocorkan Masa Depan #
Kebiasaan baik pada data tabular biasa — mengocok baris sebelum membagi data latih dan uji — berubah menjadi kesalahan serius pada deret waktu. Karena baris diacak, hari-hari uji tersebar di tengah-tengah hari latih: model yang tampak “meramal” tanggal 26 Januari ternyata sudah mempelajari data 27 Januari hingga 16 Februari. Pada deret yang berkorelasi kuat antar hari, nilai esok hari membawa banyak informasi tentang nilai hari ini, sehingga skor yang terukur bukan lagi kemampuan meramal, melainkan kemampuan mengisi celah di antara data yang sudah pernah dilihat. K-fold biasa pun sama bermasalahnya: lipatan pertama menguji data paling awal dengan model yang justru dilatih pada data-data sesudahnya — urutan yang mustahil terjadi di dunia nyata.
Kita buktikan sendiri apa yang dilakukan pengacakan terhadap garis waktu, memakai kembali tabel fitur lag dari chapter Forecasting dengan Machine Learning :
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(42)
n = 42
tanggal = pd.date_range("2025-01-06", periods=n, freq="D")
bobot_hari = np.array([0, 1, 2, 3, 8, 14, 11], dtype=float)
gelas = 40 + 0.6 * np.arange(n) + bobot_hari[tanggal.dayofweek] + rng.normal(0, 4.0, n)
deret = pd.Series(gelas.round().astype(int), index=tanggal, name="gelas")
df = pd.DataFrame({"gelas": deret})
df["lag_1"] = df["gelas"].shift(1)
df["lag_7"] = df["gelas"].shift(7)
df["nomor_hari"] = df.index.dayofweek
df = df.dropna()
fitur = ["lag_1", "lag_7", "nomor_hari"]
latih_krono = df.iloc[:28]
ujian_krono = df.iloc[28:]
latih_acak, ujian_acak = train_test_split(df, test_size=7, random_state=42)
print("Tanggal uji pada pembagian acak, beserta status hari esoknya:")
for t in sorted(ujian_acak.index):
esok = t + pd.Timedelta(days=1)
print(f" uji {t.date()} -> hari esok {esok.date()} ikut data latih: {esok in latih_acak.index}")
model = LinearRegression()
model.fit(latih_krono[fitur], latih_krono["gelas"])
mae_krono = np.abs(model.predict(ujian_krono[fitur]) - ujian_krono["gelas"]).mean()
model.fit(latih_acak[fitur], latih_acak["gelas"])
mae_acak = np.abs(model.predict(ujian_acak[fitur]) - ujian_acak["gelas"]).mean()
print(f"MAE pembagian kronologis : {mae_krono:.2f} gelas")
print(f"MAE pembagian acak : {mae_acak:.2f} gelas")Output yang Dihasilkan:
Tanggal uji pada pembagian acak, beserta status hari esoknya:
uji 2025-01-26 -> hari esok 2025-01-27 ikut data latih: True
uji 2025-01-28 -> hari esok 2025-01-29 ikut data latih: True
uji 2025-02-01 -> hari esok 2025-02-02 ikut data latih: True
uji 2025-02-03 -> hari esok 2025-02-04 ikut data latih: True
uji 2025-02-06 -> hari esok 2025-02-07 ikut data latih: True
uji 2025-02-08 -> hari esok 2025-02-09 ikut data latih: True
uji 2025-02-11 -> hari esok 2025-02-12 ikut data latih: True
MAE pembagian kronologis : 4.01 gelas
MAE pembagian acak : 4.07 gelasMari kita telaah hasilnya:
train_test_split(df, test_size=7, random_state=42)memilih 7 baris uji secara acak; daftar tanggalnya menunjukkan hari uji terpencar dari 26 Januari sampai 11 Februari — bukan lagi “masa depan”, melainkan lubang-lubang di tengah data latih.esok = t + pd.Timedelta(days=1)memeriksa hari berikutnya dari tiap tanggal uji, dan ketujuhnyaTrue: hari esok selalu ikut dilatih. Meramal hari Minggu sambil memegang data hari Senin bukanlah peramalan.- MAE-nya nyaris sama — 4,01 lawan 4,07 — dan justru di sinilah bahayanya tersembunyi. Pada data kecil kita kebetulan tidak muncul lonjakan skor palsu, sehingga kesalahan metodologi ini terlihat tidak berbahaya; pada deret dengan pergeseran level yang kuat, pembagian acak bisa membuat skor tampak jauh lebih baik secara tidak jujur. Berapa pun angkanya, skor dari eksperimen yang salah tetap tidak bermakna.
Walk-Forward: Latih Masa Lalu, Uji Masa Depan #
Prinsip perbaikannya sederhana: simulasikan cara model bekerja di produksi. Model dilatih pada masa lalu, meramal beberapa langkah ke depan, lalu diuji; ketika data baru tiba, jendela latih membesar, model dilatih ulang, dan diuji lagi pada masa depan berikutnya. Prosedur bergulir seperti ini disebut walk-forward validation, dan para praktisi juga akrab menyebutnya backtest — menguji strategi peramalan secara “mundur” menelusuri data historis.
35 baris tabel fitur (urut tanggal, 13 Jan s.d. 16 Feb)
lipatan 1 : LLLLLLLLLL UUUUU
lipatan 2 : LLLLLLLLLLLLLLL UUUUU
lipatan 3 : LLLLLLLLLLLLLLLLLLLL UUUUU
lipatan 4 : LLLLLLLLLLLLLLLLLLLLLLLL UUUUU
lipatan 5 : LLLLLLLLLLLLLLLLLLLLLLLLLLLL UUUUU
L = baris latih (selalu di masa lalu), U = 5 baris uji (masa depan terdekat)| Satu jendela uji (holdout) | Walk-forward (5 lipatan) | |
|---|---|---|
| Jumlah pengukuran | 1 | 5, satu per lipatan |
| Kestabilan angka | bergantung keberuntungan jendela | bisa dirata-rata dan dilihat sebarannya |
| Urutan waktu | terjaga bila dibagi dengan iloc | terjaga oleh desain |
| Biaya komputasi | satu kali melatih model | model dilatih ulang di tiap lipatan |
TimeSeriesSplit dari scikit-learn #
scikit-learn menyediakan kelas TimeSeriesSplit yang membangun lipatan walk-forward secara otomatis. Kita jalankan pada 35 baris tabel fitur dengan lima lipatan, dan seperti pada chapter Evaluasi Forecasting
, naive musiman ikut dihitung tiap lipatan sebagai pembanding — cukup memakai kolom lag_7, karena nilai hari yang sama pekan sebelumnya memang tersedia tanpa pelatihan apa pun.
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import TimeSeriesSplit
rng = np.random.default_rng(42)
n = 42
tanggal = pd.date_range("2025-01-06", periods=n, freq="D")
bobot_hari = np.array([0, 1, 2, 3, 8, 14, 11], dtype=float)
gelas = 40 + 0.6 * np.arange(n) + bobot_hari[tanggal.dayofweek] + rng.normal(0, 4.0, n)
deret = pd.Series(gelas.round().astype(int), index=tanggal, name="gelas")
df = pd.DataFrame({"gelas": deret})
df["lag_1"] = df["gelas"].shift(1)
df["lag_7"] = df["gelas"].shift(7)
df["nomor_hari"] = df.index.dayofweek
df = df.dropna()
fitur = ["lag_1", "lag_7", "nomor_hari"]
tscv = TimeSeriesSplit(n_splits=5)
skor_model = []
skor_naive = []
for i, (idx_latih, idx_uji) in enumerate(tscv.split(df), start=1):
latih = df.iloc[idx_latih]
ujian = df.iloc[idx_uji]
model = LinearRegression().fit(latih[fitur], latih["gelas"])
prediksi = model.predict(ujian[fitur])
mae_model = np.abs(prediksi - ujian["gelas"]).mean()
mae_naive = np.abs(ujian["lag_7"] - ujian["gelas"]).mean()
skor_model.append(mae_model)
skor_naive.append(mae_naive)
print(f"Lipatan {i}: latih {len(latih):2d} baris | uji {len(ujian)} baris "
f"| MAE model {mae_model:.2f} | MAE naive musiman {mae_naive:.2f}")
print()
print(f"LinearRegression : rata-rata MAE {np.mean(skor_model):.2f} "
f"+/- {np.std(skor_model):.2f} gelas")
print(f"Naive musiman : rata-rata MAE {np.mean(skor_naive):.2f} "
f"+/- {np.std(skor_naive):.2f} gelas")Output yang Dihasilkan:
Lipatan 1: latih 10 baris | uji 5 baris | MAE model 4.50 | MAE naive musiman 2.60
Lipatan 2: latih 15 baris | uji 5 baris | MAE model 5.45 | MAE naive musiman 5.60
Lipatan 3: latih 20 baris | uji 5 baris | MAE model 5.01 | MAE naive musiman 6.80
Lipatan 4: latih 25 baris | uji 5 baris | MAE model 1.88 | MAE naive musiman 3.80
Lipatan 5: latih 30 baris | uji 5 baris | MAE model 4.11 | MAE naive musiman 6.80
LinearRegression : rata-rata MAE 4.19 +/- 1.24 gelas
Naive musiman : rata-rata MAE 5.12 +/- 1.67 gelasMari kita bedah baris-baris kodenya:
TimeSeriesSplit(n_splits=5)menyiapkan lima lipatan; karena kita tidak mengaturtest_size, ukurannya dihitung otomatis sebagai35 // (5 + 1) = 5baris per lipatan.tscv.split(df)menghasilkan pasangan indeks posisi (latih, uji) untuk tiap lipatan; jendela latih selalu berakhir tepat sebelum jendela uji dimulai — tanpa celah, tanpa tumpang tindih, tanpa acak.df.iloc[idx_latih]memetakan indeks posisi kembali menjadi baris bertanggal: lipatan pertama berlatih 13-22 Januari lalu diuji 23-27 Januari; lipatan kelima berlatih 13 Januari-11 Februari lalu diuji 12-16 Februari, lima hari terakhir deret kita.model = LinearRegression().fit(...)di dalam loop membuat model dilatih ulang di setiap lipatan — inilah biaya walk-forward yang kita bayar demi kejujuran eksperimen.mae_naivedihitung langsung dari kolomlag_7pada baris uji: ramalan “hari yang sama pekan lalu” yang tidak membutuhkan pelatihan sama sekali.
Membaca Skor per Lipatan #
Lima baris hasil memuat pelajaran yang tidak bisa diberikan satu jendela uji. Skor model berayun dari 1,88 sampai 5,45 — MAE 4,01 yang dulu kita banggakan ternyata hanya satu tarikan dari sebaran selebar itu. Lipatan pertama memperlihatkan kekalahan telak: dengan hanya 10 baris latih, model mencatat 4,50 sementara naive musiman cukup 2,60 — persis pola “data latih sangat sedikit” dari chapter sebelumnya. Mulai lipatan ketiga perannya berbalik dan model menang dengan selisih nyata, karena makin banyak masa lalu yang dipelajari.
Rangkuman rata-rata +/- simpangan baku merangkum keduanya sekaligus: LinearRegression 4,19 +/- 1,24 mengungguli naive musiman 5,12 +/- 1,67 dengan kemenangan pada empat dari lima lipatan. Aturan bacanya: bandingkan rata-rata antar model, lalu periksa konsistensinya — model dengan sebaran sempit lebih bisa dipercaya daripada model yang sesekali bersinar. Bila dua model berdekatan, pilih yang lebih sederhana dan lebih stabil.
Rangkuman #
- Pembagian acak membuat model berlatih memakai masa depan — skornya berubah menjadi ukuran interpolasi; pada data kita 4,01 lawan 4,07 tampak serupa, bukti bahwa kesalahan metodologi tidak selalu terlihat dari angkanya.
- Walk-forward (backtest) melatih model di masa lalu dan mengujinya pada masa depan terdekat, berulang dengan jendela latih yang membesar; model dilatih ulang di setiap lipatan.
TimeSeriesSplit(n_splits=5)membagi 35 baris menjadi lima lipatan uji berisi 5 baris; laporkan skor per lipatan, lalu rangkum dengan rata-rata +/- simpangan baku.- LinearRegression mencatat rata-rata MAE 4,19 +/- 1,24 dan mengungguli naive musiman (5,12 +/- 1,67) pada empat dari lima lipatan; dengan 10 baris latih saja, model masih kalah dari baseline.
Validasi yang berjalan memberi kita angka yang jujur — sekaligus pengingat yang tidak nyaman: skor antar lipatan berayun 1,88 sampai 5,45, dan ayunan itu akan semakin lebar semakin jauh kita meramal. Batas seperti ini tidak bisa dihilangkan dengan model yang lebih rumit; ia hanya bisa dipetakan dan dihormati. Chapter penutup Section ini, Keterbatasan Prediksi Deret Waktu , mengerjakannya.