Disiplin train/val/test

Pemisahan data train, validation, dan test beserta early stopping.

Daftar isi

Model yang diukur pada data pelatihannya sendiri melaporkan angka yang terlalu baik, karena sebagian sampel sudah pernah digunakan saat bobot diperbarui. Topik ini menetapkan disiplin yang mengatasinya, yaitu membagi data menjadi bagian train, validation, dan test, kemudian menerapkan early stopping untuk menghentikan pelatihan ketika loss validation tidak lagi membaik. Hasil akhirnya adalah satu angka test yang diukur sekali di akhir dan layak dilaporkan.

Tiga bagian data dan tiga perannya#

Pembagian standar memisahkan data menjadi tiga bagian dengan peran yang tidak boleh tercampur. Data train digunakan oleh optimizer untuk memperbarui bobot pada setiap batch. Data validasi digunakan untuk mengambil keputusan selama pelatihan, misalnya memilih epoch dengan bobot terbaik atau menyetel hyperparameter. Data test digunakan tepat sekali di akhir untuk memperoleh ukuran kinerja akhir.

Ketiga peran itu dipisahkan karena besaran kebocorannya berbeda. Model tidak pernah melihat validasi maupun test saat belajar, tetapi keputusan pelatihan tetap mengalir ke validasi, sehingga angka validasi juga sedikit optimistis. Test dijaga dari seluruh keputusan, sehingga angkanya paling mendekati kinerja pada data yang benar-benar baru. Proporsi 70/15/15 adalah titik awal yang lazim pada dataset sedang, sedangkan dataset besar dapat mengalokasikan bagian test lebih kecil.

Ringkasan perannya digambarkan sebagai berikut:

Pembagian data train, validation, dan testtrain 70%validation15%test15%memperbarui bobotpada setiap epochpantau lossper epoch,pilih bobotdisentuhsekali diakhir
Pembagian 70/15/15 beserta peran setiap bagian data

Pembagian dataset dengan random_split#

Method random_split dari torch.utils.data membagi dataset menjadi beberapa Subset dengan panjang yang ditentukan. Panjang dihitung dari proporsi, dan sisa pembagian diberikan ke bagian test agar jumlah total tetap konsisten. Argumen generator=torch.Generator().manual_seed(0) memastikan pembagian yang sama dihasilkan pada setiap eksekusi, sehingga angka antara percobaan dapat dibandingkan secara adil. Skrip berikut menggunakan seluruh disiplin ini sekaligus, mulai dari pembagian data, pelatihan dengan early stopping, hingga evaluasi test di akhir:

python early-stopping.py
"""Pembagian train/validation/test dan early stopping pada data sintetis."""

import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset, random_split

torch.manual_seed(0)

# data sintetis: tiga gugus dengan dua fitur yang saling sedikit tumpang-tindih
n_per_kelas = 40
pusat = torch.tensor([[0.0, 0.0], [3.0, 3.0], [-3.0, 3.0]])
fitur = torch.cat([pusat[k] + 0.9 * torch.randn(n_per_kelas, 2) for k in range(3)])
label = torch.cat([torch.full((n_per_kelas,), k) for k in range(3)])
dataset = TensorDataset(fitur, label)

# pembagian 70/15/15 dengan generator berseed
n = len(dataset)
n_latih = int(0.7 * n)
n_val = int(0.15 * n)
n_uji = n - n_latih - n_val
data_latih, data_val, data_uji = random_split(
    dataset, [n_latih, n_val, n_uji],
    generator=torch.Generator().manual_seed(0),
)
print(f"pembagian data: train {len(data_latih)} | validation {len(data_val)} | test {len(data_uji)}")

loader_latih = DataLoader(data_latih, batch_size=32, shuffle=True,
                          generator=torch.Generator().manual_seed(0))
loader_val = DataLoader(data_val, batch_size=64, shuffle=False)
loader_uji = DataLoader(data_uji, batch_size=64, shuffle=False)

model = nn.Sequential(nn.Linear(2, 64), nn.ReLU(), nn.Linear(64, 3))
optimizer = torch.optim.Adam(model.parameters(), lr=0.015)
criterion = nn.CrossEntropyLoss()


def rata_loss(loader):
    """Rata-rata loss sebuah loader, diukur dalam mode evaluasi."""
    model.eval()
    total_loss = 0.0
    total_batch = 0
    with torch.no_grad():
        for xb, yb in loader:
            total_loss += criterion(model(xb), yb).item()
            total_batch += 1
    model.train()
    return total_loss / total_batch


# pelatihan dengan early stopping
terbaik_val = float("inf")
bobot_terbaik = None
epoch_terbaik = 0
kesabaran = 3
tanpa_baik = 0
maks_epoch = 30

for epoch in range(1, maks_epoch + 1):
    jumlah_loss = 0.0
    jumlah_batch = 0
    for xb, yb in loader_latih:
        optimizer.zero_grad()
        loss = criterion(model(xb), yb)
        loss.backward()
        optimizer.step()
        jumlah_loss += loss.item()
        jumlah_batch += 1
    loss_latih = jumlah_loss / jumlah_batch
    loss_val = rata_loss(loader_val)
    print(f"epoch {epoch:2d} | train loss {loss_latih:.4f} | val loss {loss_val:.4f}")
    if loss_val < terbaik_val:
        terbaik_val = loss_val
        bobot_terbaik = {k: v.detach().clone() for k, v in model.state_dict().items()}
        epoch_terbaik = epoch
        tanpa_baik = 0
    else:
        tanpa_baik += 1
        if tanpa_baik >= kesabaran:
            print(f"berhenti dini pada epoch {epoch} (val loss tidak membaik {kesabaran} epoch)")
            break

# test hanya disentuh sekali, setelah bobot terbaik dipilih
model.load_state_dict(bobot_terbaik)
loss_uji = rata_loss(loader_uji)
model.eval()
benar = 0
total = 0
with torch.no_grad():
    for xb, yb in loader_uji:
        benar += (model(xb).argmax(dim=1) == yb).sum().item()
        total += yb.numel()
print(f"bobot terbaik: epoch {epoch_terbaik} (val loss {terbaik_val:.4f})")
print(f"test loss {loss_uji:.4f} | test akurasi {benar / total:.4f}")
text Keluaran
pembagian data: train 84 | validation 18 | test 18
epoch  1 | train loss 0.6776 | val loss 0.4970
epoch  2 | train loss 0.3463 | val loss 0.4511
epoch  3 | train loss 0.2197 | val loss 0.3777
epoch  4 | train loss 0.1442 | val loss 0.3031
epoch  5 | train loss 0.1025 | val loss 0.2761
epoch  6 | train loss 0.0959 | val loss 0.2814
epoch  7 | train loss 0.0810 | val loss 0.3014
epoch  8 | train loss 0.0705 | val loss 0.3302
berhenti dini pada epoch 8 (val loss tidak membaik 3 epoch)
bobot terbaik: epoch 5 (val loss 0.2761)
test loss 0.0404 | test akurasi 1.0000

Dataset berisi 120 sampel, sehingga pembagian menghasilkan 84 sampel train, 18 validation, dan 18 test. Loss validasi turun hingga 0.2761 pada epoch 5, lalu naik tiga epoch berturut-turut, dan pelatihan berhenti pada epoch 8 tanpa menjalankan seluruh 30 epoch yang tersedia. Bobot yang digunakan untuk evaluasi test adalah bobot epoch 5, bukan bobot epoch terakhir yang sudah mulai memburuk.

Early stopping dan bobot terbaik#

Early stopping pada skrip di atas bekerja dengan tiga komponen. Pertama, loss validasi diukur pada setiap epoch oleh fungsi rata_loss, yang memanggil model.eval() dan membungkus perhitungan dengan torch.no_grad(). Kedua, setiap kali loss validasi mencapai nilai terbaik baru, seluruh state_dict disalin ke memori dengan v.detach().clone(), sehingga bobot terbaik tersimpan tanpa menyentuh file. Ketiga, pelatihan berhenti setelah nilai terbaik tidak diperbarui selama kesabaran epoch berturut-turut.

Pemantauan menggunakan loss validasi, bukan loss train, karena keduanya bergerak ke arah yang berbeda saat model mulai menghafal data latihnya. Pada keluaran skrip, train loss terus menurun dari 0.6776 hingga 0.0705, sedangkan loss validasi berbalik naik setelah epoch 5. Apabila pemantauan menggunakan loss train, pelatihan tidak akan pernah berhenti lebih awal dan bobot yang terbawa justru bobot yang sedang menghafal.

Test disentuh tepat sekali, yaitu setelah load_state_dict(bobot_terbaik) dipanggil. Urutan ini penting karena test loss dan test akurasi yang dilaporkan harus berasal dari bobot terbaik yang dipilih validasi, bukan dari bobot epoch terakhir yang sudah memburuk. Dengan urutan tersebut, angka 1.0000 pada test akurasi mewakili kinerja satu model yang dipilih secara adil, bukan hasil pengamatan berulang.

Kesalahan umum#

Kesalahan pertama adalah memanggil random_split tanpa generator. Pembagian berubah pada setiap eksekusi, sehingga dua percobaan yang seharusnya sebanding menggunakan sampel train dan validation yang berbeda. Perbaikannya adalah menyertakan generator=torch.Generator().manual_seed(0) pada setiap pemanggilan.

Kesalahan kedua adalah menyalin bobot terbaik tanpa .clone(). Baris bobot_terbaik = model.state_dict() hanya menyimpan rujukan ke tensor yang sama, dan pelatihan berikutnya memperbarui tensor itu di tempat, sehingga bobot terbaik diam-diam berubah menjadi bobot terakhir. Perbaikannya adalah menyalin setiap nilai dengan v.detach().clone() sebagaimana dilakukan skrip.

Kesalahan ketiga adalah mengukur loss validation dengan model masih dalam mode pelatihan. Pada model dengan dropout atau batch normalization, angka validasi tidak sebanding antara epoch karena perilaku layernya ikut berubah. Perbaikannya adalah membungkus pengukuran dengan model.eval() dan torch.no_grad(), lalu kembali ke model.train() sebelum melatih lagi.

Ringkasan#

  • Data dibagi menjadi train, validation, dan test dengan random_split dan generator berseed agar pembagiannya tetap sama.
  • Loss validasi dipantau setiap epoch untuk early stopping, dan bobot terbaik disalin ke memori dengan detach().clone().
  • Test dievaluasi tepat sekali setelah bobot terbaik dimuat kembali, sehingga angkanya layak dilaporkan.
  • Loss train yang terus turun sementara loss validasi naik adalah tanda model mulai menghafal.

Referensi#