ml-models / Model architectures / RNNs and LSTMs core

RNNs and LSTMs

A recurrent network reads a sequence one step at a time and carries a hidden state forward, so the same weights handle a sequence of any length. This page builds the plain recurrence, shows why its gradients vanish over long distances, derives the LSTM and GRU gates that fix it, and follows PyTorch's nn.RNN, nn.LSTM, nn.GRU and packed sequences down to their parameter shapes.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. The recurrence
  5. Backprop through time and vanishing gradients
  6. LSTM gates, derived
  7. GRU
  8. Sequence to sequence, and attention as the bridge
  9. Padding and packed sequences
  10. Worked example
  11. Implementation
  12. Tradeoffs
  13. Connections
  14. Read first
  15. Read next
  16. References

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.

An RNN unrolled over three time steps Inputs x1, x2 and x3 enter from below into three copies of the same cell. Each cell receives the previous hidden state from the left, computes h_t = tanh(W_ih x_t + W_hh h_(t-1) + b), and passes h_t to the right and up as the output. The same weights W_ih and W_hh are used in every copy. The backward pass travels right to left along the hidden-state arrows, multiplying by W_hh and the tanh slope at each step. h₀ = 0 tanh(Wihx + Whhh + b) same weights x₁ h₁ = output tanh(Wihx + Whhh + b) same weights x₂ h₂ = output tanh(Wihx + Whhh + b) same weights x₃ h₃ = output gradient: × Whhᵀ · tanh′ per step

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.

Inside an LSTM cell The cell state c runs straight across the top: c_(t-1) is multiplied by the forget gate f, then the product of input gate i and candidate g is added, giving c_t. Below, the previous hidden state h_(t-1) and input x_t feed four parallel blocks: three sigmoids for f, i and o, and one tanh for g. The new hidden state h_t is o times tanh of c_t. The top line has no weight matrix and no squashing, which is why gradients survive along it. ct−1 ct × + × σ f σ i tanh g σ o [ht−1, xt] tanh × ht ∂ct/∂ct−1 = f (direct path)

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.

ComponentShape/ParamsMeaningSource
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.

txtWihxtWhhht−1pre-activationht = tanh(·)
11.0(0.500, −0.300)(0.000, 0.000)(0.500, −0.200)(0.462, −0.197)
20.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.

txtifgoct = f·ct−1 + i·ght = o·tanh(ct)
11.00.6460.7860.7620.5990.786·0.000 + 0.646·0.762 = 0.4920.273
20.50.5880.7640.3480.5700.764·0.492 + 0.588·0.348 = 0.5810.298
3−1.00.3680.675−0.8170.4230.675·0.581 + 0.368·−0.817 = 0.0910.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.

Modelt = 5t = 10t = 20t = 30Direct path Π f at t = 30
RNN, w = 0.50.0520.00161.6e−61.5e−9–
RNN, w = 0.950.240.110.0440.023–
RNN, w = 20.0022.5e−74.1e−156.7e−23–
LSTM, forget bias 00.0270.00021.1e−86e−131.9e−9
LSTM, forget bias 30.550.210.0320.00480.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.
Gradient magnitude against distance in time Log-scale plot of the derivative of the state at step t with respect to the state at step 1, for t from 1 to 30. The RNN with w = 2 and the LSTM with forget bias 0 fall fastest and leave the chart below 10 to the minus 16 or near it. The RNN with w = 0.5 falls steadily to about 1.5e−9. The RNN with w = 0.95 and the LSTM with forget bias 3 stay above 10 to the minus 3, ending at 0.023 and 0.0048. Values below 10 to the minus 16 are clipped. 10⁰ 10⁻⁴ 10⁻⁸ 10⁻¹² 10⁻¹⁶ 1102030 time step t RNN, w = 0.5 RNN, w = 0.95 RNN, w = 2 LSTM, forget bias 0 LSTM, forget bias 3

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

Read next

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.