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:

python Loop training (src/latih.py)
"""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:

text
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.27x

Mari kita telaah kode di atas baris per baris:

  1. Baris 36-53 (latih_model): data disiapkan sekali, tensor dibuat dari array NumPy, lalu model diinisialisasi dengan torch.manual_seed(SEED) dan Adam learning rate 0.005 — laju kecil dipilih karena LSTM sensitif terhadap pembaruan besar.
  2. Baris 57-75 (loop epoch): pola zero_grad -> forward -> loss -> backward -> step berjalan full-batch pada 260 jendela; setelah step, model pindah ke mode eval untuk menghitung loss validasi tanpa mengubah bobot.
  3. Baris 68-71 (penyimpanan bobot terbaik): copy.deepcopy(model.state_dict()) menyimpan salinan bobot setiap kali loss validasi membaik; deepcopy wajib karena state_dict() mengembalikan referensi tensor yang terus diperbarui.
  4. Baris 77-82 (pemulihan): setelah 150 epoch, load_state_dict mengembalikan bobot epoch ke-128 — epoch dengan loss validasi terbaik. Tanpa baris ini, model akhir adalah epoch ke-150 yang sudah mulai melenceng.
  5. Baris 85-126 (latih_dengan_acak): 300 jendela latih dan validasi digabung, indeksnya diacak np.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:

PembagianLoss validasiRMSE satuan asli
temporal (jujur)0.05522.427
diacak (bocor)0.03441.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.