Vanishing gradient membuat RNN vanilla kehilangan akses ke konteks jauh: gradien menyusut eksponensial karena setiap langkah waktu memaksa gradien melewati perkalian matriks dan turunan tanh. Long Short-Term Memory (LSTM) dari Hochreiter dan Schmidhuber (1997) menyerang masalah ini lewat desain arsitektur: sel baru bernama cell state ditambahkan, dan aliran masuk-keluarnya dikendalikan tiga gate yang bisa membiarkan informasi lewat nyaris tanpa diubah. Hasilnya adalah jalur gradien yang panjang dan hampir konstan — syarat yang gagal dipenuhi RNN vanilla. Chapter ini membedah rumus keempat komponen itu, mengimplementasikannya dari nol dengan NumPy, lalu memverifikasi angkanya terhadap torch.nn.LSTM.
Cell State dan Tiga Gate #
LSTM memisahkan dua jalur: cell state c_t sebagai memori jangka panjang, dan hidden state h_t sebagai keluaran yang diperlihatkan ke lapisan berikutnya. Tiga gate — masing-masing bernilai 0 sampai 1 hasil sigmoid — mengatur lalu lintas pada jalur tersebut:
| Komponen | Rumus | Peran |
|---|---|---|
| Gate forget | f_t = σ(W_f x_t + U_f h_{t-1} + b_f) | berapa bagian c_{t-1} yang dipertahankan |
| Gate input | i_t = σ(W_i x_t + U_i h_{t-1} + b_i) | berapa kandidat baru yang masuk ke c_t |
| Kandidat | g_t = tanh(W_g x_t + U_g h_{t-1} + b_g) | isi memori yang diusulkan langkah ini |
| Gate output | o_t = σ(W_o x_t + U_o h_{t-1} + b_o) | berapa c_t yang diperlihatkan sebagai h_t |
| Cell state | c_t = f_t ⊙ c_{t-1} + i_t ⊙ g_t | memori jangka panjang |
| Hidden state | h_t = o_t ⊙ tanh(c_t) | keluaran langkah ini |
Kunci jawaban LSTM terhadap vanishing gradient ada pada baris cell state: c_t = f_t ⊙ c_{t-1} + ... adalah penjumlahan, bukan perkalian matriks berulang. Ketika gate forget bernilai dekat 1 dan gate input dekat 0, informasi lama mengalir dari c_0 sampai c_T hanya dikalikan bilangan skalar mendekati 1 — gradien pada jalur itu nyaris tidak menyusut walau T besar. Gate-gate inilah yang dipelajari selama training: model belajar kapan mengingat, kapan menimpa, dan kapan membuka memori.
Diagram di atas menunjukkan alur intinya: cell state lama dan kandidat baru dijumlahkan setelah masing-masing dikalikan gatenya, lalu sebagian cell state yang baru dikeluarkan sebagai hidden state. Keempat gate semuanya dihitung dari pasangan x_t dan h_{t-1} yang sama.
LSTM Cell Murni dengan NumPy #
Program berikut mengambil bobot dari layer torch.nn.LSTM, membaginya menjadi empat porsi gate, lalu menghitung forward pass lima langkah secara manual pada sekuens yang menaruh token penting di langkah kedua — ujian kecil bagi kemampuan menyimpan informasi:
"""LSTM cell dari nol dengan NumPy, diverifikasi terhadap torch.nn.LSTM.
Tiga gate (forget, input, output) dan cell state c_t dihitung manual per
langkah pada sekuens 5 langkah. Bobot diambil dari layer torch.nn.LSTM
(seed sama) sehingga jejak c_t dan h_t dapat dibandingkan angka demi angka.
"""
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 ----------
lstm_torch = torch.nn.LSTM(D, H, batch_first=True, dtype=torch.float64)
W_ih = lstm_torch.weight_ih_l0.detach().numpy() # (4H, D)
W_hh = lstm_torch.weight_hh_l0.detach().numpy() # (4H, H)
b = (
lstm_torch.bias_ih_l0.detach().numpy()
+ lstm_torch.bias_hh_l0.detach().numpy()
) # (4H,) -- dua bias dijumlahkan
# Urutan baris PyTorch: [input, forget, g (kandidat), output]
Wi, Wf, Wg, Wo = np.split(W_ih, 4)
Ui, Uf, Ug, Uo = np.split(W_hh, 4)
bi, bf, bg, bo = np.split(b, 4)
# ---------- Sekuens masukan: token penting di langkah 2 ----------
x = np.array(
[
[0.00, 0.00],
[1.50, -1.00], # token "penanda" yang perlu diingat
[0.00, 0.00],
[0.00, 0.00],
[0.00, 0.00],
]
)
# ---------- Forward NumPy ----------
print("Forward LSTM NumPy, jejak c_t dan h_t:")
h = np.zeros(H)
c = np.zeros(H)
simpan_h = []
for t in range(T):
f = sigmoid(x[t] @ Wf.T + h @ Uf.T + bf) # gate forget
i = sigmoid(x[t] @ Wi.T + h @ Ui.T + bi) # gate input
g = np.tanh(x[t] @ Wg.T + h @ Ug.T + bg) # kandidat nilai baru
o = sigmoid(x[t] @ Wo.T + h @ Uo.T + bo) # gate output
c = f * c + i * g # cell state baru
h = o * np.tanh(c) # hidden baru
simpan_h.append(h.copy())
print(f"t={t} c={np.round(c, 6)} h={np.round(h, 6)}")
if t == 1:
print(f" gate: f={np.round(f, 4)} i={np.round(i, 4)} "
f"o={np.round(o, 4)}")
# ---------- Forward PyTorch ----------
out, (h_n, c_n) = lstm_torch(torch.from_numpy(x).unsqueeze(0))
h_torch = out[0].detach().numpy()
c_torch = c_n[0, 0].detach().numpy()
# ---------- Verifikasi ----------
selisih_h = np.abs(np.array(simpan_h) - h_torch).max()
selisih_c = np.abs(c - c_torch).max()
print()
print("c_5 NumPy :", np.round(c, 6))
print("c_5 PyTorch:", np.round(c_torch, 6))
print("h_5 NumPy :", np.round(h, 6))
print("h_5 PyTorch:", np.round(h_torch[-1], 6))
print(f"Selisih maksimum h: {selisih_h:.3e} | c: {selisih_c:.3e}")
print("Cocok dengan torch.nn.LSTM:",
bool(selisih_h < 1e-12 and selisih_c < 1e-12))
# ---------- Selisih jumlah parameter vs RNN vanilla ----------
rnn = torch.nn.RNN(D, H, dtype=torch.float64)
n_rnn = sum(p.numel() for p in rnn.parameters())
n_lstm = sum(p.numel() for p in lstm_torch.parameters())
print()
print(f"Parameter RNN vanilla (D={D}, H={H}) : {n_rnn}")
print(f"Parameter LSTM (D={D}, H={H}) : {n_lstm}")
print(f"Rasio LSTM / RNN vanilla : {n_lstm / n_rnn:.1f}x")Output yang Dihasilkan:
Forward LSTM NumPy, jejak c_t dan h_t:
t=0 c=[-0.062721 0.008911 -0.178631] h=[-0.035279 0.004874 -0.087386]
t=1 c=[-0.339295 0.037836 -0.216777] h=[-0.177287 0.013957 -0.068622]
gate: f=[0.4705 0.5273 0.3887] i=[0.634 0.3104 0.2652] o=[0.5424 0.3691 0.3215]
t=2 c=[-0.289781 0.046273 -0.261321] h=[-0.159224 0.024547 -0.122247]
t=3 c=[-0.257687 0.064078 -0.281016] h=[-0.139965 0.033971 -0.130585]
t=4 c=[-0.236548 0.075187 -0.287242] h=[-0.128144 0.039916 -0.133268]
c_5 NumPy : [-0.236548 0.075187 -0.287242]
c_5 PyTorch: [-0.236548 0.075187 -0.287242]
h_5 NumPy : [-0.128144 0.039916 -0.133268]
h_5 PyTorch: [-0.128144 0.039916 -0.133268]
Selisih maksimum h: 2.776e-17 | c: 5.551e-17
Cocok dengan torch.nn.LSTM: True
Parameter RNN vanilla (D=2, H=3) : 21
Parameter LSTM (D=2, H=3) : 84
Rasio LSTM / RNN vanilla : 4.0xMari kita telaah kode di atas baris per baris:
- Baris 22-29: layer
torch.nn.LSTM(2, 3, dtype=torch.float64)menjadi sumber bobot. Matriksweight_ih_l0berbentuk(4H, D)danweight_hh_l0berbentuk(4H, H)— keempat gate digabung dalam satu matriks besar; dua bias PyTorch dijumlahkan menjadibseperti pada chapter RNN. - Baris 31-34:
np.split(W_ih, 4)memotong matriks menjadi empat blok. Urutan blok PyTorch adalah[input, forget, kandidat g, output]— urutan ini wajib benar; tertukarnya blok forget dan input membuat hasil melenceng tanpa galat yang mencurigakan. - Baris 37-45: sekuens masukan berisi nol kecuali langkah t=1 yang bernilai
[1,5; -1,0]— token penanda yang harus “ditahan” cell state setelah langkah itu lewat. - Baris 49-63: perulangan forward. Baris 53-56 menghitung keempat gate; baris 57 memperbarui cell state lewat penjumlahan
f * c + i * g; baris 58 menghasilkan hidden state. Gate pada t=1 ikut dicetak agar perilakunya terbaca. - Baris 66-68: tensor yang sama dijalankan lewat layer PyTorch;
c_nadalah cell state akhir danoutberisi seluruhh_t. - Baris 71-80: verifikasi — selisih maksimum h sebesar 2,78e-17 dan c sebesar 5,55e-17, keduanya setara nol pada presisi float64. Rumus NumPy kita identik dengan implementasi PyTorch.
- Baris 83-89: hitungan parameter: RNN vanilla 21, LSTM 84 — tepat 4 kali lipat karena keempat porsi gate memakai struktur bobot yang sama.
Perhatikan jejak pada output setelah token penanda lewat: c berubah paling besar pada t=1 (dari sekitar nol menjadi [-0,339; 0,038; -0,217]), lalu hanya bergeser sedikit pada t=2 sampai t=4 karena masukan kembali nol dan gate yang menimpanya kecil. Inilah perilaku “menahan memori” yang sulit dicapai RNN vanilla pada sekuens panjang.
Dokumentasi resmi torch.nn.LSTM
memuat rumus lengkap serta konvensi urutan gate [i, f, g, o] yang kita ikuti; visualisasi gate yang klasik dan mudah diingat tersedia di colah.github.io
.
LSTM bukan satu-satunya rancangan gate. Enam tahun kemudian muncul versi yang lebih ramping dengan dua gate saja dan performa yang setara di banyak tugas — Gated Recurrent Unit, pokok bahasan chapter berikutnya.