Problem
Text, audio, sensor readings and clicks arrive as sequences, and their lengths vary. A feed-forward network from neurons and layers takes a fixed-size input, so it would need a separate weight for every position and could never see a longer sequence than it was built for. We want one function that consumes the sequence an element at a time, remembers what matters about the past in a fixed-size vector, and is trained with the same backpropagation as everything else.
The plain recurrent network does exactly that, but its memory is short: the gradient that tells step 1 how it affected the loss at step 30 is a product of 29 per-step factors, and it usually vanishes or explodes. The LSTM's gated, additive cell state is the classic fix. Before the Transformer, encoder-decoder LSTMs with attention were the state of the art in machine translation, and attention itself was invented to help them.
Intuition
Unroll the loop and an RNN is a very deep feed-forward network with one layer per time step, all layers sharing the same weights. Information from step 1 reaches step T only by passing through T − 1 copies of that layer, each multiplying it by the recurrent weight and squashing it through tanh. That is a game of telephone: the message fades unless every relay repeats it at full volume.
An LSTM adds a second, separate line, the cell state c, that does not pass through a weight matrix or a squashing function from step to step. Each step only scales it by a forget gate between 0 and 1 and adds new content through an input gate. If the forget gate stays near 1, the cell carries information, and gradient, across many steps nearly unchanged. Learning when to keep, write and read the cell is the whole trick.
One cell, reused at every step. Backprop through time runs the dashed path, and each step multiplies by the same Jacobian.
Mechanics
The recurrence
PyTorch's nn.RNN computes, for each step t,
ht = tanh(xt Wih⊤ + bih + ht−1 Whh⊤ + bhh)
with nonlinearity="relu" as the alternative to tanh. The two bias vectors always appear as a sum, so one would do; the source keeps the second "for CuDNN compatibility". The output at each step is ht itself,
and a model reads off a prediction with a linear layer on top: from every step for tagging, or from the last one for classifying the whole sequence.
Stacking (num_layers) feeds layer k's outputs as layer k+1's inputs. A bidirectional RNN runs a second copy right to left and concatenates the two states, so each output sees the whole sequence.
Backprop through time and vanishing gradients
Training unrolls the loop and applies the chain rule along the unrolled graph; this is backpropagation through time (BPTT). The gradient from step T back to step k is a product of Jacobians:
∂hT / ∂hk = Πt=k+1..T diag(1 − ht²) · Whh
Each factor has the same Whh. If its largest singular value times the tanh slope is below 1, the product shrinks geometrically with the distance T − k; if it is above 1 it can grow geometrically. Since tanh′ ≤ 1, a large Whh does not reliably help: it pushes the units into saturation, where the slope is near 0. Bengio, Simard and Frasconi (1994) showed this tension is fundamental for learning long-range dependencies by gradient descent. Exploding gradients are tamed by gradient clipping (Pascanu et al., 2013), rescaling the gradient when its norm passes a threshold. Vanishing gradients need a change of architecture. In practice long sequences are also cut into chunks, carrying the hidden state across chunks but stopping the gradient at each boundary (truncated BPTT).
LSTM gates, derived
To keep a gradient alive, we want a state whose step-to-step Jacobian is close to the identity. The simplest such update is additive, ct = ct−1 + (new content), so ∂ct/∂ct−1 = 1: Hochreiter and Schmidhuber's "constant error carousel". A pure sum never forgets and lets everything in, so each part gets a learned, data-dependent gate, a sigmoid between 0 and 1. The forget gate was added by Gers, Schmidhuber and Cummins (2000). PyTorch's LSTM:
it = σ(Wiixt + bii + Whiht−1 + bhi) input gate
ft = σ(Wifxt + bif + Whfht−1 + bhf) forget gate
gt = tanh(Wigxt + big + Whght−1 + bhg) candidate
ot = σ(Wioxt + bio + Whoht−1 + bho) output gate
ct = ft ⊙ ct−1 + it ⊙ gt ht = ot ⊙ tanh(ct)
The direct path from ct−1 to ct has Jacobian diag(ft): no weight matrix, no squashing. Over many steps it multiplies by Π ft, which stays near 1 for as long as the network keeps its forget gates open.
The gates also depend on ht−1, which depends on ct−1, so there are indirect paths too; the worked example measures the full derivative.
The four gates' weights are stored stacked: weight_ih_l0 is (Wii|Wif|Wig|Wio), shape (4·hidden_size, input_size), so one matrix multiply computes all four.
PyTorch initializes every LSTM weight and bias from U(−1/√hidden, 1/√hidden), forget bias included. Jozefowicz, Zaremba and Sutskever (2015) recommend setting the forget-gate bias to 1 or more; in PyTorch you do it by hand.
With proj_size > 0, ht is multiplied by a learned Whr to a smaller size (Sak et al., 2014), while ct keeps hidden_size.
The green line is the cell state: one multiply by f and one add per step.
GRU
The gated recurrent unit (Cho et al., 2014) merges the cell and hidden state and uses two gates, a reset gate r and an update gate z:
rt = σ(Wirxt + bir + Whrht−1 + bhr)
zt = σ(Wizxt + biz + Whzht−1 + bhz)
nt = tanh(Winxt + bin + rt ⊙ (Whnht−1 + bhn))
ht = (1 − zt) ⊙ nt + zt ⊙ ht−1
The last line is an interpolation, so ∂ht/∂ht−1 contains diag(zt) on its direct path: the update gate plays the forget gate's role. A GRU has three gate blocks instead of four, so 3/4 of an LSTM's parameters at the same size, and in comparisons the two usually perform similarly. The PyTorch docs note that its nt applies rt after multiplying by Whn, unlike the original paper's Whn(rt ⊙ ht−1), "on purpose for efficiency": all three hidden-side products then come from one matrix multiply.
Sequence to sequence, and attention as the bridge
For translation, an encoder RNN reads the source sentence and a decoder RNN, started from the encoder's final state, generates the target one token at a time, feeding each output back in as the next input (Sutskever, Vinyals and Le, 2014). The whole source must squeeze through one fixed-size vector, and quality dropped on long sentences. Bahdanau, Cho and Bengio (2014) let the decoder, at every step, score all encoder states against its current state, turn the scores into weights with a softmax, and read their weighted average, the context vector. That is attention. The Transformer kept the attention and dropped the recurrence, which removed the sequential dependency that stops an RNN from processing all positions of a sequence in parallel during training.
Padding and packed sequences
A batch holds sequences of different lengths. Padding them to the longest wastes compute on the padding and, worse, runs the recurrence through padding, so the "last" hidden state of a short sequence is polluted.
A PackedSequence stores only real elements, time step by time step: at each step it holds the elements of every sequence still running, with sequences sorted from longest to shortest. batch_sizes[t] is how many sequences are still running at step t, so the kernel just processes a shrinking batch.
pack_padded_sequence builds it from a padded tensor plus lengths, pad_packed_sequence undoes it, and with enforce_sorted=False the sort is done for you and recorded in sorted_indices/unsorted_indices so the outputs come back in your order.
The RNN then returns h_n at each sequence's true last step.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
| Gate size | LSTM 4H, GRU 3H, RNN H | Rows of each weight matrix: one block per gate, stacked. | rnn.py 158-167 |
weight_ih_l{k}, weight_hh_l{k} |
(gate_size, input_size or D·H), (gate_size, H) | Input and recurrent weights for layer k; layer k > 0 takes D·H inputs. | rnn.py 171-183 |
bias_ih_l{k}, bias_hh_l{k} |
(gate_size,) each | Two biases that only ever appear as a sum; the second is for CuDNN. | rnn.py 184-187 |
weight_hr_l{k}, _reverse suffix |
(proj_size, H); reverse direction gets its own set | LSTM projection; names are formatted per layer and direction. | rnn.py 188-210 |
| Initialization | U(−1/√H, 1/√H) for every parameter | No special forget bias, no orthogonal recurrent weights by default. | rnn.py 308-311 |
| LSTM equations | input (L, N, Hin); h0, c0 (D·num_layers, N, H) | i, f, g, o gates, cell and hidden update; zeros if no state is passed. | rnn.py 837-847, 1082-1204 |
| GRU equations | 3 gate blocks: r, z, n | PyTorch applies r after Whnh, on purpose for efficiency. | rnn.py 1213-1221, 1307-1325 |
| Cells | RNNCell, LSTMCell, GRUCell(input_size, hidden_size) |
One time step, for writing your own loop (e.g. a decoder). | rnn.py 1485-1540, 1662-1769 |
PackedSequence |
data (ΣL, *), batch_sizes (max L,), int64 on CPU | Real elements only, step-major; data abc, x → axbc, batch_sizes [2, 1, 1]. |
utils/rnn.py 39-60 |
| Packing | pack_padded_sequence(input, lengths, batch_first=False, enforce_sorted=True) |
Padded tensor + lengths → PackedSequence; pad_packed_sequence inverts it. |
utils/rnn.py 258-324, 327-402 |
| Padding and lists | pad_sequence(sequences, batch_first=False, padding_value=0.0, padding_side="right"), pack_sequence |
List of variable-length tensors → padded tensor or PackedSequence. | utils/rnn.py 405-470, 522-558 |
How many parameters does nn.LSTM(10, 20, num_layers=2) have? And the same as a GRU?
Layer 0: 4·20·10 + 4·20·20 + 2·80 = 2560. Layer 1 takes the 20-dim outputs: 4·20·20 + 4·20·20 + 2·80 = 3360. Total 5920. A GRU uses 3 blocks instead of 4, so 4440, exactly 3/4.
Worked example
An RNN by hand. Input size 1, hidden size 2, inputs x = (1.0, 0.5, −1.0), Wih = (0.5, −0.3)ᵀ, Whh = [[0.8, −0.2], [0.4, 0.6]], combined bias bih + bhh = (0.0, 0.1), h0 = 0.
| t | xt | Wihxt | Whhht−1 | pre-activation | ht = tanh(·) |
|---|---|---|---|---|---|
| 1 | 1.0 | (0.500, −0.300) | (0.000, 0.000) | (0.500, −0.200) | (0.462, −0.197) |
| 2 | 0.5 | (0.250, −0.150) | (0.409, 0.066) | (0.659, 0.016) | (0.578, 0.016) |
| 3 | −1.0 | (−0.500, 0.300) | (0.459, 0.241) | (−0.041, 0.641) | (−0.041, 0.566) |
An LSTM by hand. A scalar LSTM (hidden size 1) on the same inputs. Weights (Wx, Wh, b) per gate: i (0.6, 0.2, 0.0), f (0.3, 0.1, 1.0), g (1.0, −0.5, 0.0), o (0.4, 0.3, 0.0). The forget bias of 1 is the Jozefowicz et al. recommendation.
| t | xt | i | f | g | o | ct = f·ct−1 + i·g | ht = o·tanh(ct) |
|---|---|---|---|---|---|---|---|
| 1 | 1.0 | 0.646 | 0.786 | 0.762 | 0.599 | 0.786·0.000 + 0.646·0.762 = 0.492 | 0.273 |
| 2 | 0.5 | 0.588 | 0.764 | 0.348 | 0.570 | 0.764·0.492 + 0.588·0.348 = 0.581 | 0.298 |
| 3 | −1.0 | 0.368 | 0.675 | −0.817 | 0.423 | 0.675·0.581 + 0.368·−0.817 = 0.091 | 0.038 |
At step 3 the input turns negative, so the candidate g is negative and the cell drops to 0.091, but the forget gate keeps 0.675 of the old cell, which is why it does not go negative. A GRU on the same inputs (Wx, Wh: r 0.5, 0.4; z 0.7, −0.3; n 1.0, 0.6; biases 0) gives h = (0.253, 0.372, −0.384); at step 2, z = 0.568, so it keeps 0.568 of h1 and takes 0.432 of the candidate n = 0.529.
Attention over the encoder states. Treat the three RNN states as an encoder and use the last one, (−0.041, 0.566), as the decoder's query. Dot-product scores (−0.131, −0.014, 0.322), softmax weights (0.271, 0.304, 0.425), context vector (0.283, 0.192). The decoder reads a blend of every step instead of only the last state.
How far does a gradient reach? Feed x1 = 1 and then 29 zeros, and compute the exact derivative of the state at step t with respect to the state at step 1, forward-mode through every path. For a scalar RNN ht = tanh(w ht−1), each step multiplies the derivative by w(1 − ht²). For the scalar LSTM above, it is ∂ct/∂c1, including the paths through the gates.
| Model | t = 5 | t = 10 | t = 20 | t = 30 | Direct path Π f at t = 30 |
|---|---|---|---|---|---|
| RNN, w = 0.5 | 0.052 | 0.0016 | 1.6e−6 | 1.5e−9 | – |
| RNN, w = 0.95 | 0.24 | 0.11 | 0.044 | 0.023 | – |
| RNN, w = 2 | 0.002 | 2.5e−7 | 4.1e−15 | 6.7e−23 | – |
| LSTM, forget bias 0 | 0.027 | 0.0002 | 1.1e−8 | 6e−13 | 1.9e−9 |
| LSTM, forget bias 3 | 0.55 | 0.21 | 0.032 | 0.0048 | 0.25 |
- w = 0.5 shrinks the derivative by at most 0.5 per step. w = 2 looks like it should explode, but the state saturates near h = 0.958, where 1 − h² ≈ 0.083, so each step multiplies by about 0.166 and the gradient vanishes fastest of all: 6.7e−23.
- w = 0.95 lasts longest of the RNNs (0.023 at step 30) because the state decays toward 0, where tanh is linear and the factor approaches 0.95. That keeps the gradient only by forgetting the content: h30 = 0.082.
- The LSTM with forget bias 0 has f ≈ 0.500 and does no better than an RNN. With forget bias 3, f ≈ 0.953, the direct path keeps 0.25 after 29 steps, and the full derivative is 0.0048, smaller than the direct path alone: in this example the indirect paths through the gates have the opposite sign.
Only a forget gate held near 1 keeps both the content and the gradient alive across thirty steps.
Implementation
import torch
import torch.nn as nn
from torch.nn.utils.rnn import pad_sequence, pack_padded_sequence, pad_packed_sequence
class Tagger(nn.Module):
def __init__(self, vocab, dim=64, hidden=128, n_tags=10):
super().__init__()
self.embed = nn.Embedding(vocab, dim, padding_idx=0)
self.lstm = nn.LSTM(dim, hidden, num_layers=2, batch_first=True,
dropout=0.1, bidirectional=True)
self.out = nn.Linear(2 * hidden, n_tags)
for name, p in self.lstm.named_parameters():
if name.startswith("bias_ih"): # gate order i, f, g, o
p.data[hidden:2 * hidden] = 1.0
elif name.startswith("bias_hh"):
p.data[hidden:2 * hidden] = 0.0
def forward(self, tokens, lengths): # tokens (N, L) padded with 0
x = self.embed(tokens)
packed = pack_padded_sequence(x, lengths.cpu(), batch_first=True,
enforce_sorted=False)
out, (h_n, c_n) = self.lstm(packed) # h_n: (2 layers * 2 directions, N, hidden)
out, _ = pad_packed_sequence(out, batch_first=True)
return self.out(out) # (N, L, n_tags)
seqs = [torch.tensor([5, 2, 9]), torch.tensor([7, 1]), torch.tensor([3])]
tokens = pad_sequence(seqs, batch_first=True) # (3, 3), zeros as padding
lengths = torch.tensor([len(s) for s in seqs])
model = Tagger(vocab=10)
loss = model(tokens, lengths).sum()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # tame exploding gradients
Pass lengths on the CPU: batch_sizes is always a CPU int64 tensor. batch_first=True applies to input and output but not to h_n and c_n, which stay (D·num_layers, N, H).
For a bidirectional LSTM, out[:, -1] is not the same as h_n: the reverse direction's final state is at time step 0. Dropout here applies between the two layers only; PyTorch warns if you set it with num_layers=1.
Tradeoffs
- Sequential by nature. Step t needs step t − 1, so training cannot be parallelized across time the way a Transformer can. On modern accelerators that, more than accuracy, is why Transformers replaced RNNs for large models.
- Constant memory at inference. An RNN's state is a fixed-size vector, whatever the context length, while a Transformer's KV cache grows with every token. That is the appeal that state space models and other modern linear recurrences revisit.
- LSTM versus GRU. GRUs have three gate blocks to the LSTM's four and are a little cheaper; in most comparisons neither wins consistently. Start with an LSTM, set the forget bias, and try a GRU if speed matters.
- Memory is still finite. Gates slow the decay but do not remove it, and everything must pass through a fixed-size state. Attention over the encoder states sidesteps the bottleneck.
- Packing versus padding. Packing is correct and saves compute on uneven lengths, but costs a sort and some bookkeeping. With similar lengths (bucketed batches), padding plus a mask is simpler.
Why doesn't making Whh large fix vanishing gradients in a tanh RNN?
Each backward step multiplies by Whh times the tanh slope 1 − h². Large weights drive h toward ±1, where the slope is near 0, so the product still shrinks. The worked example's w = 2 RNN vanishes fastest of all.
Connections
Read first
- Backpropagation core
Read next
- State-space models advanced
- Time series core
References
- Elman. Finding Structure in Time. Cognitive Science, 1990.
- Werbos. Backpropagation Through Time: What It Does and How to Do It. Proceedings of the IEEE, 1990.
- Bengio, Simard and Frasconi. Learning Long-Term Dependencies with Gradient Descent is Difficult. IEEE Transactions on Neural Networks, 1994.
- Hochreiter and Schmidhuber. Long Short-Term Memory. Neural Computation, 1997.
- Gers, Schmidhuber and Cummins. Learning to Forget: Continual Prediction with LSTM. Neural Computation, 2000.
- Pascanu, Mikolov and Bengio. On the difficulty of training Recurrent Neural Networks. ICML 2013. arXiv:1211.5063
- Cho et al. Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation. EMNLP 2014. arXiv:1406.1078
- Sutskever, Vinyals and Le. Sequence to Sequence Learning with Neural Networks. NeurIPS 2014. arXiv:1409.3215
- Bahdanau, Cho and Bengio. Neural Machine Translation by Jointly Learning to Align and Translate. 2014. arXiv:1409.0473
- Sak, Senior and Beaufays. Long Short-Term Memory Based Recurrent Neural Network Architectures for Large Vocabulary Speech Recognition. 2014. arXiv:1402.1128
- Jozefowicz, Zaremba and Sutskever. An Empirical Exploration of Recurrent Network Architectures. ICML 2015.
- PyTorch v2.15.0:
torch/nn/modules/rnn.py,torch/nn/utils/rnn.py.