AI Knowledge Tree

LSTM and GRU

Build an LSTM cell gate-by-gate; show how the constant error carousel defeats vanishing gradients; then simplify to the GRU.

⏱ 32 minPrerequisites:RNN language models and BPTT

The vanilla RNN multiplies the hidden state by WhW_h at every step, which either shrinks gradients exponentially (vanishing) or explodes them. The Long Short-Term Memory (LSTM) introduces a cell state ctc_t that flows through the network with only additive updates -- gradients can travel backward through time without repeated matrix multiplication.

The LSTM cell

The LSTM (Hochreiter and Schmidhuber, 1997)[1] maintains two vectors per time step:

  • ct∈Rmc_t \in \mathbb{R}^m: the cell state (long-term memory).
  • ht∈Rmh_t \in \mathbb{R}^m: the hidden state (output passed to the next layer).

Four gates control the information flow. All gate activations use the same weight matrices applied to the concatenated input [ht−1;xt]∈Rm+d[h_{t-1}; x_t] \in \mathbb{R}^{m+d}:

ft=σ(Wf[ht−1;xt]+bf)(forget gate)it=σ(Wi[ht−1;xt]+bi)(input gate)gt=tanh⁡(Wg[ht−1;xt]+bg)(cell candidate)ot=σ(Wo[ht−1;xt]+bo)(output gate)\begin{aligned} f_t &= \sigma(W_f [h_{t-1}; x_t] + b_f) & \text{(forget gate)} \\ i_t &= \sigma(W_i [h_{t-1}; x_t] + b_i) & \text{(input gate)} \\ g_t &= \tanh(W_g [h_{t-1}; x_t] + b_g) & \text{(cell candidate)} \\ o_t &= \sigma(W_o [h_{t-1}; x_t] + b_o) & \text{(output gate)} \end{aligned}

Cell and hidden state updates:

ct=ft⊙ct−1+it⊙gt,ht=ot⊙tanh⁡(ct).c_t = f_t \odot c_{t-1} + i_t \odot g_t, \qquad h_t = o_t \odot \tanh(c_t).

⊙\odot denotes elementwise multiplication.

The constant error carousel

The cell update ct=ft⊙ct−1+it⊙gtc_t = f_t \odot c_{t-1} + i_t \odot g_t is additive in ct−1c_{t-1}. The gradient of ctc_t with respect to ct−1c_{t-1} is:

∂ct∂ct−1=diag(ft).\frac{\partial c_t}{\partial c_{t-1}} = \text{diag}(f_t).

This is a diagonal matrix -- no matrix multiplication through WhW_h. If ft≈1f_t \approx 1 (the gate is open), gradients flow backward unchanged. Hochreiter called this the constant error carousel: errors are "carried" through time at constant scale.

In practice, the forget gate is initialized with a positive bias (bf≈1b_f \approx 1) so that it starts close to 1, ensuring gradients flow freely at the beginning of training.

LSTM implementation

python · numpy
import numpy as np

class LSTMCell:
    def __init__(self, d, m, seed=0):
        rng = np.random.default_rng(seed)
        scale = 0.1
        concat_dim = d + m
        # All four gates in one matrix for efficiency
        self.W = rng.normal(0, scale, (4 * m, concat_dim))
        self.b = np.zeros(4 * m)
        self.b[0:m] = 1.0     # forget gate bias init = 1
        self.m = m

    def forward(self, x, h_prev, c_prev):
        """Single LSTM step. Returns (h, c)."""
        m = self.m
        combined = np.concatenate([h_prev, x])
        gates = self.W @ combined + self.b    # (4m,)

        f = self._sigmoid(gates[0    :   m])   # forget
        i = self._sigmoid(gates[m    : 2*m])   # input
        g = np.tanh      (gates[2*m  : 3*m])   # candidate
        o = self._sigmoid(gates[3*m  : 4*m])   # output

        c = f * c_prev + i * g
        h = o * np.tanh(c)
        return h, c, (x, h_prev, c_prev, f, i, g, o, c)

    @staticmethod
    def _sigmoid(x):
        return 1.0 / (1.0 + np.exp(-np.clip(x, -10, 10)))

def run_lstm(cell, xs):
    """xs: (T, d) array. Returns all hidden states (T, m)."""
    m = cell.m
    h, c = np.zeros(m), np.zeros(m)
    hs = []
    for x in xs:
        h, c, _ = cell.forward(x, h, c)
        hs.append(h)
    return np.array(hs)

d, m = 4, 8
cell = LSTMCell(d, m)
xs   = np.random.randn(10, d)  # 10-step sequence
hs   = run_lstm(cell, xs)
print("Hidden states shape:", hs.shape)
print("Last hidden state norm:", np.linalg.norm(hs[-1]))

Gated Recurrent Unit (GRU)

The GRU (Cho et al., 2014)[2] is a simplified LSTM that merges cell and hidden states and uses two gates instead of four:

rt=σ(Wr[ht−1;xt])(reset gate)zt=σ(Wz[ht−1;xt])(update gate)h~t=tanh⁡(Wh[rt⊙ht−1;xt])(candidate)ht=(1−zt)⊙ht−1+zt⊙h~t.(update)\begin{aligned} r_t &= \sigma(W_r [h_{t-1}; x_t]) & \text{(reset gate)} \\ z_t &= \sigma(W_z [h_{t-1}; x_t]) & \text{(update gate)} \\ \tilde{h}_t &= \tanh(W_h [r_t \odot h_{t-1}; x_t]) & \text{(candidate)} \\ h_t &= (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t. & \text{(update)} \end{aligned}

The update gate ztz_t interpolates between the previous hidden state and the new candidate:

  • zt≈0z_t \approx 0: carry the old state forward (remember).
  • zt≈1z_t \approx 1: replace with the new candidate (update).

The reset gate rtr_t modulates how much the previous state influences the candidate.

python · numpy
import numpy as np

class GRUCell:
    def __init__(self, d, m, seed=1):
        rng = np.random.default_rng(seed)
        s = 0.1
        cdim = d + m
        self.Wr = rng.normal(0, s, (m, cdim))
        self.Wz = rng.normal(0, s, (m, cdim))
        self.Wh = rng.normal(0, s, (m, cdim))
        self.m  = m

    def forward(self, x, h_prev):
        combined = np.concatenate([h_prev, x])
        r = self._sigmoid(self.Wr @ combined)
        z = self._sigmoid(self.Wz @ combined)
        h_tilde = np.tanh(self.Wh @ np.concatenate([r * h_prev, x]))
        h = (1 - z) * h_prev + z * h_tilde
        return h

    @staticmethod
    def _sigmoid(x):
        return 1.0 / (1.0 + np.exp(-np.clip(x, -10, 10)))

gru = GRUCell(d=4, m=8)
xs = np.random.default_rng(0).normal(size=(10, 4))  # 10-step sequence
h = np.zeros(8)
for x in xs:
    h = gru.forward(x, h)
print("GRU final hidden norm:", np.linalg.norm(h))

LSTM vs GRU vs vanilla RNN

Vanilla RNNGRULSTM
Parametersd⋅m+m2d \cdot m + m^23(d+m)⋅m3(d+m) \cdot m4(d+m)⋅m4(d+m) \cdot m
Memoryhth_t onlyhth_t onlyhth_t + ctc_t
Long-range gradientsVanishesGoodExcellent
SpeedFastestFastSlower

Exercises

Exercise 1. Take a one-dimensional LSTM (m=1m = 1) at a single step with gate values ft=0.9f_t = 0.9, it=0.2i_t = 0.2, gt=0.5g_t = 0.5, ot=0.8o_t = 0.8 and previous cell state ct−1=1.0c_{t-1} = 1.0. Compute ctc_t and hth_t by hand using ct=ftct−1+itgtc_t = f_t c_{t-1} + i_t g_t and ht=ottanh⁡(ct)h_t = o_t \tanh(c_t).

Solution

Step 1. Update the cell state: ct=0.9⋅1.0+0.2⋅0.5=0.9+0.1=1.0c_t = 0.9 \cdot 1.0 + 0.2 \cdot 0.5 = 0.9 + 0.1 = 1.0. The forget gate keeps 0.90.9 of the old cell, the input gate writes 0.10.1 of the candidate.

Step 2. Squash the cell state: tanh⁡(1.0)=0.7616\tanh(1.0) = 0.7616.

Step 3. Apply the output gate: ht=ottanh⁡(ct)=0.8⋅0.7616=0.6093h_t = o_t \tanh(c_t) = 0.8 \cdot 0.7616 = 0.6093. The hidden state is a gated, bounded readout of the cell, while the cell itself accumulated additively.

Exercise 2. The gradient of the cell state across one step is ∂ct/∂ct−1=diag(ft)\partial c_t / \partial c_{t-1} = \mathrm{diag}(f_t). Show that the gradient carried across TT steps is ∏k=1Tfk\prod_{k=1}^{T} f_k (elementwise), and contrast its decay with the vanilla RNN's ∏kWh⊤diag(1−hk2)\prod_k W_h^\top \mathrm{diag}(1 - h_k^2).

Solution

Step 1. Apply the chain rule along the cell-state path. Because each step contributes the diagonal factor diag(fk)\mathrm{diag}(f_k),

∂cT∂c0=∏k=1Tdiag(fk)=diag ⁣(∏k=1Tfk),\frac{\partial c_T}{\partial c_0} = \prod_{k=1}^{T} \mathrm{diag}(f_k) = \mathrm{diag}\!\left(\prod_{k=1}^{T} f_k\right),

a product of diagonal matrices, which is elementwise multiplication of the forget gates.

Step 2. No WhW_h appears in this product. When fk≈1f_k \approx 1 for all kk, the product stays near 11 and the gradient is preserved across the whole sequence, the constant error carousel.

Step 3. The vanilla RNN instead multiplies by full matrices Wh⊤diag(1−hk2)W_h^\top \mathrm{diag}(1 - h_k^2) at every step, whose norm shrinks like ρ(Wh)T\rho(W_h)^T (with the tanh⁡\tanh derivative 1−hk2<11 - h_k^2 < 1 shrinking it further). The LSTM replaces that contractive matrix product with a near-identity scalar product, which is why long-range gradients survive.

Exercise 3 (code). Implement the cell-state gradient path ∂cT/∂c0=∏kfk\partial c_T / \partial c_0 = \prod_k f_k for a constant forget gate, and confirm that f=0.95f = 0.95 preserves far more gradient over 3030 steps than f=0.5f = 0.5.

Solution
python · numpy
import numpy as np

m, T = 5, 30

def carousel_grad(f_value):
    forgets = np.full((T, m), f_value)   # forget gate at each step
    return np.prod(forgets, axis=0)      # dc_T/dc_0 = elementwise product

g_open = carousel_grad(0.95)             # gate near 1: memory persists
g_half = carousel_grad(0.50)             # gate at 0.5: memory decays fast

assert np.allclose(g_open, 0.95 ** T)    # exact product
assert np.all(g_half < g_open)           # smaller forget gate vanishes faster
print("f=0.95 ->", round(g_open[0], 4),
      "   f=0.50 ->", round(g_half[0], 12))

With f=0.95f = 0.95 the surviving gradient is 0.9530≈0.2150.95^{30} \approx 0.215, while f=0.5f = 0.5 collapses to 0.530≈9.3×10−100.5^{30} \approx 9.3 \times 10^{-10}: an open forget gate carries signal across the sequence, a half-open one destroys it.

GRU matches LSTM on most benchmarks with fewer parameters; LSTM tends to win on very long sequences where the separate cell state helps. Both are superseded by Transformers for most tasks, but appear as components in sequence models and state-space models.


References

  1. [1]Hochreiter, S., & Schmidhuber, J. (1997). "Long short-term memory". Neural Computation, 9(8), 1735-1780. doi:10.1162/neco.1997.9.8.1735. The cell state and constant error carousel.↩
  2. [2]Cho, K., et al. (2014). "Learning phrase representations using RNN encoder-decoder for statistical machine translation". EMNLP. arXiv:1406.1078. Introduces the GRU.↩