Chapter sebelumnya meninggalkan dua kursi kosong di siklus pelatihan: fungsi loss yang menghakimi prediksi, dan optimizer yang memindahkan parameter berdasarkan hukuman itu. Keduanya sudah kita bangun manual dengan NumPy di section Dasar Neural Network ; torch.nn dan torch.optim mengambil alihnya. Tetapi pemilihan loss menyimpan dua jebakan numerik: BCELoss yang kehilangan gradien pada prediksi ekstrem, dan CrossEntropyLoss yang diumpani softmax padahal menuntut raw logits. Chapter ini membedah keduanya dengan bukti angka, lalu menutup dengan training mini lengkap yang menyatukan model, loss, dan optimizer.

Loss juga adalah nn.Module #

Fungsi loss di PyTorch adalah nn.Module yang bisa dipanggil: nn.MSELoss()(prediksi, target) mengembalikan tensor skalar siap-backward(). Pemilihan loss mengikuti tugas dan aktivasi output — tabel berikut peta rujukannya:

LossTugasAktivasi outputInput yang diberikan
nn.MSELossregresitanpa aktivasiprediksi kontinu
nn.BCELossklasifikasi binersigmoid di modelprobabilitas 0..1
nn.BCEWithLogitsLossklasifikasi binertanpa — sigmoid di dalam lossraw logit
nn.CrossEntropyLossklasifikasi multikelastanpa — softmax di dalam lossraw logits per kelas

nn.MSELoss berperilaku persis seperti MSE yang kita hitung manual di chapter Loss Function . Perhatikan pola dua baris terakhir tabel: loss modern memindahkan aktivasi terakhir ke dalam fungsi loss — bukan gaya penulisan, melainkan strategi stabilitas numerik.

Stabilitas: BCELoss versus BCEWithLogitsLoss #

nn.BCELoss menerima probabilitas hasil sigmoid. Masalahnya, sigmoid(30.0) dibulatkan float32 menjadi tepat 1.0, dan −log(1 − 1.0) adalah −log(0). PyTorch membatasi (clamping) nilainya agar tidak menghasilkan inf, tetapi harganya fatal: gradien menjadi nol tepat ketika model paling salah. nn.BCEWithLogitsLoss menerima raw logit dan menghitung sigmoid + BCE dalam satu ekspresi yang distabilkan lewat trik log-sum-exp. stabilitas_loss.py membuktikan keduanya:

python
"""Stabilitas loss: BCELoss vs BCEWithLogitsLoss dan jebakan softmax ganda."""

import torch
import torch.nn as nn

torch.manual_seed(42)

# ---- Kasus 1: logit ekstrem, model sangat yakin tetapi keliruan ----
logit = torch.tensor([30.0], requires_grad=True)
target = torch.tensor([0.0])          # kelas sebenarnya 0

prob = torch.sigmoid(logit)           # BCELoss minta probabilitas, bukan logit
print("sigmoid(30.0)                 :", prob.item())
print("BCELoss(prob, target)         :", nn.BCELoss()(prob, target).item())
print("BCEWithLogitsLoss(logit, ...) :", nn.BCEWithLogitsLoss()(logit, target).item())

nn.BCELoss()(prob, target).backward()
grad_bce = logit.grad.item()
logit2 = torch.tensor([30.0], requires_grad=True)
nn.BCEWithLogitsLoss()(logit2, target).backward()
grad_stabil = logit2.grad.item()
print(f"gradien thd logit -- BCELoss: {grad_bce:.4f} | BCEWithLogits: {grad_stabil:.4f}")
print()

# ---- Kasus 2: CrossEntropyLoss menuntut raw logits ----
logit_kelas = torch.tensor([[2.0, 1.0, 0.1]])
target_kelas = torch.tensor([0])      # kelas sebenarnya indeks 0

ce = nn.CrossEntropyLoss()
print("CrossEntropyLoss(logits)      :", ce(logit_kelas, target_kelas).item())

prob_kelas = torch.softmax(logit_kelas, dim=1)   # softmax dulu = kesalahan
print("CrossEntropyLoss(softmax)     :", ce(prob_kelas, target_kelas).item())
print("prediksi sama, loss membengkak karena softmax dihitung dua kali")

Output yang Dihasilkan:

text
sigmoid(30.0)                 : 1.0
BCELoss(prob, target)         : 100.0
BCEWithLogitsLoss(logit, ...) : 30.0
gradien thd logit -- BCELoss: 0.0000 | BCEWithLogits: 1.0000

CrossEntropyLoss(logits)      : 0.4170299470424652
CrossEntropyLoss(softmax)     : 0.8021209239959717
prediksi sama, loss membengkak karena softmax dihitung dua kali

Mari kita telaah kode di atas baris per baris:

  1. Baris 9-12 (skenario): logit 30.0 dengan target kelas 0 — model sangat yakin pada jawaban yang salah. torch.sigmoid(logit) di baris 12 adalah langkah pengguna BCELoss, dan float32 membulatkannya menjadi tepat 1.0.
  2. Baris 14-15 (nilai loss): BCELoss menjawab 100.0 — angka buatan pembatasan log. BCEWithLogitsLoss menjawab 30.0, tepat softplus(30) = log(1 + e³⁰) — nilai matematis yang benar.
  3. Baris 17-22 (gradien): bagian terpenting. Gradien BCELoss terhadap logit 0.0000 — model paling salah justru berhenti belajar; BCEWithLogitsLoss memberi 1.0000 (sigmoid(30) − 0), sinyal koreksi penuh.
  4. Baris 26-30 (kasus kedua): CrossEntropyLoss diumpani raw logits [2.0, 1.0, 0.1] dan target indeks 0; hasil 0.4170 cocok dengan hitungan manual −log(e²/(e²+e¹+e⁰·¹)).
  5. Baris 32-33 (softmax ganda): setelah torch.softmax diterapkan dulu, loss melonjak ke 0.8021 padahal prediksi argmax tidak berubah — CrossEntropyLoss menerapkan log-softmax sendiri di dalam, sehingga softmax kedua meratakan kembali distribusi yang sudah rata.

Optimizer: SGD dan Adam #

Optimizer membawa gradien dari .grad menjadi pembaruan parameter. torch.optim.SGD menjalankan aturan yang kita tulis manual di chapter Gradient Descent : w ← w − lr · grad. torch.optim.Adam menghitung ukuran langkah berbeda untuk tiap parameter — gradien konsisten mendapat langkah besar, gradien goyah mendapat langkah kecil — sehingga lr lazimnya jauh lebih kecil (0.001–0.01). Keduanya menerima kontrak yang sama: model.parameters(), hyperparameter lr, lalu siklus zero_grad() dan step(). Adam umumnya konvergen lebih cepat; SGD unggul kesederhanaan dan hemat memori.

Training Mini Lengkap #

Berkas latih_mlp.py merangkai seluruhnya: data sintetis dua gumpalan, MLP dari chapter sebelumnya, CrossEntropyLoss, lalu SGD dan Adam berlomba dari inisialisasi bobot yang sama:

python
"""Training mini lengkap: pasangan model-loss-optimizer pada data sintetis.

Dua gumpalan titik 2D dilatih dengan MLP kecil + CrossEntropyLoss.
SGD dan Adam mengambil inisialisasi bobot yang sama supaya perbandingannya adil.
"""

import torch
import torch.nn as nn

torch.manual_seed(42)

# Data sintetis: dua gumpalan yang saling menempel sebagian
n = 50
X0 = torch.randn(n, 2) - 1.0            # kelas 0 berkumpul di sekitar (-1, -1)
X1 = torch.randn(n, 2) + 1.0            # kelas 1 berkumpul di sekitar (+1, +1)
X = torch.cat([X0, X1])
y = torch.cat([torch.zeros(n, dtype=torch.long),
               torch.ones(n, dtype=torch.long)])
print("Data:", tuple(X.shape), "| kelas:", y.tolist().count(0), "dan", y.tolist().count(1))
print()


def buat_model():
    """Model dengan inisialisasi bobot identik untuk tiap optimizer."""
    torch.manual_seed(7)
    return nn.Sequential(
        nn.Linear(2, 16),
        nn.ReLU(),
        nn.Linear(16, 2),               # 2 logits: satu per kelas
    )


def latih(nama, kelas_optimizer, lr, jumlah_epoch=15):
    model = buat_model()
    loss_fn = nn.CrossEntropyLoss()
    optimizer = kelas_optimizer(model.parameters(), lr=lr)
    print(f"optimizer {nama} (lr={lr}):")
    for epoch in range(1, jumlah_epoch + 1):
        optimizer.zero_grad()
        logits = model(X)
        loss = loss_fn(logits, y)
        loss.backward()
        optimizer.step()
        if epoch in (1, 5, 10, 15):
            print(f"  epoch {epoch:2d}: loss = {loss.item():.4f}")
    with torch.no_grad():
        benar = (model(X).argmax(dim=1) == y).sum().item()
    print(f"  akurasi akhir: {benar / len(y):.3f}")
    return model


model_sgd = latih("SGD ", torch.optim.SGD, lr=0.1)
print()
model_adam = latih("Adam", torch.optim.Adam, lr=0.01)

Output yang Dihasilkan:

text
Data: (100, 2) | kelas: 50 dan 50

optimizer SGD  (lr=0.1):
  epoch  1: loss = 0.6297
  epoch  5: loss = 0.4705
  epoch 10: loss = 0.3729
  epoch 15: loss = 0.3194
  akurasi akhir: 0.870

optimizer Adam (lr=0.01):
  epoch  1: loss = 0.6297
  epoch  5: loss = 0.4713
  epoch 10: loss = 0.3424
  epoch 15: loss = 0.2689
  akurasi akhir: 0.880

Mari kita telaah kode di atas baris per baris:

  1. Baris 12-18 (data): dua gumpalan randn digeser ke sekitar (-1, -1) dan (+1, +1) — menempel sebagian, sehingga loss tidak akan pernah nol dan akurasi berhenti di kisaran 0,9. Label disimpan sebagai torch.long karena CrossEntropyLoss menuntut indeks kelas, bukan one-hot.
  2. Baris 23-30 (buat_model): torch.manual_seed(7) dipanggil setiap fungsi berjalan, sehingga SGD dan Adam menerima bobot awal identik — epoch 1 keduanya loss 0.6297, bukti perbandingan adil. Layer output (16, 2) menghasilkan dua raw logits tanpa aktivasi.
  3. Baris 33-36 (pasangan model-loss-optimizer): tiga baris ini seluruh konfigurasi training PyTorch — model dari chapter nn.Module , loss dari tabel di atas, optimizer dengan model.parameters() dan lr.
  4. Baris 38-43 (siklus lima langkah): identik dengan chapter Automatic Differentiation , kini dengan model utuh — zero_grad, forward, loss, backward, step — seluruh batch sekali per epoch.
  5. Baris 46-48 (evaluasi): argmax(dim=1) memilih kelas ber-logsit terbesar; blok no_grad() menandai ini inferensi.
  6. Baris 52-54 (perlombaan): SGD berhenti di 0.3194 (akurasi 0.870); Adam mencapai 0.2689 (0.880) dengan epoch sama — Adam sedikit lebih cepat pada dataset mungil ini.

Dokumentasi keluarga loss tersedia di docs.pytorch.org/docs/stable/nn.html , optimizer beserta hyperparameternya di docs.pytorch.org/docs/stable/optim.html , dan detail stabilitas BCEWithLogitsLoss di docs.pytorch.org/docs/stable/generated/torch.nn.BCEWithLogitsLoss.html . Model, loss, dan optimizer kini lengkap — yang tersisa sebelum training loop sungguhan adalah bahan bakunya: menyuapi data ke model dalam batch yang rapi, tugas chapter Dataset dan DataLoader .