Pelatihan model deret waktu berbeda dari klasifikasi pada satu titik krusial: urutan waktu adalah bagian dari kebenaran yang dievaluasi. Bagian ini melatih LSTM dari Bagian 2 dengan loop standar PyTorch, tetapi dengan dua pengaman khas deret waktu. Pertama, validasi selalu berasal dari periode waktu setelah data latih — model diuji memprediksi masa depan, bukan periode acak. Kedua, early stopping menyimpan bobot epoch terbaik lalu memulihkannya di akhir pelatihan. Sebagai penutup, kita sengaja melanggar aturan pertama pada eksperimen terpisah untuk melihat sendiri betapa menyesatkan hasilnya.
Loop Training dengan Early Stopping #
latih.py mengimpor siapkan_dataset dan ModelDeret, lalu menjalankan pelatihan full-batch Adam selama 150 epoch. Setiap epoch, loss validasi dihitung; bila membaik, salinan bobot disimpan:
"""Training dan validasi model deret waktu dengan pembagian temporal.
Alur latih.py:
1. Siapkan dataset: deret dibagi latih/validasi/uji secara temporal,
dinormalisasi dengan statistik latih, lalu di-window.
2. Latih ModelDeret (LSTM) pada segmen latih, pantau loss validasi.
3. Eksperimen pembanding: latih model kedua dengan jendela latih dan
validasi yang DIACAK BERSAMA sebelum dipotong -- meniru kesalahan
umum shuffle pada data deret waktu -- lalu bandingkan loss
validasinya dengan pembagian yang jujur.
Early stopping diterapkan: bobot epoch dengan loss validasi terbaik
disimpan dan dipulihkan di akhir pelatihan.
"""
import copy
import numpy as np
import torch
import torch.nn as nn
from dataset import siapkan_dataset
from model import ModelDeret, hitung_parameter
SEED = 42
JUMLAH_EPOCH = 150
LAJU_PEMBELAJARAN = 0.005
def ke_tensor(X, y):
"""Ubah array NumPy (N, W, 1) dan (N,) menjadi tensor float32."""
return (torch.tensor(X, dtype=torch.float32),
torch.tensor(y, dtype=torch.float32).unsqueeze(1))
def latih_model(cetak=True):
"""Latih LSTM dengan pembagian temporal yang jujur.
Kembalikan (model, data). Seluruh inisialisasi dikunci seed tetap.
"""
data = siapkan_dataset()
X_latih, y_latih = ke_tensor(data["X_latih"], data["y_latih"])
X_validasi, y_validasi = ke_tensor(data["X_validasi"], data["y_validasi"])
torch.manual_seed(SEED)
model = ModelDeret()
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=LAJU_PEMBELAJARAN)
if cetak:
print(f"jendela latih {len(X_latih)} | jendela validasi "
f"{len(X_validasi)} | parameter {hitung_parameter(model)}")
riwayat_validasi = []
keadaan_terbaik = None
loss_terbaik = float("inf")
for epoch in range(1, JUMLAH_EPOCH + 1):
model.train()
optimizer.zero_grad()
prediksi = model(X_latih)
loss = criterion(prediksi, y_latih)
loss.backward()
optimizer.step()
model.eval()
with torch.no_grad():
loss_validasi = criterion(model(X_validasi), y_validasi).item()
riwayat_validasi.append(loss_validasi)
if loss_validasi < loss_terbaik:
loss_terbaik = loss_validasi
keadaan_terbaik = copy.deepcopy(model.state_dict())
if cetak and (epoch == 1 or epoch % 30 == 0):
print(f"epoch {epoch:3d} | loss latih {loss.item():.6f} | "
f"loss validasi {loss_validasi:.6f}")
model.load_state_dict(keadaan_terbaik) # pulihkan bobot terbaik
if cetak:
print(f"loss validasi terbaik: {loss_terbaik:.6f} "
f"(epoch {riwayat_validasi.index(loss_terbaik) + 1})")
model.eval()
return model, data
def latih_dengan_acak():
"""Pembanding tidak jujur: jendela latih+validasi diacak bersama.
Setelah diacak, 268 jendela menjadi 'latih' dan 48 menjadi
'validasi'. Karena jendela berdekatan pada deret hampir identik,
sebagian 'validasi' praktis sudah dilihat model -> loss validasi
tampak jauh lebih rendah daripada pembagian temporal.
"""
data = siapkan_dataset()
X_gab = np.concatenate([data["X_latih"], data["X_validasi"]], axis=0)
y_gab = np.concatenate([data["y_latih"], data["y_validasi"]], axis=0)
acak = np.random.default_rng(SEED)
urutan = acak.permutation(len(X_gab))
n_latih = len(data["X_latih"])
X_latih, y_latih = ke_tensor(X_gab[urutan[:n_latih]],
y_gab[urutan[:n_latih]])
X_validasi, y_validasi = ke_tensor(X_gab[urutan[n_latih:]],
y_gab[urutan[n_latih:]])
torch.manual_seed(SEED)
model = ModelDeret()
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=LAJU_PEMBELAJARAN)
for _ in range(JUMLAH_EPOCH):
model.train()
optimizer.zero_grad()
loss = criterion(model(X_latih), y_latih)
loss.backward()
optimizer.step()
model.eval()
with torch.no_grad():
loss_validasi = criterion(model(X_validasi), y_validasi).item()
return loss_validasi
def rmse_satuan_asli(mse_ternormalisasi, sigma):
"""Ubah MSE skala ternormalisasi menjadi RMSE satuan asli."""
return float(np.sqrt(mse_ternormalisasi)) * sigma
if __name__ == "__main__":
model, data = latih_model()
with torch.no_grad():
X_validasi = torch.tensor(data["X_validasi"], dtype=torch.float32)
y_validasi = torch.tensor(data["y_validasi"],
dtype=torch.float32).unsqueeze(1)
mse_jujur = nn.MSELoss()(model(X_validasi), y_validasi).item()
rmse_jujur = rmse_satuan_asli(mse_jujur, data["sigma"])
mse_acak = latih_dengan_acak()
rmse_acak = rmse_satuan_asli(mse_acak, data["sigma"])
print("\nperbandingan pembagian data:")
print(f" temporal (jujur) : loss validasi {mse_jujur:.6f} "
f"-> RMSE {rmse_jujur:.3f} satuan asli")
print(f" diacak (bocor) : loss validasi {mse_acak:.6f} "
f"-> RMSE {rmse_acak:.3f} satuan asli")
print(f" rasio RMSE jujur/acak: {rmse_jujur / rmse_acak:.2f}x")Skrip di atas menghasilkan keluaran berikut saat dijalankan di mesin uji (CPU, PyTorch 2.14):
Output yang Dihasilkan:
jendela latih 260 | jendela validasi 40 | parameter 17217
epoch 1 | loss latih 0.959203 | loss validasi 1.723315
epoch 30 | loss latih 0.116379 | loss validasi 0.415431
epoch 60 | loss latih 0.047837 | loss validasi 0.093783
epoch 90 | loss latih 0.039233 | loss validasi 0.059932
epoch 120 | loss latih 0.034599 | loss validasi 0.057371
epoch 150 | loss latih 0.032318 | loss validasi 0.057273
loss validasi terbaik: 0.055168 (epoch 128)
perbandingan pembagian data:
temporal (jujur) : loss validasi 0.055168 -> RMSE 2.427 satuan asli
diacak (bocor) : loss validasi 0.034360 -> RMSE 1.915 satuan asli
rasio RMSE jujur/acak: 1.27xMari kita telaah kode di atas baris per baris:
- Baris 36-53 (
latih_model): data disiapkan sekali, tensor dibuat dari array NumPy, lalu model diinisialisasi dengantorch.manual_seed(SEED)dan Adam learning rate 0.005 — laju kecil dipilih karena LSTM sensitif terhadap pembaruan besar. - Baris 57-75 (loop epoch): pola
zero_grad -> forward -> loss -> backward -> stepberjalan full-batch pada 260 jendela; setelahstep, model pindah ke modeevaluntuk menghitung loss validasi tanpa mengubah bobot. - Baris 68-71 (penyimpanan bobot terbaik):
copy.deepcopy(model.state_dict())menyimpan salinan bobot setiap kali loss validasi membaik;deepcopywajib karenastate_dict()mengembalikan referensi tensor yang terus diperbarui. - Baris 77-82 (pemulihan): setelah 150 epoch,
load_state_dictmengembalikan bobot epoch ke-128 — epoch dengan loss validasi terbaik. Tanpa baris ini, model akhir adalah epoch ke-150 yang sudah mulai melenceng. - Baris 85-126 (
latih_dengan_acak): 300 jendela latih dan validasi digabung, indeksnya diacaknp.random.default_rng(SEED), lalu dipotong kembali 260/40. Model kedua dilatih pada pengaturan yang tidak jujur ini.
Membaca Kurva Pelatihan #
Loss latih turun monoton dari 0.959 ke 0.032 — model menyerap segmen latih dengan baik. Loss validasi menurun sampai 0.0552 pada epoch ke-128, lalu berhenti membaik dan sedikit naik di epoch-epoch akhir (0.0573 pada epoch ke-150). Pola ini adalah overfitting yang belum parah: selisih loss latih-validasi melebar karena model mulai menghafal derau segmen latih. Early stopping menjawabnya dengan memulihkan bobot epoch ke-128, praktik yang dibahas tuntas chapter Early Stopping .
Eksperimen Kebocoran Data #
Bagian akhir keluaran membandingkan dua pembagian pada data yang sama:
| Pembagian | Loss validasi | RMSE satuan asli |
|---|---|---|
| temporal (jujur) | 0.0552 | 2.427 |
| diacak (bocor) | 0.0344 | 1.915 |
Pembagian acak tampak 1,27 kali lebih baik — padahal model dan datanya identik. Penjelasannya mekanis: jendela yang bergeser satu langkah berbagi 19 dari 20 nilainya. Setelah diacak, jendela validasi hampir pasti memiliki tetangga waktu di data latih, sehingga model cukup mengingat pola lokal untuk “memprediksi” — kemampuan yang tidak akan terbawa saat model benar-benar dihadapkan pada masa depan. Kesalahan ini adalah bentuk kebocoran data spesifik deret waktu yang dibedah chapter Data Leakage .
Model terlatih kini menunggu penghakiman yang sesungguhnya. Bagian berikutnya membawanya ke segmen uji yang belum pernah disentuh dan membandingkannya dengan lawan paling jujur di dunia prediksi deret waktu: baseline naive.