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:
| Komponen | Rumus | Peran |
|---|---|---|
| Gate reset | r_t = σ(W_r x_t + U_r h_{t-1} + b_r) | berapa h_{t-1} yang ikut menghitung kandidat |
| Gate update | z_t = σ(W_z x_t + U_z h_{t-1} + b_z) | pembagi campuran lama dan baru |
| Kandidat | n_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 state | h_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:
"""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:
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 4xMari kita telaah kode di atas baris per baris:
- 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, ...). - Baris 30-34:
np.splitmemotong 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 biasb_n^hikut dikalikan gate reset di dalam tanda kurung. - Baris 36-44: sekuens masukan lima langkah, sama dengan chapter RNN, agar perilaku kedua arsitektur bisa dibandingkan pada masukan identik.
- 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)danz. - 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.
- 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? #
| Aspek | RNN vanilla | GRU | LSTM |
|---|---|---|---|
| Porsi bobot | 1x | 3x | 4x |
| Gate | tidak ada | 2 (reset, update) | 3 (forget, input, output) |
| Jalur memori | jalan bersama h_t | jalan bersama h_t | c_t terpisah dari h_t |
| Dependensi panjang | lemah | kuat | kuat |
| Kebutuhan data | paling sedikit | menengah | paling banyak |
| Kapan dipilih | sekuens pendek, sumber daya minim | default seimbang, urutan sedang | urutan 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.