Deep Learning · Module 15 of 26 · 8 concepts · about 18 min

RNNs, LSTMs & GRUs

Recurrent sequence models and the gating that made them trainable.

At work: Models for sequences: sensor streams, transaction histories, older speech and text systems.

Hidden state and gated recurrence

What it is

An RNN processes a sequence one step at a time and passes a hidden state forward, so a token's representation can depend on preceding tokens. A plain RNN repeatedly applies the same transition, which makes gradients through many time steps prone to vanish or explode. LSTMs and GRUs add gates that decide what to retain, write, and forget; their nearly additive memory paths make long-range information easier, though not effortless, to preserve. For batched variable-length sequences, padding, masks, and hidden-state resets are part of the correctness contract.

Why it matters

They explain sequence modelling before attention and remain useful for small streaming or time-series tasks with strict latency budgets. They maintain only a compact state rather than an all-pairs attention matrix, but their sequential computation prevents the broad parallelism that made Transformers dominant for many offline language tasks.

In the real world: Reading a customer's transaction history one step at a time to spot unusual behaviour.

import torch
import torch.nn as nn

rnn = nn.LSTM(input_size=32, hidden_size=64, num_layers=2, batch_first=True)
x = torch.randn(8, 50, 32)               # (batch, timesteps, features)
out, (h_n, c_n) = rnn(x)
out.shape, h_n.shape                     # (8, 50, 64), (2, 8, 64)

Treating an RNN as order-aware after shuffling time steps. Its state only has meaning when the sequence order and sequence boundaries are correct.

The recurrent idea: one transition, reused every step

What it is

A vanilla RNN holds a single hidden vector h and updates it once per timestep: h_t = tanh(W_x x_t + W_h h_{t−1} + b). The same W_x, W_h and b are applied at step 1 and at step 500 — there is no per-position weight anywhere in the model. Two consequences follow. The parameter count is independent of sequence length, so the same trained model runs on a 10-token or a 10,000-token input. And h_t is the model's entire memory of everything before step t, compressed into a fixed-size vector, which is both the source of its efficiency and the source of its bottleneck.

Why it matters

Parameter sharing across time is the sequence analogue of a CNN's sharing across space, and it makes the same two promises: far fewer parameters, and a pattern learned at one position that is recognisable at every other. Writing the loop by hand once is the fastest way to see that nn.RNN is not a new kind of object — it is this three-line update, fused and run in a fast kernel.

In the real world: One small model applied to each day of sensor data, however long the history is.

Visual example

Deep learning

One transition matrix, applied once per timestep, carrying a single fixed-size state forward.

Unrolling the same weights

  1. t = 1h₁ = tanh(W_x·x₁ + W_h·h₀ + b)
  2. t = 2h₂ = tanh(W_x·x₂ + W_h·h₁ + b) — same W
  3. t = 3h₃ = tanh(W_x·x₃ + W_h·h₂ + b) — same W
  4. t = 4h₄ carries a summary of x₁..x₄ in one vector

The parameter count does not depend on the sequence length; the memory capacity does not either, which is the problem.

import torch
import torch.nn as nn

class VanillaRNN(nn.Module):
    def __init__(self, in_dim, hidden):
        super().__init__()
        self.cell = nn.Linear(in_dim + hidden, hidden)   # ONE weight matrix
        self.hidden = hidden

    def forward(self, x):                  # x: (batch, time, in_dim)
        h = x.new_zeros(x.size(0), self.hidden)
        outputs = []
        for t in range(x.size(1)):         # the same self.cell every step
            h = torch.tanh(self.cell(torch.cat([x[:, t], h], dim=-1)))
            outputs.append(h)
        return torch.stack(outputs, dim=1)

Carrying the hidden state across batches without detaching it. h then keeps a computation graph reaching back into the previous batch, so backward() either walks an ever-growing graph or raises 'trying to backward through the graph a second time'. Use h.detach() between batches.

Why vanilla RNNs fail on long sequences

What it is

Backpropagation through time unrolls the network into one layer per timestep and applies module 7's chain rule along it. A 200-step sequence is therefore a 200-layer-deep computation whose every layer shares the same W_h — so the gradient reaching step 1 is multiplied by roughly the same factor 200 times. Module 10's arithmetic applies exactly: if that factor is slightly below 1, the gradient vanishes and the model cannot learn any dependency spanning more than a few dozen steps; slightly above 1, and it explodes, which is why gradient clipping is close to mandatory for recurrent models rather than merely prudent.

Why it matters

It is the concrete reason a plain RNN cannot learn 'the verb agrees with a subject 80 words earlier'. The model is not short of capacity — it is receiving no usable gradient for that dependency. And because the repeated factor is one shared matrix rather than 200 different ones, the failure is much sharper than in a feedforward stack of the same depth: there is no averaging across layers to soften it.

In the real world: They forget what happened long ago, missing slow patterns like a machine gradually wearing out.

Visual example

Gradient by distance

Gradient magnitude reaching timestep t, from a loss at step 200. The vanilla RNN's influence is gone within ~30 steps.

vanilla RNN (tanh)LSTM (gated, additive cell path)
vanilla RNN learns nothing here05010015020000.20.40.60.81timesteps back from the lossrelative gradient magnitude

Why this matters

It shows the limitation as a measurable quantity rather than a slogan: 'RNNs struggle with long sequences' means the gradient at distance 60 is four thousand times smaller than at distance 0.

The LSTM curve decays too — gating makes long dependencies learnable, not free. Attention (module 16) makes the distance irrelevant instead.

import torch
import torch.nn as nn

rnn = nn.RNN(1, 32, batch_first=True)
x = torch.randn(1, 200, 1, requires_grad=True)
out, _ = rnn(x)
out[:, -1].sum().backward()               # loss at the LAST timestep only

grads = x.grad.abs().squeeze()
print(float(grads[-1]), float(grads[100]), float(grads[0]))
# 0.41   0.0006   0.0000000004  -> step 0 has no influence on the loss

Blaming the dataset when an RNN ignores long-range structure. Test it directly: put the answer at distance 5, then 50, then 500, and watch the accuracy fall off a cliff at a distance that has nothing to do with the data and everything to do with the gradient.

The LSTM and its gates

What it is

An LSTM carries two vectors: a hidden state h and a cell state c. Three gates, each a small learned layer with a sigmoid output between 0 and 1, control the cell state elementwise. The forget gate multiplies the old cell state, deciding what to discard. The input gate scales the candidate update, deciding what to write. The output gate decides how much of the cell state is exposed as h_t for the rest of the network to read. The crucial structural detail is the cell update c_t = f_t ⊙ c_{t−1} + i_t ⊙ g_t: information moves along it by *addition*, so when the forget gate is near 1 the cell state passes through nearly unchanged — and so does its gradient, exactly as with module 10's residual connections.

Why it matters

The gates are what make long-range dependencies learnable, and the mechanism is the same 'additions do not compound' insight that residual connections rely on. It is also why LSTMs are described by what each gate is *for* rather than by their equations: the equations are four parallel linear layers and some elementwise multiplications, and the design content is entirely in which quantity each gate multiplies.

In the real world: Long used for speech recognition, text prediction and time-series forecasting.

Visual example

Three gates

Each gate is a sigmoid between 0 and 1 that multiplies one specific quantity. That is the entire design.

what each gate controls
gatemultipliesin plain language
forget f_tc_{t−1}how much of the old memory survives
input i_tcandidate g_thow much of the new observation is written
output o_ttanh(c_t)how much of the memory the rest of the net sees

Why f_t ≈ 1 preserves the gradient

∂c_t / ∂c_{t−1} = f_t, elementwise

over 100 steps with f ≈ 1: the product is ≈ 1

a vanilla RNN's equivalent factor is W_h, with no way to hold it near 1

Common misconception

Wrong idea: gates let the LSTM 'choose what to remember' as a deliberate decision.

Correct idea: they are learned continuous multipliers. The useful property is structural: a near-1 forget gate makes the cell path additive, and additive paths do not vanish.

import torch
import torch.nn as nn

lstm = nn.LSTM(input_size=16, hidden_size=32, batch_first=True)
out, (h_n, c_n) = lstm(torch.randn(4, 20, 16))

# 4 gates x (input->hidden) weights, hence 4 * hidden in the first dimension
lstm.weight_ih_l0.shape   # (128, 16)  = 4 * 32 rows: i, f, g, o stacked
lstm.weight_hh_l0.shape   # (128, 32)
sum(p.numel() for p in lstm.parameters())   # 6,400 — 4x a vanilla RNN's

Confusing the two returned states. h_n is the last hidden state and the one a classifier head should read; c_n is internal memory and is not a sequence representation. Passing c_n to a head trains, converges, and quietly underperforms.

The GRU

What it is

A GRU merges the LSTM's forget and input gates into one update gate z_t, drops the separate cell state, and adds a reset gate controlling how much of the previous hidden state enters the candidate. The result is two gates rather than three, one state vector rather than two, and roughly three quarters of an LSTM's parameters for the same hidden size. The additive path survives — h_t = (1 − z_t) ⊙ h_{t−1} + z_t ⊙ h̃_t is an interpolation, so z_t ≈ 0 passes the old state through untouched — which is the property that matters.

Why it matters

It is the practical default when a recurrent model is the right choice: comparable accuracy on most tasks, faster per step, fewer parameters, and less to tune. The honest summary is that the LSTM-versus-GRU question rarely decides a project, which is itself worth knowing — it is a cheap A/B rather than an architecture debate, and the time is better spent on the data or on whether the model should be recurrent at all.

In the real world: A lighter alternative to the LSTM, often used on phones and small devices.

Visual example

LSTM vs. GRU

The GRU keeps the additive path and spends fewer parameters to get it.

same hidden size, 16-dimensional input
LSTMGRU
gates3 (forget, input, output)2 (update, reset)
state tensorsh and ch only
parameters (h=32)6,4004,800
additive pathc_t = f⊙c + i⊙gh_t = (1−z)⊙h + z⊙h̃
import torch
import torch.nn as nn

gru = nn.GRU(input_size=16, hidden_size=32, batch_first=True)
lstm = nn.LSTM(input_size=16, hidden_size=32, batch_first=True)

sum(p.numel() for p in gru.parameters())    # 4,800  (3 gate matrices)
sum(p.numel() for p in lstm.parameters())   # 6,400  (4 gate matrices)

out, h_n = gru(torch.randn(4, 20, 16))      # ONE state, not a (h, c) tuple

Swapping nn.LSTM for nn.GRU and leaving the unpacking as out, (h_n, c_n) = .... A GRU returns a single state tensor, so this raises a confusing 'too many values to unpack' from a line that looks unrelated to the change.

Bidirectional RNNs

What it is

A bidirectional RNN runs two independent recurrences over the same sequence — one left to right, one right to left — and concatenates their hidden states at each position, so each output vector has seen the whole sequence on both sides. It doubles the parameters and the compute, and it typically helps substantially on tasks where the full input is available up front: classification, tagging, named-entity recognition, encoding a source sentence for translation. nn.LSTM(..., bidirectional=True) gives a hidden size of 2 × hidden per position.

Why it matters

The important limit is what it cannot do. The backward pass over the sequence requires the future tokens, so a bidirectional model cannot be used for autoregressive generation or for any online, streaming prediction — at inference you simply do not have the right-hand context yet. This is the same information boundary that makes causal masking necessary in module 17, arriving here first and for the same reason.

In the real world: Reading a sentence both ways, useful for tagging names and places in documents.

import torch
import torch.nn as nn

enc = nn.LSTM(16, 32, batch_first=True, bidirectional=True)
out, (h_n, c_n) = enc(torch.randn(4, 20, 16))
out.shape     # (4, 20, 64) — 32 forward ++ 32 backward, concatenated
h_n.shape     # (2, 4, 32) — one final state per direction

head = nn.Linear(64, n_tags)    # must be 2 * hidden, not hidden

Using a bidirectional encoder for next-token prediction. Each position's representation already contains the token it is meant to predict, so training loss collapses to near zero and the model is worthless at generation — the same 'cheating' failure as a missing causal mask.

Variable-length sequences, padding and masks

What it is

Real batches contain sequences of different lengths, so they are padded to a common length with a dummy token. Nothing about the recurrence knows those positions are fake: the RNN happily consumes them and the final hidden state ends up summarising a run of padding rather than the sentence. Two fixes exist. pack_padded_sequence tells cuDNN the true lengths so each sequence's recurrence stops where it should, and the returned h_n is each sequence's real final state. Or keep the padding and mask explicitly — exclude padded positions from the loss with ignore_index, and never pool over them.

Why it matters

This is the most common silent correctness bug in sequence code. Padding contributes to the loss, to any mean-pooled representation, and to the final state, so the model is trained partly to predict a token that does not exist and partly on a representation of whitespace. The metrics look plausible, and results improve noticeably when it is fixed — which is the signature of a bug rather than a tuning opportunity.

In the real world: Handling support chats of different lengths in the same batch.

import torch
import torch.nn as nn
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence

lengths = torch.tensor([20, 14, 7, 3])             # true lengths in the batch
packed = pack_padded_sequence(x, lengths, batch_first=True, enforce_sorted=False)
out, (h_n, _) = lstm(packed)
out, _ = pad_packed_sequence(out, batch_first=True)

# and for the loss, ignore the pad id explicitly
loss_fn = nn.CrossEntropyLoss(ignore_index=PAD_ID)

Mean-pooling out over the time axis to get a sequence embedding. The padded positions are included in the mean, so a 3-token sentence padded to 20 has its representation diluted by 17 meaningless vectors. Divide by the true length, using the mask.

Why attention replaced recurrence

What it is

Two arguments, and the second is the decisive one. First, path length: relating token 1 to token 200 in an RNN requires information to survive 199 state updates, while self-attention (module 17) connects them in a single operation regardless of distance. Second, parallelism: h_t depends on h_{t−1}, so a 500-token sequence requires 500 sequential steps that no amount of hardware can overlap. A Transformer computes all positions' representations in one batched matrix multiplication, so training time scales with available compute rather than with sequence length — and that is what made training on internet-scale corpora feasible.

Why it matters

It explains a historical shift as an engineering property rather than a fashion. It also explains where recurrence still wins: its per-step cost is constant and its state is fixed-size, so for streaming inference, long-running time series, or tight latency and memory budgets on a device, an RNN remains a reasonable and sometimes better choice. Attention's advantage is in *training* throughput, and it pays for it with cost quadratic in sequence length.

In the real world: Why modern translation and chat systems moved to transformers: faster training and better long-range understanding.

Visual example

Recurrence vs. attention

The trade is sequential-but-cheap against parallel-but-quadratic.

sequence length n, hidden size d
RNN / LSTMself-attention
sequential steps in trainingn — cannot be parallelised1
path length between any two tokensup to n1
cost per layerO(n · d²)O(n² · d)
memory at inferenceone fixed-size statethe whole key/value cache
import torch
import torch.nn as nn

x = torch.randn(8, 512, 64)

lstm = nn.LSTM(64, 64, batch_first=True)      # 512 sequential steps
attn = nn.MultiheadAttention(64, 8, batch_first=True)   # 1 batched matmul

lstm(x)[0].shape                              # (8, 512, 64)
attn(x, x, x, need_weights=False)[0].shape    # (8, 512, 64)
# same output shape; the LSTM's 512 steps cannot be overlapped, the attention's can

Concluding RNNs are obsolete. They are the wrong default for large-scale language modelling and still a good fit for streaming, on-device, and long-horizon time-series work, where a constant-size state and a constant per-step cost matter more than training throughput.

Practise it: Scaled Dot-Product Attention