LSTM menjawab vanishing gradient dengan tiga gate dan jalur cell state terpisah, tetapi jawaban itu dibayar mahal: empat porsi bobot membuat parameter dan komputasinya empat kali RNN vanilla. Gated Recurrent Unit (GRU) dari Cho et al. (2014) mengajukan pertanyaan provokatif: apakah semua komponen LSTM benar-benar diperlukan? GRU membuang cell state terpisah dan menyusut jadi dua gate, namun tetap menyediakan jalur gradien yang nyaris konstan lewat campuran penjumlahan. Hasilnya 25 persen lebih ramping dari LSTM, dan di banyak tugas — terjemahan, pemodelan bahasa, deret waktu — akurasinya setara. Chapter ini membedah dua gate tersebut, mengimplementasikannya dari nol dengan NumPy, lalu memverifikasi angkanya terhadap torch.nn.GRU.

Dua Gate: Reset dan Update #

GRU hanya menyimpan satu jalur state, h_t, yang sekaligus berperan sebagai memori dan keluaran. Dua gate mengatur hidupnya:

KomponenRumusPeran
Gate resetr_t = σ(W_r x_t + U_r h_{t-1} + b_r)berapa h_{t-1} yang ikut menghitung kandidat
Gate updatez_t = σ(W_z x_t + U_z h_{t-1} + b_z)pembagi campuran lama dan baru
Kandidatn_t = tanh(W_n x_t + b_n + r_t ⊙ (U_n h_{t-1} + b_n^h))isi h_t yang diusulkan langkah ini
Hidden stateh_t = (1 - z_t) ⊙ n_t + z_t ⊙ h_{t-1}campuran kandidat dan state lama

Pembawa jawaban terhadap vanishing gradient ada di baris terakhir: h_t = (1 - z_t) ⊙ n_t + z_t ⊙ h_{t-1} adalah penjumlahan terbobot, bukan perkalian matriks berulang. Ketika gate update z_t mendekati 1, hidden state lama diteruskan hampir tanpa diubah — gradien mengalir dari h_0 sampai h_T hanya dikalikan bilangan mendekati 1, persis mekanisme yang dijual LSTM. Gate reset bekerja di jalur berbeda: ia memutus seberapa banyak h_{t-1} yang boleh memengaruhi kandidat n_t. Reset kecil berarti “abaikan masa lalu saat mengusulkan isi baru” — berguna saat konteks berganti topik.

Perbedaan konseptual dari LSTM: tidak ada gate output, dan tidak ada jalur memori (c_t) yang terpisah dari keluaran (h_t). GRU mempertaruhkan bahwa satu jalur yang dikelola dua gate sudah cukup.

GRU Murni dengan NumPy #

Program berikut menyalin bobot dari layer torch.nn.GRU, membaginya menjadi tiga porsi, lalu menghitung forward pass lima langkah secara manual. Sekali lagi float64 dipakai agar perbandingan angka ketat:

python
"""GRU dari nol dengan NumPy, diverifikasi terhadap torch.nn.GRU.

Dua gate (reset r, update z) mengatur seberapa banyak hidden state lama
yang bertahan. Bobot diambil dari layer torch.nn.GRU (seed sama) lalu
dihitung manual; angka dibandingkan angka demi angka. Ditambah tabel
jumlah parameter RNN vanilla vs GRU vs LSTM.
"""

import numpy as np
import torch

torch.manual_seed(0)
np.random.seed(0)

D, H, T = 2, 3, 5


def sigmoid(v):
    return 1.0 / (1.0 + np.exp(-v))


# ---------- Layer torch: sumber bobot ----------
gru_torch = torch.nn.GRU(D, H, batch_first=True, dtype=torch.float64)

W_ih = gru_torch.weight_ih_l0.detach().numpy()  # (3H, D)
W_hh = gru_torch.weight_hh_l0.detach().numpy()  # (3H, H)
b_ih = gru_torch.bias_ih_l0.detach().numpy()    # (3H,)
b_hh = gru_torch.bias_hh_l0.detach().numpy()    # (3H,)

# Urutan baris PyTorch: [reset r, update z, kandidat n]
Wr, Wz, Wn = np.split(W_ih, 3)
Ur, Uz, Un = np.split(W_hh, 3)
br_ih, bz_ih, bn_ih = np.split(b_ih, 3)
br_hh, bz_hh, bn_hh = np.split(b_hh, 3)

x = np.array(
    [
        [0.60, -0.20],
        [0.10, 0.40],
        [-0.50, 0.30],
        [0.80, 0.00],
        [0.20, -0.70],
    ]
)

# ---------- Forward NumPy ----------
print("Forward GRU NumPy, jejak h_t, r_t, z_t:")
h = np.zeros(H)
simpan_h = []
for t in range(T):
    r = sigmoid(x[t] @ Wr.T + h @ Ur.T + br_ih + br_hh)  # gate reset
    z = sigmoid(x[t] @ Wz.T + h @ Uz.T + bz_ih + bz_hh)  # gate update
    n = np.tanh(x[t] @ Wn.T + bn_ih + r * (h @ Un.T + bn_hh))
    h = (1.0 - z) * n + z * h                             # campuran lama-baru
    simpan_h.append(h.copy())
    print(f"t={t}  h={np.round(h, 6)}")
    print(f"     r={np.round(r, 4)}  z={np.round(z, 4)}")

# ---------- Forward PyTorch ----------
out, _ = gru_torch(torch.from_numpy(x).unsqueeze(0))
h_torch = out[0].detach().numpy()

# ---------- Verifikasi ----------
selisih = np.abs(np.array(simpan_h) - h_torch).max()
print()
print("h_5 NumPy  :", np.round(h, 6))
print("h_5 PyTorch:", np.round(h_torch[-1], 6))
print(f"Selisih maksimum: {selisih:.3e}")
print("Cocok dengan torch.nn.GRU:", bool(selisih < 1e-12))

# ---------- Tabel jumlah parameter ----------
print()
print(f"{'model':<7} {'parameter (D=2, H=3)':>20} {'per porsi gate':>14}")
for nama, modul in (
    ("RNN", torch.nn.RNN(D, H, dtype=torch.float64)),
    ("GRU", torch.nn.GRU(D, H, dtype=torch.float64)),
    ("LSTM", torch.nn.LSTM(D, H, dtype=torch.float64)),
):
    n = sum(p.numel() for p in modul.parameters())
    porsi = {"RNN": 1, "GRU": 3, "LSTM": 4}[nama]
    print(f"{nama:<7} {n:>20} {porsi:>13}x")

Output yang Dihasilkan:

text
Forward GRU NumPy, jejak h_t, r_t, z_t:
t=0  h=[-0.116994 -0.284984  0.152898]
     r=[0.6368 0.5337 0.3964]  z=[0.6603 0.4929 0.6088]
t=1  h=[-0.134938 -0.47767   0.193839]
     r=[0.5664 0.6361 0.4331]  z=[0.6441 0.4108 0.592 ]
t=2  h=[-0.120233 -0.546795  0.259322]
     r=[0.4593 0.6508 0.3997]  z=[0.6818 0.3911 0.6161]
t=3  h=[-0.197902 -0.571736  0.285735]
     r=[0.6056 0.611  0.4274]  z=[0.573  0.4316 0.6322]
t=4  h=[-0.240763 -0.543782  0.372228]
     r=[0.4758 0.5339 0.3537]  z=[0.6469 0.4499 0.7068]

h_5 NumPy  : [-0.240763 -0.543782  0.372228]
h_5 PyTorch: [-0.240763 -0.543782  0.372228]
Selisih maksimum: 1.110e-16
Cocok dengan torch.nn.GRU: True

model   parameter (D=2, H=3) per porsi gate
RNN                       21             1x
GRU                       63             3x
LSTM                      84             4x

Mari kita telaah kode di atas baris per baris:

  1. Baris 23-28: layer torch.nn.GRU(2, 3, dtype=torch.float64) menjadi sumber bobot. Matriksnya berbentuk (3H, D) dan (3H, H) — satu porsi lebih ramping dari LSTM yang berbentuk (4H, ...).
  2. Baris 30-34: np.split memotong menjadi tiga blok dengan urutan PyTorch [reset r, update z, kandidat n]. Perhatikan bahwa kedua vektor bias dipecah dan dijumlahkan langsung pada baris 51-52 — berbeda dari LSTM yang cukup menjumlah sekali di awal, karena pada GRU bias b_n^h ikut dikalikan gate reset di dalam tanda kurung.
  3. Baris 36-44: sekuens masukan lima langkah, sama dengan chapter RNN, agar perilaku kedua arsitektur bisa dibandingkan pada masukan identik.
  4. Baris 50-57: inti GRU. Baris 51-52 menghitung kedua gate; baris 53 menghitung kandidat dengan gate reset di dalam tanda kurung r * (h @ Un.T + bn_hh) — penempatan ini mengikuti konvensi PyTorch dan wajib tepat; baris 54 mencampur kandidat dan state lama dengan bobot (1 - z) dan z.
  5. Baris 60-69: verifikasi — selisih maksimum antara jejak NumPy dan PyTorch sebesar 1,11e-16, setara nol pada float64. Rumus kita identik dengan implementasi resmi.
  6. Baris 74-81: tabel parameter dihitung nyata dari modul: RNN 21, GRU 63, LSTM 84 — sesuai porsi 1x, 3x, 4x.

Pada output, nilai z berkisar 0,39 sampai 0,71: setiap langkah, sekitar 40-60 persen hidden state lama diteruskan apa adanya dan sisanya ditimpa kandidat baru. Bandingkan dengan chapter RNN pada masukan yang sama — di sana seluruh h_t ditimpa paksa oleh tanh setiap langkah tanpa kendali.

GRU, LSTM, atau RNN Vanilla? #

AspekRNN vanillaGRULSTM
Porsi bobot1x3x4x
Gatetidak ada2 (reset, update)3 (forget, input, output)
Jalur memorijalan bersama h_tjalan bersama h_tc_t terpisah dari h_t
Dependensi panjanglemahkuatkuat
Kebutuhan datapaling sedikitmenengahpaling banyak
Kapan dipilihsekuens pendek, sumber daya minimdefault seimbang, urutan sedangurutan panjang, data berlimpah

Aturan praktisnya: pada data sedang, keduanya layak diadu lewat validasi karena pemenangnya bervariasi antar tugas; GRU melatih lebih cepat per epoch karena komputasinya lebih ringan, sementara LSTM unggul lebih sering pada urutan yang sangat panjang. RNN vanilla hanya menang ketika sekuensnya pendek dan sumber daya benar-benar sempit. Dokumentasi torch.nn.GRU memuat rumus dan urutan bobot [r, z, n] yang kita ikuti di atas.

Satu dimensi masih bisa ditambah tanpa gate tambahan: membaca sekuens dari dua arah sekaligus. Chapter berikutnya membahas bidirectional RNN — maju untuk masa lalu, mundur untuk masa depan.