Dropout menekan variance dengan mengacak jalur komputasi. Weight decay menekannya dengan menyerang sumbernya: besaran bobot itu sendiri. Bobot yang besar memungkinkan fungsi berubah drastis terhadap perubahan kecil pada input — ciri kurva yang berkelok liar seperti polinomial derajat 15 pada chapter Bias-Variance . Dengan menambahkan penalti pada norma bobot ke fungsi loss, optimizer dipaksa memilih solusi yang lebih sederhana di antara banyak solusi yang sama-sama cocok dengan data latih. Chapter ini membuktikan kesetaraan klasik antara penalti L2 dan weight_decay pada SGD, lalu membahas perbedaan penting yang membuat AdamW menjadi pilihan modern — semuanya diuji nyata pada CPU dengan PyTorch 2.14.1.

Intuisi Penalti L2 #

Bentuk paling langsung dari weight decay adalah menambahkan suku penalti ke fungsi loss: loss_total = loss_data + (lambda/2) * ||w||^2, dengan lambda kekuatan penalti dan ||w||^2 jumlah kuadrat seluruh parameter. Suku ini tidak bergantung pada data — tugasnya murni menghukum bobot besar. Turunannya sederhana: d/dw (lambda/2 * ||w||^2) = lambda * w, sehingga pada tiap langkah optimizer, selain mengikuti gradien data, tiap bobot juga tertarik menuju nol sebesar lambda * w. Bobot yang tidak benar-benar dibutuhkan data untuk menurunkan loss akan menyusut perlahan; bobot yang penting bertahan karena gradien data terus menariknya menjauh dari nol.

Hasil akhirnya adalah model dengan bobot kecil yang menghasilkan fungsi lebih halus: output berubah sedikit demi sedikit terhadap perubahan input, sehingga pola yang dipelajari lebih mungkin berlaku pada data baru. Karena penalti memakai kuadrat ( norma-L2 ), teknik ini lazim disebut regularisasi L2.

Bukti Kesetaraan: SGD weight_decay Sama dengan Penalti L2 #

PyTorch mengekspos weight decay sebagai argumen optimizer, bukan sebagai bagian loss — torch.optim.SGD(model.parameters(), lr=..., weight_decay=...). Dokumentasi torch.optim menyebutnya penalti L2 pada loss. Berkas weight_decay_adamw.py membuktikan klaim itu secara numerik: dua model identik dilatih 300 epoch — satu lewat weight_decay=0.01, satu lagi dengan menambahkan 0.5 * LAMBDA * ||w||^2 langsung pada loss — lalu norma bobot akhirnya dibandingkan:

python
"""Weight decay: kesetaraan SGD+L2 dan efek AdamW pada bobot akhir.

Bagian 1 membuktikan bahwa weight_decay pada SGD sama dengan menambahkan
suku penalti L2 (lambda/2 * ||w||^2) langsung pada loss: dua jalur
pelatihan menghasilkan bobot akhir yang identik sampai banyak desimal.
Bagian 2 melatih tiga MLP identik dengan torch.optim.AdamW pada
weight_decay 0, 0.01, dan 0.1 lalu membandingkan norma bobot akhir,
akurasi latih, dan akurasi validasi.
"""

import torch
import torch.nn as nn
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split

torch.manual_seed(42)
criterion = nn.CrossEntropyLoss()

# ---------- 1. Data: make_moons 150 sampel ----------
X, y = make_moons(n_samples=150, noise=0.30, random_state=42)
X_latih, X_val, y_latih, y_val = train_test_split(
    X, y, test_size=0.40, random_state=42
)
rata2 = X_latih.mean(axis=0)
simpangan = X_latih.std(axis=0)
X_latih = (X_latih - rata2) / simpangan
X_val = (X_val - rata2) / simpangan

X_latih_t = torch.tensor(X_latih, dtype=torch.float32)
y_latih_t = torch.tensor(y_latih, dtype=torch.long)
X_val_t = torch.tensor(X_val, dtype=torch.float32)
y_val_t = torch.tensor(y_val, dtype=torch.long)

def buat_model():
    torch.manual_seed(42)
    return nn.Sequential(
        nn.Linear(2, 128), nn.ReLU(),
        nn.Linear(128, 128), nn.ReLU(),
        nn.Linear(128, 2),
    )

def norm_bobot(model):
    kuadrat = sum((p.detach() ** 2).sum() for p in model.parameters())
    return kuadrat.sqrt().item()

def akurasi(model, X, y_):
    model.eval()
    with torch.no_grad():
        return (model(X).argmax(dim=1) == y_).float().mean().item()

# ---------- 2. Bukti: SGD weight_decay == penalti L2 pada loss ----------
LAMBDA = 0.01
EPOCH_BAGIAN_1 = 300

model_a = buat_model()
optim_a = torch.optim.SGD(model_a.parameters(), lr=0.5, weight_decay=LAMBDA)

model_b = buat_model()
optim_b = torch.optim.SGD(model_b.parameters(), lr=0.5)

for _ in range(EPOCH_BAGIAN_1):
    optim_a.zero_grad()
    loss_a = criterion(model_a(X_latih_t), y_latih_t)
    loss_a.backward()
    optim_a.step()

for _ in range(EPOCH_BAGIAN_1):
    optim_b.zero_grad()
    penalti = sum((p ** 2).sum() for p in model_b.parameters())
    loss_b = criterion(model_b(X_latih_t), y_latih_t) + 0.5 * LAMBDA * penalti
    loss_b.backward()
    optim_b.step()

print("bagian 1: SGD weight_decay=0.01 vs penalti L2 manual (300 epoch)")
print(f"norma bobot akhir SGD+weight_decay : {norm_bobot(model_a):.6f}")
print(f"norma bobot akhir SGD+penalti loss : {norm_bobot(model_b):.6f}")
print(f"selisih kedua norma                : "
      f"{abs(norm_bobot(model_a) - norm_bobot(model_b)):.2e}")
print("kesimpulan: keduanya identik -> weight_decay SGD = penalti L2\n")

# ---------- 3. AdamW: weight_decay 0, 0.01, 0.1 ----------
EPOCH_BAGIAN_2 = 1500
print("bagian 2: AdamW lr=0.05 | MLP [2, 128, 128, 2] | 1500 epoch")
print(f"{'weight_decay':>12s} | {'norma bobot':>11s} | "
      f"{'akurasi latih':>13s} | {'akurasi val':>11s}")
for wd in (0.0, 0.01, 0.1):
    model = buat_model()
    optimizer = torch.optim.AdamW(model.parameters(), lr=0.05, weight_decay=wd)
    for _ in range(EPOCH_BAGIAN_2):
        model.train()
        optimizer.zero_grad()
        loss = criterion(model(X_latih_t), y_latih_t)
        loss.backward()
        optimizer.step()
    print(f"{wd:12.2f} | {norm_bobot(model):11.4f} | "
          f"{akurasi(model, X_latih_t, y_latih_t):13.3f} | "
          f"{akurasi(model, X_val_t, y_val_t):11.3f}")

Output yang Dihasilkan:

text
bagian 1: SGD weight_decay=0.01 vs penalti L2 manual (300 epoch)
norma bobot akhir SGD+weight_decay : 4.783152
norma bobot akhir SGD+penalti loss : 4.783152
selisih kedua norma                : 0.00e+00
kesimpulan: keduanya identik -> weight_decay SGD = penalti L2

bagian 2: AdamW lr=0.05 | MLP [2, 128, 128, 2] | 1500 epoch
weight_decay | norma bobot | akurasi latih | akurasi val
        0.00 |     54.4808 |         1.000 |       0.800
        0.01 |     47.1728 |         1.000 |       0.817
        0.10 |     29.5831 |         0.989 |       0.867

Mari kita telaah kode di atas baris per baris:

  1. Baris 20-32 (data): make_moons 150 sampel, 90 latih / 60 validasi, standardisasi dari data latih saja — bahan overfitting yang sama dengan chapter-chapter sebelumnya agar hasil sebanding.
  2. Baris 34-40 (buat_model): seed di-reset ke 42 di dalam fungsi sehingga seluruh model pada skrip ini berangkat dari bobot awal yang identik.
  3. Baris 42-44 (norm_bobot): menghitung sqrt(jumlah p²) atas seluruh parameter — ukuran total besaran bobot; .detach() memutusnya dari graf autograd karena hanya dipakai untuk pelaporan.
  4. Baris 55-59 (dua jalur): model_a memakai SGD(..., weight_decay=LAMBDA); model_b memakai SGD polos — penalti akan ditambahkan manual pada loss-nya.
  5. Baris 61-65 (jalur weight_decay): loop standar zero_grad → forward → backward → step; penalti terjadi di dalam step().
  6. Baris 67-72 (jalur penalti loss): baris 69 menjumlahkan kuadrat seluruh parameter (dalam graf autograd), baris 70 menambahkan 0.5 * LAMBDA * penalti ke loss sebelum backward() — gradien penalti mengalir lewat jalur normal.
  7. Baris 74-79 (verifikasi): kedua norma tercetak 4,783152 dengan selisih 0.00e+00 — dua mekanisme yang berbeda tempat terbukti identik hasil.
  8. Baris 86-97 (AdamW): tiga model dilatih dengan torch.optim.AdamW pada weight_decay 0, 0,01, dan 0,1; norma bobot, akurasi latih, dan akurasi validasi dicetak per model.

AdamW: Decay yang Terpisah dari Penskalaan Adaptif #

Mengapa bagian kedua memakai optimizer khusus bernama AdamW, bukan Adam dengan weight_decay? Alasannya fundamental. Adam — yang kita kenal pada chapter Loss dan Optimizer — menskalakan tiap komponen gradien dengan 1/sqrt(v̂), estimasi momen kedua. Bila penalti L2 dimasukkan lewat gradien (seperti pada SGD), penalti itu ikut terbagi oleh penskalaan adaptif: parameter dengan riwayat gradien kecil mendapat penalti yang justru diperbesar, dan sebaliknya — kekuatan regularisasi menjadi tidak merata dan bergantung pada tiap parameter.

AdamW memutus masalah itu dengan decoupled weight decay: penalti tidak melewati jalur gradien adaptif sama sekali, melainkan diterapkan langsung sebagai penyusutan bobot w ← w − lr * weight_decay * w pada tiap step(). Efeknya, kekuatan decay seragam untuk seluruh parameter dan tidak berinteraksi dengan momentum maupun penskalaan Adam. Perbandingan dua pendekatan tersebut dirangkum berikut:

AspekSGD + weight_decay / penalti L2AdamW (decoupled)
Jalur penaltimelalui gradien losslangsung pada bobot saat step()
Kesetaraan L2identik (terbukti di bagian 1)tidak identik dengan L2 pada loss
Kekuatan per parameterbisa terdistorsi penskalaan adaptif (jika dipakai di Adam)seragam, independen dari v̂
Kapan dipakaiSGD klasik, masalah kecilstandar modern: Adam + regularisasi bobot

Membaca Hasil: Norma Menyusut, Validasi Membaik #

Tiga baris akhir keluaran memperlihatkan spektrum kekuatan penalti. Dengan weight_decay=0, norma bobot tumbuh bebas hingga 54,4808 dan model overfitting (akurasi latih 1,000, validasi 0,800). Nilai 0,01 menarik norma turun ke 47,1728 dengan validasi membaik ke 0,817. Nilai 0,1 menekan norma drastis ke 29,5831 — akurasi latih merosot sedikit ke 0,989 (penalti mulai membatasi kapasitas) tetapi akurasi validasi melonjak ke 0,867. Pola ini konsisten dengan bab Overfitting dan Underfitting : kita menukar sedikit kesempurnaan pada data latih untuk generalisasi yang lebih baik.

Weight decay dan dropout sama-sama menekan variance selama pelatihan berjalan. Pertanyaan berikutnya: apakah kita perlu melatih sampai epoch terakhir sama sekali? Chapter Early Stopping menunjukkan bahwa momen terbaik model sering kali tercapai jauh sebelum kuota epoch habis — dan menghentikan pelatihan tepat pada momen itu adalah regularisasi paling murah yang ada.