Skip to content

LSTMs, GRUs, and Gated Memory

12 min read

Retention is a product of gate values

Direct cell-state sensitivity = 0.97^20 = 0.5438

Hold the gates fixed and follow the direct cell-state path. A gate near one can preserve sensitivity; addition alone does not guarantee memory retention. The full network derivative has other paths.

A vanilla RNN sends gradients through a long chain of state updates. Those gradients can shrink or grow across the chain, making distant dependencies difficult to learn. LSTMs add a memory path whose retention the network can control.

Hochreiter and Schmidhuber introduced the LSTM in 1997 with a separate memory state and gates controlling writes and reads. Gers, Schmidhuber, and Cummins later added a forget gate.

The equations below describe the later LSTM with a forget gate, which lets the cell reset memory on continual streams. LSTMs became widely used in NLP, speech, and time-series modeling before attention-based models became common.


The cell state highway

An LSTM keeps a separate cell state, ctc_t. Gates control how much of its previous value is retained, how much new information is added, and how much is exposed as the hidden state.

The vanilla RNN computes a new hidden state through a nonlinear transformation at each step. The LSTM cell-state update instead adds a gated candidate to a gated copy of the old cell state.

The scalar comparison below tracks how strongly the initial state affects a later state. Both architectures receive the same input stream; the derivative measures sensitivity to that initial state.

scalar initial-state sensitivity through 6 updates
vanilla RNN
overwrite + squash
ht = tanh(Wh ht-1 + Wx xt)
sensitivity to the initial state
0.216
before: ∂ht-1/∂h0 = 1.000
multiply by decay ≈ 0.62, times tanh′(·)
after: ∂ht/∂h0 = 0.216
initial-state sensitivity over time:
0
1
2
3
4
5
6
LSTM cell state
additive + gated
ct = ft · ct-1 + it · c̃t
sensitivity to the initial state
0.970
before: ∂ct-1/∂c0 = 1.000
multiply by ft = 0.97 (no squash)
after: ∂ct/∂c0 = 0.970
initial-state sensitivity over time:
0
1
2
3
4
5
6
step t = 1 of 6. The RNN sensitivity is multiplied by the recurrent weight and the tanh derivative at every step, so by t = 6 it's basically 0.010. The direct LSTM cell-state derivative gets scaled by the forget gate, so it lands at 0.790. Same number of steps. Very different arithmetic.

Same six-step input stream, two different update rules. The RNN panel computes the derivative with respect to its initial state through the tanh updates. The LSTM panel follows the direct cell-state derivative with fixed gates. Both start at sensitivity 1.

Here are the equations for one LSTM timestep. Each gate takes the concatenation of the previous hidden state ht−1h_{t-1} and the current input xtx_t, then applies its own learned weight matrix and bias.

Forget gate (how much of the previous cell state to retain):

ft=σ(Wf⋅[ht−1,xt]+bf)f_t = \sigma(W_f \cdot [h_{t-1}, x_t] + b_f)

Input gate (how much of the new candidate to write):

it=σ(Wi⋅[ht−1,xt]+bi)i_t = \sigma(W_i \cdot [h_{t-1}, x_t] + b_i)

Candidate values (what new information looks like):

c~t=tanh⁡(Wc⋅[ht−1,xt]+bc)\tilde{c}_t = \tanh(W_c \cdot [h_{t-1}, x_t] + b_c)

Output gate (what part of cell state to reveal as hidden state):

ot=σ(Wo⋅[ht−1,xt]+bo)o_t = \sigma(W_o \cdot [h_{t-1}, x_t] + b_o)

Then the cell state update and hidden state output:

ct=ft⊙ct−1+it⊙c~tc_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t

ht=ot⊙tanh⁡(ct)h_t = o_t \odot \tanh(c_t)

The σ\sigma is the sigmoid function (outputs between 0 and 1, so each gate value acts like a dial). The ⊙\odot is element-wise multiplication. The cell-state update adds the retained old state to the new candidate, scaled by the input gate.


Why addition helps gradient flow

In a vanilla RNN, the gradient flows backward through a chain of matrix multiplications, one per timestep. Repeated contraction along the Jacobian chain can shrink the gradient exponentially.

In an LSTM, the gradient can flow backward along the cell state highway via that additive update. Along the direct cell-state path, treating the gates at that timestep as fixed, ∂ct/∂ct−1=ft\partial c_t / \partial c_{t-1} = f_t. The full Jacobian has more terms because ftf_t, iti_t, and c~t\tilde{c}_t all depend on ht−1h_{t-1} and therefore indirectly on ct−1c_{t-1}, so this is one useful path through the derivative, not the full derivative.

As long as the forget gate stays close to 1, the direct cell-state gradient decays slowly instead of being repeatedly transformed by the full recurrent matrix and the tanh derivative. That decay is still a product of forget-gate values, so a forget gate of 0.97 over 40 steps gives roughly 0.9740≈0.300.97^{40} \approx 0.30 along that path. LSTMs mitigate vanishing gradients rather than eliminating them, but the improvement can be substantial on tasks that require retaining information across many steps.

Compare chosen constant derivative paths

10^-1010^-810^-610^-410^-210^010^210^402040

Vertical axis: magnitude (log scale); horizontal axis: steps back. Values outside the plotted range are clipped.

Toy RNN path
0.55^40 = 4.12e-11
Direct cell path: f = 0.7
0.7^40 = 6.37e-7
Direct cell path: f = 0.97
0.97^40 = 2.96e-1

Compare 40 steps of scalar decay using constant forget-gate outputs of 0.7 and 0.97.


Walking through one timestep

Suppose an LSTM has processed "the cat" and now receives "sat". Its previous hidden state ht−1h_{t-1} depends on the preceding words; its current input xtx_t is the embedding for "sat". The visualization uses chosen values to trace the gates.

1/5

Forget gate

retained = fₜ ⊙ cₜ₋₁

Multiply each old cell value by its retention fraction.

Vector1234
cₜ₋₁0.800-0.5000.3001.200
fₜ0.9000.1000.8000.700
retained0.720-0.0500.2400.840

Inputs: h(cat) = [0.5, 0.3, -0.2, 0.6]; 'sat' = [0.1, -0.4, 0.7, 0.2]

Step through the gates to inspect the retained state, new write, and hidden-state output.

In phase 1, the chosen forget-gate values are [0.9,0.1,0.8,0.7][0.9, 0.1, 0.8, 0.7]. The second coordinate retains only 10% of its previous cell-state value; the others retain 70–90%. These coordinates have no assigned linguistic meaning in this example.

In phase 2, the second coordinate receives an input-gate value of 0.8 and a candidate value of 0.9. Their product adds 0.72 to the retained cell value, replacing much of what the forget gate removed.

By phase 5, the output gate filters the updated cell state to produce the new hidden state. Dimension 3 has a high output gate (0.9), so more of that coordinate reaches the hidden state. Dimension 4 has a low output gate (0.3), keeping most of that cell state internal, which can preserve information for later steps.


A long-range dependency, slot by slot

Across a sentence, a gate can retain a feature until a later prediction needs it.

Consider subject-verb agreement with a relative clause in the middle: "the keys that the man on the bench forgot are on the table". The verb "are" has to agree with "keys" (plural), not with "man" or "bench" which are closer but irrelevant. A trained LSTM can learn mechanisms like this. In some language models, researchers have found interpretable units that track number or syntax (Lakretz et al. found long-distance number information largely managed by two "number units" in a studied LSTM, and Linzen et al. showed LSTMs capture non-trivial grammatical structure even when sequential and structural cues conflict), though real models often distribute the same information across several dimensions.

The viz below is hand-built, not trained, but it shows the mechanism. Watch the c[0] slot when "keys" arrives. The forget gate for that slot drops near zero (erase whatever was there), the input gate writes a large positive value, and from that point on the forget gate holds near 0.97 through every other token. By the time "are" arrives, the plural signal is still intact.

toy cell-state dimension carrying subject number across a sentence
the
keys
that
the
man
on
the
bench
forgot
are
c[0] · subj#subject number (sg/pl)
0.00
f
1.00
c[1] · POSrecent part-of-speech signal
0.30
f
0.50
c[2] · relCclause-related feature
0.00
f
1.00
c[3] · otheranother toy feature
0.10
f
1.00
Warming up. The cell state is still mostly zeros.

Select each token to inspect the four states computed from hand-chosen gates and writes.

Karpathy's 2015 post visualized a quote-detection cell in a trained character-level LSTM. Radford et al. (2017) found a sentiment-sensitive neuron in a multiplicative LSTM trained on Amazon reviews.


The GRU: a streamlined alternative

In 2014, Cho et al. proposed the Gated Recurrent Unit as a simpler alternative. The GRU merges the cell state and hidden state into a single vector and uses only two gates instead of three.

Reset gate (how much of the previous hidden state to expose when computing the candidate):

rt=σ(Wr⋅[ht−1,xt]+br)r_t = \sigma(W_r \cdot [h_{t-1}, x_t] + b_r)

Update gate (the interpolation factor between old and new):

zt=σ(Wz⋅[ht−1,xt]+bz)z_t = \sigma(W_z \cdot [h_{t-1}, x_t] + b_z)

Candidate hidden state:

h~t=tanh⁡(Wh⋅[rt⊙ht−1,xt]+bh)\tilde{h}_t = \tanh(W_h \cdot [r_t \odot h_{t-1}, x_t] + b_h)

Final hidden state:

ht=(1−zt)⊙ht−1+zt⊙h~th_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t

Here z means the amount to write. Some sources, including PyTorch, use the opposite convention: z weights the old state. PyTorch also applies the reset gate after its recurrent candidate projection; check the library equations when comparing implementations.

In the final equation, the update gate ztz_t interpolates between keeping the old hidden state and adopting the new candidate. When ztz_t is close to 0, the hidden state passes through unchanged (like the LSTM's forget gate being close to 1). When ztz_t is close to 1, the old state is replaced with new information.

The GRU has no separate cell state or output gate. The update gate ztz_t is the main keep-vs-write mechanism: it decides how much old hidden state to preserve versus how much candidate state to adopt, the same job the LSTM splits between forget and input gates. The reset gate rtr_t does something different: it controls how much of the previous hidden state is visible while computing the candidate, gating what counts as "context" for the new write.

LSTM vs GRU — gate structure and parameter count
256
LSTM
3 gates + candidate
input: [hₜ₋₁, xₜ]
f
forget
i
input
c̃
candidate
o
output
cₜ = f ⊙ cₜ₋₁ + i ⊙ c̃
hₜ = o ⊙ tanh(cₜ)
carries a separate cell state cₜ alongside hₜ
parameters525,312
GRU
2 gates + candidate
input: [hₜ₋₁, xₜ]
r
reset
z
update
h̃
candidate
hₜ = (1 − z) ⊙ hₜ₋₁ + z ⊙ h̃
one interpolation — no separate cell state
parameters393,984
GRU = 75% of LSTM
Counts assume equal input and hidden sizes, one bias per affine transform, and one recurrent layer. Both architectures provide an additive state path. GRU uses one update gate to balance retention and writing, and keeps one state rather than separate cell and hidden states.

Drag the hidden size to see how parameter counts scale. GRU uses three affine transforms per cell versus four for LSTM, counting the candidate transform. Under the displayed bias convention its parameter count is 75% at the same input and hidden sizes.

Fewer parameters reduce parameter memory and arithmetic, though wall-clock speed depends on the implementation and hardware. Chung et al. (2014) found no consistent winner in their GRU–LSTM comparison. In a separate study, Greff et al. (2017) tested eight LSTM variants on three tasks and found no consistent improvement over the standard LSTM.


Implementation from scratch

This PyTorch cell implements the four affine transforms and gated updates explicitly:

import torch
import torch.nn as nn
 
class LSTMCell(nn.Module):
    def __init__(self, input_size: int, hidden_size: int):
        super().__init__()
        self.hidden_size = hidden_size
        # One big linear layer for all four gates at once.
        # Input: [h_{t-1}, x_t] of size (hidden_size + input_size)
        # Output: 4 * hidden_size (forget, input, candidate, output)
        self.gates = nn.Linear(input_size + hidden_size, 4 * hidden_size)
 
    def forward(self, x_t, h_prev, c_prev):
        # x_t:    (batch, input_size)
        # h_prev: (batch, hidden_size)
        # c_prev: (batch, hidden_size)
 
        combined = torch.cat([h_prev, x_t], dim=1)
        gate_values = self.gates(combined)  # (batch, 4 * hidden_size)
 
        # Split into four chunks along the last dimension
        f, i, c_tilde, o = gate_values.chunk(4, dim=1)
 
        f = torch.sigmoid(f)                # forget gate
        i = torch.sigmoid(i)                # input gate
        c_tilde = torch.tanh(c_tilde)       # candidate
        o = torch.sigmoid(o)                # output gate
 
        # Cell state update: the additive highway
        c_t = f * c_prev + i * c_tilde
 
        # Hidden state output
        h_t = o * torch.tanh(c_t)
 
        return h_t, c_t
 
class LSTMLanguageModel(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_size, num_layers=1):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.cells = nn.ModuleList([
            LSTMCell(embed_dim if layer == 0 else hidden_size, hidden_size)
            for layer in range(num_layers)
        ])
        self.output_proj = nn.Linear(hidden_size, vocab_size)
        self.hidden_size = hidden_size
        self.num_layers = num_layers
 
    def forward(self, token_ids):
        # token_ids: (batch, seq_len)
        batch_size, seq_len = token_ids.shape
        embeds = self.embedding(token_ids)  # (batch, seq_len, embed_dim)
 
        # Initialize hidden and cell states to zeros
        h = [torch.zeros(batch_size, self.hidden_size, device=token_ids.device)
             for _ in range(self.num_layers)]
        c = [torch.zeros(batch_size, self.hidden_size, device=token_ids.device)
             for _ in range(self.num_layers)]
 
        outputs = []
        for t in range(seq_len):
            x = embeds[:, t, :]
            for layer_idx, cell in enumerate(self.cells):
                h[layer_idx], c[layer_idx] = cell(x, h[layer_idx], c[layer_idx])
                x = h[layer_idx]  # feed this layer's output to the next
            outputs.append(x)
 
        out = torch.stack(outputs, dim=1)       # (batch, seq_len, hidden_size)
        logits = self.output_proj(out)           # (batch, seq_len, vocab_size)
        return logits

The implementation uses a single nn.Linear(..., 4 * hidden_size). All four gate computations run as one matrix multiply, and then we split the output with .chunk(4). Efficient LSTM implementations usually fuse the four gate affine transforms into one larger matrix multiply, because one big matmul is much faster on a GPU than four small ones.


Misconceptions

"LSTMs completely solve vanishing gradients." They mitigate the problem significantly, but they don't eliminate it. The gradient still flows through the forget gate at each timestep, and if the forget gate learns values consistently below 1.0, the gradient will still decay over very long sequences. Karpathy's character-level examples also showed difficulty with dependencies spanning thousands of characters.

"GRUs are always worse than LSTMs." Chung et al. found that performance depended on the task. A GRU has fewer parameters at the same hidden size, but that alone does not determine model quality or training speed.

"The forget gate decides what to forget." Its value is a retention fraction: 1 keeps the old cell value and 0 erases it. The name describes the operation it controls, rather than the direction of the gate value.

What's next

Gates help preserve information, but an encoder still has to summarize its input for a decoder. The next post, Seq2seq and Bahdanau attention, lets the decoder revisit individual source positions instead of relying on one final state.


Additional reading (and watching)