S103 · RNNs & LSTMs — Sequences & the Vanishing Gradient
Why plain RNNs forget after 20 steps and how LSTMs bought us the decade of sequence modelling that got us to Transformers. Gates, cell state, and BPTT — derived, not just described.
🎯 Understand why sequences broke feedforward networks, how RNNs weight-share across time, why they fail past 20 timesteps, and how LSTM gates fix it.
Why this session exists
From 2014 to 2017, LSTMs powered Google Translate, Siri, Alexa, and every speech-to-text system on Earth. They were the first neural architecture that could actually model sequences longer than a few tokens without falling over. Transformers replaced them in 2017 — but every idea that made Transformers work (gating, memory, sequential decoding) came from LSTM lineage. You cannot understand attention without first understanding what problem RNNs were trying and failing to solve.
- Explain why a feedforward network can't model 'the cat sat on the ___' but an RNN can.
- Derive backprop through time (BPTT) and identify exactly why gradients vanish or explode.
- Draw an LSTM cell from memory — forget gate, input gate, output gate, cell state.
- Choose between RNN, GRU, LSTM, and Transformer given a sequence-modelling problem.
- Build a working character-level LSTM in PyTorch that generates plausible text.
Prerequisites
- S099 · Backpropagation — BPTT is backprop with weight tying across time steps.
- S100 · PyTorch Fundamentals — you need
nn.LSTMand how to slice sequence tensors. - S102 · CNNs — for the weight-sharing intuition (space for CNNs, time for RNNs).
(a) Intuition · 5 min
You're reading this sentence one word at a time. Right now, as your eye hits "now," you're carrying the memory of every previous word — you know we're mid-sentence, you know the topic is reading, you know a period is probably coming soon.
You don't restart your understanding for each word. Instead, you maintain a running mental state that gets updated with each new word. That running state is what lets you understand "the cat sat on the ___" — the missing word constrains itself against everything before it.
An RNN literally does this. It processes one token at a time, and at each step takes two inputs: the current token and a hidden state carrying "everything I've seen so far." The hidden state is a vector (say 128 dims). Each step: `h_t = tanh(W · [h_{t-1}, x_t] + b)`. Same weights, every step.
Weight sharing across time is exactly what parameter sharing across space is for CNNs. It's why RNNs can handle sequences of any length with a fixed number of parameters.
What RNNs unlocked that MLPs couldn't
- Variable-length input — an MLP has a fixed input shape. An RNN can consume 5 tokens or 5,000, same weights.
- Sequential memory — the hidden state carries information from step 1 to step N. In principle, a 500-word document informs the prediction for word 501.
- Sequence generation — feed the output back as the next input and you have a language model. This is how Karpathy's 2015 char-RNN generated Shakespeare — and how GPT still generates text, one token at a time.
A short history of "why can't we do sequences?"
- 1982Hopfield networkFirst recurrent neural network. Content-addressable memory, no training on sequences per se.
- 1990Elman networkFirst RNN trained with backprop through time. Toy problems only.
- 1997LSTM · Hochreiter & SchmidhuberCell state + three gates. The paper solves vanishing gradients — but gets ignored for 15 years.
- 2014Seq2Seq · Sutskever et al.Encoder-decoder LSTM for translation. Google Translate switches to it in 2016.
- 2014GRU · Cho et al.Simpler LSTM cousin — 2 gates instead of 3, similar performance, less memory.
- 2017Attention is All You NeedTransformer paper drops. RNNs are dethroned for most NLP tasks by 2019.
(b) Visual walkthrough · 15 min
An RNN unrolled through time
The purple cells are all the same cell — same Wx and Wh weights — just applied at different timesteps. This is weight sharing across time. Training uses backprop through this unrolled graph (BPTT).
Why gradients vanish — the math
The LSTM cell — three gates and a cell state
Anatomy of an LSTM cell
The four equations that make an LSTM
RNN vs GRU vs LSTM vs Transformer — when to use what
Never use in production
- Cheapest — one weight matrix
- Vanishes past ~20 steps
- Use only for pedagogy
- GRU is strictly better
The pragmatic default (pre-2020)
- 2 gates (update + reset)
- ~25% fewer params than LSTM
- Almost identical accuracy
- Good for edge / on-device
The workhorse of 2014–2019
- 3 gates + cell state
- Handles 100–500 step deps
- Still used in time-series
- Trains slowly (sequential)
The 2020+ default
- Parallelisable across sequence
- O(N²) attention memory
- Wins for text / long context
- Overkill for short sequences
Bidirectional and stacked variants
Two orthogonal upgrades: bidirectional (run one LSTM forward and one backward, concatenate hidden states — every position sees future context) and stacked (feed one LSTM's outputs as inputs to a second LSTM — deeper representations). Google Translate 2016 used stacked bidirectional LSTMs with 8 layers.
"LSTMs solve the vanishing gradient problem, so they can learn arbitrarily long dependencies. That's what the memory cell is for — it remembers things indefinitely."
LSTMs mitigate vanishing gradients; they do not eliminate them. The cell state provides a path where the gradient is multiplied by the forget gate rather than by a weight matrix and an activation derivative — but that gate is a sigmoid output in (0,1), so gradients still decay whenever it is less than 1. LSTMs extended the practical range from roughly tens of steps to low hundreds. They did not make it unbounded, and that residual limitation is a direct reason attention was invented.
The myth is sticky because the constant-error-carousel story is told as a clean fix: the cell state is a "highway", additive rather than multiplicative, so gradients "flow unchanged". That is true only in the special case where the forget gate is saturated at exactly 1 — which the network must learn to do, and typically does not do perfectly over hundreds of steps. The mechanism is real and the improvement was enormous, so the qualitative story gets remembered while the quantitative caveat does not.
Train on a copy task and sweep the sequence length — the failure point is empirical, not absent:
# task: output the FIRST token after seeing T filler tokens
for T in [10, 50, 100, 300, 1000]:
acc = train_copy_task(model='lstm', delay=T)
print(T, acc)
# ~1.0 at short delays, degrades as T grows.
# inspect the gradient reaching the first timestep:
g = [p.grad.norm().item() for p in per_timestep_states]
print(g[0] / g[-1]) # ratio shrinks sharply with sequence lengthWhy do gradients vanish or explode in a plain RNN specifically, when a feedforward network of the same depth is comparatively well behaved? Both are deep. The difference is one word: shared.
- 1Unrolling an RNN over T timesteps produces a network T layers deep, and the gradient from the loss back to an early timestep is a product of T Jacobians.forced by · the chain rule over a composition of T functions is a product of T derivative matrices
- 2Every one of those Jacobians involves the same recurrent weight matrix W, because weights are shared across timesteps. So the product is approximately W raised to the power T, scaled by activation derivatives.forced by · weight sharing is what makes it a recurrent network rather than a deep feedforward one
- 3Repeated multiplication by one matrix is governed by its largest eigenvalue. If it exceeds 1 the product grows exponentially in T; if it is below 1 the product shrinks exponentially. There is no stable middle unless it sits essentially exactly at 1.forced by · λᵀ is exponential in T for any λ ≠ 1, in either direction
- 4Activation derivatives make it worse. Tanh and sigmoid have derivatives bounded by 1 and 0.25 respectively, so each step multiplies in another shrinking factor. Vanishing is therefore the far more common failure.forced by · a bound below 1 compounds multiplicatively across every timestep
- 5A feedforward network avoids the sharpest form of this because each layer has its own independent weight matrix, so the factors are different and can partially offset rather than compounding one eigenvalue exponentially.forced by · a product of distinct random matrices does not concentrate on a single eigenvalue the way a matrix power does
- 6Therefore the fix must break the multiplicative chain and introduce an additive path. The LSTM cell state does this:
c_t = f_t ⊙ c_{t−1} + i_t ⊙ g_t, so the derivative along the cell path is the forget gate rather than a weight matrix times an activation derivative.forced by · an additive update yields a derivative of ~1 along that path instead of a matrix factor
Therefore the problem is weight sharing across time turning the gradient into a matrix power, and gating fixes it by replacing multiplication with addition on a dedicated path.
And note what this predicts: the same additive-path trick should work anywhere depth causes gradient decay — which is precisely residual connections in deep CNNs and transformers, the identical mechanism in a different topology. It also predicts that exploding gradients, unlike vanishing ones, have a trivial fix: you can simply clip the norm, because the direction is still correct and only the magnitude is wrong. Vanishing gradients carry no information to rescue, which is why they needed an architectural answer.
Picture the cell state as a conveyor belt running left to right through time, carrying information forward with minimal interference. Three gates control access to it: the forget gate decides what to erase from the belt, the input gate decides what new material to place on it, and the output gate decides how much of the belt to expose as this timestep's visible hidden state.
The essential design choice is that the belt is modified by addition, not by transformation. Information placed on it can travel many steps largely untouched, which is what gives both the forward memory and the backward gradient path. A plain RNN has no belt — every piece of information is re-multiplied by the same matrix at every step, so it degrades exponentially.
- Gates are sigmoids in (0,1) acting as soft, learned masks; candidate values are tanh in (−1,1). The gate decides how much, the candidate decides what.
- Sequential by construction, so timesteps cannot be parallelised — this is the fundamental throughput limitation that transformers removed, and it is why RNNs lost at scale.
- Always clip gradient norms when training RNNs. Explosion is common and clipping is a complete fix; vanishing is not fixable this way.
- GRUs merge the forget and input gates into one update gate, giving roughly 25% fewer parameters and usually comparable accuracy. Try GRU first; it trains faster.
Fire this the moment you see: a plain RNN used on sequences longer than a few dozen steps · training without gradient clipping · loss going NaN partway through an epoch · a sequence model that ignores information from early in the input · variable-length batches padded without masking · an RNN chosen for a task where the whole sequence is available at once and a transformer would parallelise.
You need a sequence model for a production task. LSTM/GRU, or a transformer?
Transformers have largely won for offline sequence modelling, and if you have the data or a pretrained checkpoint that is where to start. But RNNs are not obsolete: constant-memory streaming inference is a property attention simply does not have, and for a long-running online system processing an unbounded stream, a fixed-size recurrent state is the right architecture rather than a legacy one.
For a data engineer the practical filter is the deployment shape rather than the benchmark. Bounded input available all at once, with training throughput to consider ⇒ transformer. Unbounded stream needing an incremental update per element under a memory ceiling ⇒ recurrent. And check first whether the dependency range is short enough that a windowed model wins on every axis — that is the option most often skipped, and it is frequently the correct one.
(c) Hands-on · 25 min
Build a character-level LSTM that learns to generate text in the style of its training data. We'll train on a small corpus (paste Shakespeare or your own text) and sample.
# char_lstm.py — a character-level LSTM language model.
# Train it on a small text file, then sample new text.
# Run: uv run char_lstm.py path/to/text.txt
import sys
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, TensorDataset
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
SEQ_LEN = 100
BATCH = 64
HIDDEN = 256
LAYERS = 2
EPOCHS = 20
LR = 2e-3
class CharLSTM(nn.Module):
def __init__(self, vocab_size: int, hidden: int = HIDDEN, layers: int = LAYERS):
super().__init__()
self.embed = nn.Embedding(vocab_size, hidden)
self.lstm = nn.LSTM(hidden, hidden, num_layers=layers, batch_first=True, dropout=0.2)
self.head = nn.Linear(hidden, vocab_size)
def forward(
self, x: torch.Tensor, state: tuple[torch.Tensor, torch.Tensor] | None = None
) -> tuple[torch.Tensor, tuple[torch.Tensor, torch.Tensor]]:
emb = self.embed(x) # [B, T] → [B, T, H]
out, state = self.lstm(emb, state) # [B, T, H]
logits = self.head(out) # [B, T, V]
return logits, state
def build_dataset(text: str) -> tuple[list[str], dict[str, int], torch.Tensor]:
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
data = torch.tensor([stoi[c] for c in text], dtype=torch.long)
return chars, stoi, data
def make_batches(data: torch.Tensor, seq_len: int) -> DataLoader:
n = (len(data) - 1) // seq_len
x = data[: n * seq_len].view(n, seq_len)
y = data[1 : n * seq_len + 1].view(n, seq_len)
return DataLoader(TensorDataset(x, y), batch_size=BATCH, shuffle=True)
@torch.no_grad()
def sample(model: nn.Module, chars: list[str], stoi: dict[str, int], seed: str, n: int) -> str:
model.eval()
itos = {i: c for c, i in stoi.items()}
ids = torch.tensor([[stoi[c] for c in seed]], device=DEVICE)
logits, state = model(ids)
out = seed
last = logits[:, -1, :]
for _ in range(n):
probs = F.softmax(last / 0.8, dim=-1) # temperature 0.8
nxt = torch.multinomial(probs, num_samples=1)
out += itos[nxt.item()]
logits, state = model(nxt, state)
last = logits[:, -1, :]
return out
def main(path: str) -> None:
with open(path, "r", encoding="utf-8") as f:
text = f.read()
chars, stoi, data = build_dataset(text)
vocab = len(chars)
print(f"vocab={vocab} chars={len(text):,} device={DEVICE}")
model = CharLSTM(vocab).to(DEVICE)
opt = torch.optim.Adam(model.parameters(), lr=LR)
loader = make_batches(data, SEQ_LEN)
for epoch in range(1, EPOCHS + 1):
model.train()
total = 0.0
for step, (x, y) in enumerate(loader, 1):
x, y = x.to(DEVICE), y.to(DEVICE)
opt.zero_grad()
logits, _ = model(x)
loss = F.cross_entropy(logits.reshape(-1, vocab), y.reshape(-1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
opt.step()
total += loss.item()
avg = total / step
seed = text[:20]
gen = sample(model, chars, stoi, seed, 200)
print(f"epoch {epoch} loss={avg:.3f}\n sample: {gen[20:80]!r}...")
if __name__ == "__main__":
main(sys.argv[1])Anatomy of the script
What the interesting lines do
Everything else stays the same. This is the practical test for "does the LSTM's extra machinery earn its keep on my task?" Usually the answer is: barely, and GRU is fine.
(d) Production reality · 15 min
Google's phrase-based statistical MT system (in production since 2007) was replaced overnight by a stacked LSTM encoder-decoder with attention. The launch paper describes an 8-layer bidirectional LSTM encoder and an 8-layer LSTM decoder. Training took a week on 100 GPUs — but inference latency at launch was 10× worse than the old system.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- Why does a plain RNN struggle past 20 timesteps? What does the vanishing gradient actually look like mathematically?
- What is the LSTM cell state, and how do the three gates work together to update it?
- Why did Transformers replace LSTMs, and what was the specific limitation they escaped?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.