Dua chapter terakhir memberi kita jaringan yang bisa menghitung keluaran dari bobot apa pun — tetapi bobotnya masih angka acak, dan jaringan belum tahu seberapa jauh keluarannya meleset. Proses belajar membutuhkan dua komponen yang hilang: loss function yang mengubah “salah” menjadi satu angka yang bisa diminimalkan, dan backpropagation yang menghitung ke arah mana setiap bobot harus digeser. Keduanya adalah jantung pelatihan neural network, dan keduanya bisa kita bangun serta uji dengan NumPy. Chapter ini diakhiri dengan teknik yang menjadi standar industri untuk membuktikan kebenaran implementasi: gradient check, membandingkan gradien analitik hasil backpropagation dengan hampiran numerik — selisih terkecil kita nanti berorde sepuluh pangkat minus sebelas.

Loss Function: Alat Ukur Galat #

Loss function memetakan pasangan (prediksi, target) menjadi satu angka; semakin kecil semakin baik. Pilihan loss mengikuti jenis masalah, dan ketiganya sudah familiar dari section regresi dan klasifikasi:

text
MSE  = mean((y_hat - y)^2)                        -> regresi
BCE  = -mean( y*log(p) + (1-y)*log(1-p) )         -> klasifikasi biner
CCE  = -mean( log( softmax(z)_kelas_benar ) )     -> klasifikasi banyak kelas

MSE menghukum galat besar secara kuadratik sehingga pencilan terasa berat. BCE membandingkan peluang prediksi p dengan label biner y — prediksi 0,9 pada label 1 nyaris tanpa hukuman, prediksi 0,1 pada label 1 dihukum keras. CCE adalah generalisasi BCE untuk banyak kelas: ambil peluang kelas yang benar hasil softmax, lalu ambil negatif logaritmanya. Mari kita hitung ketiganya, sekaligus membuktikan jebakan numerik CCE:

python
# Tiga loss function inti dan masalah stabilitas numeriknya:
# MSE untuk regresi, binary cross-entropy untuk klasifikasi biner,
# categorical cross-entropy untuk banyak kelas (versi naif vs stabil).

import numpy as np


def mse(y_hat, y):
    """Mean squared error untuk regresi."""
    selisih = y_hat - y
    return float(np.mean(selisih ** 2))


def bce(peluang, y):
    """Binary cross-entropy; peluang diklip agar log tetap terdefinisi."""
    p = np.clip(peluang, 1e-12, 1.0 - 1e-12)
    return float(-np.mean(y * np.log(p) + (1.0 - y) * np.log(1.0 - p)))


def cce_stabil(logit, indeks_kelas):
    """Categorical cross-entropy versi stabil lewat log-sum-exp."""
    z = logit - logit.max()
    log_jumlah = np.log(np.exp(z).sum())
    log_peluang = z - log_jumlah
    return float(-log_peluang[indeks_kelas])


print("== MSE: prediksi harga rumah (dalam ratus juta rupiah) ==")
y = np.array([3.0, 2.0, 4.5, 5.0])
y_hat = np.array([2.5, 2.3, 4.2, 5.4])
print(f"target    = {y.tolist()}")
print(f"prediksi  = {y_hat.tolist()}")
print(f"MSE       = {mse(y_hat, y):.4f}")

print("\n== BCE: prediksi peluang email spam ==")
peluang = np.array([0.9, 0.2, 0.7, 0.6])
label = np.array([1.0, 0.0, 1.0, 0.0])
print(f"peluang   = {peluang.tolist()}")
print(f"label     = {label.astype(int).tolist()}")
print(f"BCE       = {bce(peluang, label):.4f}")

print("\n== CCE: logit ekstrem, versi naif vs stabil ==")
logit_besar = np.array([1000.0, 1000.0, 1000.0])
with np.errstate(over="ignore", invalid="ignore"):
    peluang_naive = np.exp(logit_besar) / np.exp(logit_besar).sum()
    cce_naive = float(-np.log(peluang_naive[0]))
z = logit_besar - logit_besar.max()
peluang_stabil = np.exp(z) / np.exp(z).sum()
print(f"logit                  = {logit_besar.tolist()}")
print(f"CCE naif (exp dulu)    = {cce_naive}")
print(f"CCE stabil (log-sum)   = {cce_stabil(logit_besar, 0):.4f}")
print(f"peluang stabil         = {np.round(peluang_stabil, 4).tolist()}")

Output yang Dihasilkan:

text
== MSE: prediksi harga rumah (dalam ratus juta rupiah) ==
target    = [3.0, 2.0, 4.5, 5.0]
prediksi  = [2.5, 2.3, 4.2, 5.4]
MSE       = 0.1475

== BCE: prediksi peluang email spam ==
peluang   = [0.9, 0.2, 0.7, 0.6]
label     = [1, 0, 1, 0]
BCE       = 0.4004

== CCE: logit ekstrem, versi naif vs stabil ==
logit                  = [1000.0, 1000.0, 1000.0]
CCE naif (exp dulu)    = nan
CCE stabil (log-sum)   = 1.0986
peluang stabil         = [0.3333, 0.3333, 0.3333]

Tiga pengamatan dari output di atas:

  1. Baris 28–33 (MSE): keempat galat prediksi harga bernilai −0,5 hingga +0,4, tetapi kuadratnya menjadikan MSE 0,1475 — galat terbesar (0,5) menyumbang porsi terbesar.
  2. Baris 35–40 (BCE): fungsi bce pada baris 14–17 mengklip peluang ke rentang [1e-12, 1-1e-12] sebelum mengambil log. Tanpa pengklipan, prediksi 0 atau tepat 1 menghasilkan log(0) = tak hingga.
  3. Baris 42–52 (CCE): inilah jebakan stabilitas. Hitung softmax secara naif pada logit 1000 membuat np.exp(1000) meluap menjadi inf, lalu inf/inf menjadi nan. Versi stabil pada baris 20–25 mengurangi nilai maksimum logit lebih dulu (log-sum-exp trick): hasilnya loss 1,0986 (tepat log 3, karena ketiga kelas identik) dan peluang bersih 0,3333 untuk tiap kelas.

Backpropagation: Aturan Rantai yang Dijalankan Mundur #

Backpropagation bukan algoritme baru — ia adalah aturan rantai kalkulus yang dieksekusi secara efisien dari belakang ke depan. Bayangkan jaringan sebagai graf komputasi: X -> Z1 -> A1 -> Z2 -> y_hat -> L. Untuk setiap simpul, gradien dari loss dihitung dengan resep seragam:

text
gradien_lokal = gradien_dari_hulu x turunan_fungsi_lokal

Untuk jaringan 1 lapisan tersembunyi (tanh) dengan keluaran linier dan loss MSE, seluruh turunannya dapat dituliskan ringkas:

text
Maju :  Z1 = X @ W1 + b1     A1 = tanh(Z1)
        Z2 = A1 @ W2 + b2    y_hat = Z2
        L   = mean((y_hat - y)^2)

Mundur:  dL/dZ2 = 2(y_hat - y)/n            (turunan loss langsung)
         dL/dW2 = A1^T @ dL/dZ2             (aturan rantai, bentuk matriks)
         dL/db2 = jumlah baris dL/dZ2
         dL/dA1 = dL/dZ2 @ W2^T              (gradien mengalir ke belakang)
         dL/dZ1 = dL/dA1 * (1 - A1^2)        (turunan tanh, bab sebelumnya)
         dL/dW1 = X^T @ dL/dZ1
         dL/db1 = jumlah baris dL/dZ1

Perhatikan pola simetrinya: bobot selalu dihitung sebagai masukan^T @ gradien_keluaran, dan gradien yang dikirim ke lapisan sebelumnya selalu gradien_keluaran @ W^T. Aturan turunan tanh 1 - A1^2 persis kolom d/dx pada tabel chapter Fungsi Aktivasi — di sinilah turunan-turunan itu akhirnya dipakai. Efisiensinya luar biasa: satu kali hitung maju + satu kali hitung mundur menghasilkan gradien untuk semua parameter sekaligus, dibandingkan menghitung ulang seluruh jaringan untuk tiap parameter satu per satu.

Gradient Check: Menguji Backprop dengan Beda Hingga #

Bagaimana kita tahu rumus mundur di atas tidak salah ketik? Jawaban standarnya adalah gradient check: dekati tiap turunan secara numerik dengan beda hingga pusat, (L(θ+ε) − L(θ−ε)) / 2ε, lalu bandingkan dengan gradien analitik. Implementasi yang benar akan menghasilkan selisih berorde 1e-8 atau lebih kecil. Berikut demo lengkapnya — pemeriksaan dulu, lalu pelatihan singkat sebagai bukti bahwa gradien yang benar memang menurunkan loss:

python
# Gradient check: gradien analitik hasil backpropagation dibandingkan
# dengan gradien numerik beda hingga pada jaringan 1 lapisan tersembunyi.
# Selisih maksimum yang sehat berorde 1e-8 atau lebih kecil. Setelah lolos
# pemeriksaan, jaringan dilatih beberapa epoch dan loss ditampilkan.

import numpy as np

rng = np.random.default_rng(42)

# Data regresi sintetis kecil: 8 contoh, 3 fitur, target nonlinear.
X = rng.uniform(-1.0, 1.0, size=(8, 3))
y = (X[:, 0] * X[:, 1] + 0.5 * X[:, 2] ** 2 + 0.3).reshape(-1, 1)

# Arsitektur: 3 masukan -> 5 neuron tanh -> 1 keluaran linier.
W1 = rng.normal(0.0, 1.0 / np.sqrt(3), size=(3, 5))
b1 = np.zeros(5)
W2 = rng.normal(0.0, 1.0 / np.sqrt(5), size=(5, 1))
b2 = np.zeros(1)

param = {"W1": W1, "b1": b1, "W2": W2, "b2": b2}


def hitung_loss(param, X, y):
    """Forward pass + MSE; bobot diambil dari kamus param."""
    A1 = np.tanh(X @ param["W1"] + param["b1"])
    y_hat = A1 @ param["W2"] + param["b2"]
    return float(np.mean((y_hat - y) ** 2))


def gradien_analitik(param, X, y):
    """Backpropagation untuk MSE pada jaringan 3-5-1."""
    A1 = np.tanh(X @ param["W1"] + param["b1"])
    y_hat = A1 @ param["W2"] + param["b2"]
    n = len(y)
    dZ2 = 2.0 * (y_hat - y) / n                 # gradien loss thd Z2
    dW2 = A1.T @ dZ2
    db2 = dZ2.sum(axis=0)
    dA1 = dZ2 @ param["W2"].T                   # gradien loss thd A1
    dZ1 = dA1 * (1.0 - A1 ** 2)                 # turunan tanh
    dW1 = X.T @ dZ1
    db1 = dZ1.sum(axis=0)
    return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}


def gradien_numerik(param, X, y, eps=1e-6):
    """Beda hingga pusat: (L(theta+eps) - L(theta-eps)) / (2 eps)."""
    hasil = {}
    for nama in param:
        P = param[nama].copy()
        G = np.zeros_like(P)
        for idx in np.ndindex(P.shape):
            naik = param.copy()
            turun = param.copy()
            P_naik = P.copy()
            P_naik[idx] += eps
            P_turun = P.copy()
            P_turun[idx] -= eps
            naik[nama] = P_naik
            turun[nama] = P_turun
            G[idx] = (hitung_loss(naik, X, y) - hitung_loss(turun, X, y)) / (2 * eps)
        hasil[nama] = G
    return hasil


print("== Gradient check (sebelum pelatihan) ==")
analitik = gradien_analitik(param, X, y)
numerik = gradien_numerik(param, X, y)
selisih_maks = 0.0
for nama in ("W1", "b1", "W2", "b2"):
    selisih = np.abs(analitik[nama] - numerik[nama]).max()
    selisih_maks = max(selisih_maks, float(selisih))
    print(f"  {nama}: selisih maksimum analitik vs numerik = {selisih:.3e}")
print(f"  keseluruhan: {selisih_maks:.3e} (ambang sehat <= 1e-6)")

print("\n== Pelatihan 300 epoch (gradient descent penuh) ==")
laju = 0.1
for epoch in range(301):
    G = gradien_analitik(param, X, y)
    for nama in param:
        param[nama] -= laju * G[nama]
    if epoch % 60 == 0:
        loss = hitung_loss(param, X, y)
        print(f"  epoch {epoch:3d}  loss {loss:.6f}")

loss_akhir = hitung_loss(param, X, y)
print(f"  loss akhir = {loss_akhir:.6f}")

Output yang Dihasilkan:

text
== Gradient check (sebelum pelatihan) ==
  W1: selisih maksimum analitik vs numerik = 5.027e-11
  b1: selisih maksimum analitik vs numerik = 3.286e-11
  W2: selisih maksimum analitik vs numerik = 2.353e-11
  b2: selisih maksimum analitik vs numerik = 1.960e-11
  keseluruhan: 5.027e-11 (ambang sehat <= 1e-6)

== Pelatihan 300 epoch (gradient descent penuh) ==
  epoch   0  loss 0.208730
  epoch  60  loss 0.011757
  epoch 120  loss 0.011285
  epoch 180  loss 0.010949
  epoch 240  loss 0.010620
  epoch 300  loss 0.010292
  loss akhir = 0.010292

Hasilnya melewati ekspektasi dengan margin besar:

  1. Baris 65–73 (pemeriksaan): selisih maksimum tiap kelompok parameter berkisar 1,960e-11 hingga 5,027e-11 — tiga orde magnitudo di bawah ambang sehat 1e-6, bahkan lebih baik dari patokan umum 1e-8. Backpropagation pada baris 30–42 terbukti konsisten dengan definisi turunan.
  2. Baris 45–62 (gradien numerik): fungsi ini sengaja lambat — untuk setiap elemen parameter ia menjalankan forward pass dua kali (baris 60). Untuk 21 parameter ini sepele, tetapi pada jaringan bermiliar parameter pendekatan ini tidak lagi terjangkau; itulah alasan backpropagation ada.
  3. Baris 75–86 (pelatihan): dengan gradien terverifikasi, gradient descent penuh menurunkan loss dari 0,208730 ke 0,010292 dalam 300 epoch — jaringan benar-benar belajar fungsi nonlinear dari data delapan contoh tersebut.

Pelatihan di atas memakai satu pilihan yang belum kita pertanyakan: gradient descent penuh dengan laju tetap 0,1. Kenyataannya, cara mengambil langkah — seberapa banyak data per pembaruan, seberapa besar langkahnya, dan bagaimana laju berubah sepanjang pelatihan — sangat menentukan kecepatan dan kestabilan belajar. Chapter Gradient Descent dan Variasinya membedah pilihan-pilihan tersebut, dari mini-batch SGD hingga intuisi Adam.

Referensi Resmi #