Search Tech Journey

Find topics, journeys and posts

6-month learning plan103 / 130
back to blog
mladvanced 50m read

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.

🤖Machine LearningM12 · Deep Learning· Session 103 of 130 90 min

🎯 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.

You will be able to
  • 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.LSTM and how to slice sequence tensors.
  • S102 · CNNs — for the weight-sharing intuition (space for CNNs, time for RNNs).


(a) Intuition · 5 min

Reading a sentence one word at a time
🌍 Real world

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.

💻 Code world

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

Three capabilities that only made sense once we had recurrence
  • 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?"

  1. 1982
    Hopfield network
    First recurrent neural network. Content-addressable memory, no training on sequences per se.
  2. 1990
    Elman network
    First RNN trained with backprop through time. Toy problems only.
  3. 1997
    LSTM · Hochreiter & Schmidhuber
    Cell state + three gates. The paper solves vanishing gradients — but gets ignored for 15 years.
  4. 2014
    Seq2Seq · Sutskever et al.
    Encoder-decoder LSTM for translation. Google Translate switches to it in 2016.
  5. 2014
    GRU · Cho et al.
    Simpler LSTM cousin — 2 gates instead of 3, similar performance, less memory.
  6. 2017
    Attention is All You Need
    Transformer 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

f_t = σ(W_f · [h_(t-1), x_t] + b_f)
Forget gate. σ (sigmoid) outputs 0–1 per cell dimension. 0 = wipe this memory slot, 1 = keep it. Learned per token.
gate
i_t = σ(W_i · [h_(t-1), x_t] + b_i)
Input gate. Which slots of the cell state should be updated with new info from x_t?
gate
g_t = tanh(W_g · [h_(t-1), x_t] + b_g)
Candidate new values. What could go into the cell state (before gating).
content
c_t = f_t ⊙ c_(t-1) + i_t ⊙ g_t
The cell state update. Elementwise: forget some, add some new. This is a HIGHWAY — gradients flow through the + unchanged when forget=1. That's how LSTMs beat vanishing gradients.
memory
o_t = σ(W_o · [h_(t-1), x_t] + b_o)
Output gate. Which parts of the cell state do we expose as the hidden state (visible to the next layer)?
gate
h_t = o_t ⊙ tanh(c_t)
The visible hidden state. Feeds into the next timestep AND the output head.
output

RNN vs GRU vs LSTM vs Transformer — when to use what

Vanilla RNN

Never use in production

  • Cheapest — one weight matrix
  • Vanishes past ~20 steps
  • Use only for pedagogy
  • GRU is strictly better
GRU

The pragmatic default (pre-2020)

  • 2 gates (update + reset)
  • ~25% fewer params than LSTM
  • Almost identical accuracy
  • Good for edge / on-device
LSTM

The workhorse of 2014–2019

  • 3 gates + cell state
  • Handles 100–500 step deps
  • Still used in time-series
  • Trains slowly (sequential)
Transformer

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.


Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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 length
From first principles
Start with the question

Why 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.

  1. 1
    Unrolling 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
  2. 2
    Every 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
  3. 3
    Repeated 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
  4. 4
    Activation 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
  5. 5
    A 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
  6. 6
    Therefore 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

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.

Mental modelA conveyor belt with gated write, erase, and read

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.
🔔 Fires when you see

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.

The tradeoff

You need a sequence model for a production task. LSTM/GRU, or a transformer?

LSTM / GRU
+ you gain memory and compute scale linearly in sequence length, whereas self-attention is quadratic — so for very long sequences an RNN may be the only feasible option. It also maintains a fixed-size state, which makes true streaming inference natural: process one new element in constant time and memory, with no growing context. Far fewer parameters, so it trains on modest data without overfitting.
− you pay sequential dependency means timesteps cannot be parallelised during training, so wall-clock training time scales with sequence length and modern hardware sits underutilised; long-range dependencies degrade beyond a few hundred steps; and there is no ecosystem of pretrained weights to transfer from
pick when genuine streaming or online inference with unbounded input, very long sequences where quadratic attention is infeasible, limited training data, or a tight latency and memory budget on edge hardware
Transformer
+ you gain every position attends to every other in one operation, so the path length between any two tokens is constant rather than proportional to their distance — long-range dependencies are structurally easy. Training parallelises fully across positions, which is what allowed scaling to the model sizes that changed the field, and pretrained checkpoints exist for nearly every domain.
− you pay attention is O(n²) in time and memory, which becomes the binding constraint on long inputs; needs substantially more data to train from scratch since it has weaker inductive biases; positional information must be added explicitly because attention is permutation-invariant; and autoregressive inference requires a KV cache that grows with generated length
pick when sequences of bounded moderate length, plentiful data or a suitable pretrained checkpoint, and training throughput that matters — the default for text and increasingly for everything else
Neither — a windowed feedforward or temporal CNN
+ you gain if the dependency range is genuinely short and known, a fixed window into an MLP or a dilated 1D CNN is far simpler, fully parallel, and often just as accurate; dilated convolutions in particular reach long receptive fields at logarithmic depth with none of the sequential cost
− you pay the context window is fixed at design time, so anything outside it is invisible and the model cannot adapt; no ability to carry state indefinitely
pick when the relevant history is short and bounded (recent sensor readings, a fixed lookback in a forecasting problem) — very common in time-series work and routinely over-engineered into an RNN
What a senior engineer actually does

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

nn.Embedding(vocab_size, hidden)
Learnable lookup table — maps each character ID to a 256-dim vector. Vocab of ~90 chars → 90×256 = 23K params. Nothing fancy.
embed
nn.LSTM(hidden, hidden, num_layers=2, dropout=0.2)
Two stacked LSTM layers, each with 256 hidden units. PyTorch handles unrolling internally. Dropout applies BETWEEN layers, not within a timestep.
rnn
logits, state = self.lstm(emb, state)
Passes hidden state along. During training we start fresh each batch; during sampling we thread state across calls so the LSTM 'remembers' what it just generated.
state
F.cross_entropy(logits.reshape(-1, V), y.reshape(-1))
Flatten [B, T, V] to [B·T, V] and [B, T] to [B·T]. Compute loss over every position. This is teacher-forcing — target at t+1 is the ground-truth token, not the model's prediction.
loss
clip_grad_norm_(model.parameters(), 5.0)
Gradient clipping. LSTMs can still get exploding gradients on adversarial batches. Cap the total gradient L2 norm at 5. Cheap insurance.
safety
probs = F.softmax(last / 0.8, dim=-1)
Temperature sampling. T \<1 = sharper (more repetitive), T \>1 = flatter (more random). 0.8 is a good default for text generation.
sample
torch.multinomial(probs, num_samples=1)
Draw ONE token from the probability distribution. Alternative: argmax (greedy) — deterministic but boring output.
sample
Try itReplace nn.LSTM with nn.GRU and see if training changes
# swap nn.LSTM nn.GRUself.rnn = nn.GRU(hidden, hidden, num_layers=layers, batch_first=True, dropout=0.2)

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.

💡 Hint · Change `nn.LSTM` to `nn.GRU` and drop the `dropout=0.2` if PyTorch complains. GRU has no cell state — the state is a single tensor, not a tuple. You'll need to unpack `state` differently. Compare final loss and sample quality after 20 epochs — they'll be within 5% of each other, and GRU will train ~10% faster.

(d) Production reality · 15 min

War story Google · Neural Machine Translation· 2016all Google Translate traffic
🔥 What broke

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.

🧯 The fix
They deployed quantised int8 weights (4× memory savings) and used TPUs for serving, dropping latency to below the old system. The team also introduced sub-word units (wordpiece) to keep the vocabulary manageable — a technique that survived into every LLM tokenizer.
🎓 Lesson to steal
Sequential models are hard to serve. Every serious LSTM deployment ends up quantising and using specialised hardware. It's part of why Transformers eventually won — they're much easier to parallelise for inference too.
Post-mortem
War story Uber · Michelangelo forecasting· 20181M+ time series
🔥 What broke
Uber's ML team tried to use LSTMs to forecast ride demand per city per hour. Trained on 2 years of data, the model looked great in backtesting. In production it produced wildly wrong forecasts every Monday morning. Investigation: the LSTM had memorised the seasonality — but the vector it exposed to the head was the LAST hidden state, which was dominated by the previous hour, not the weekly pattern.
🧯 The fix
They added an attention layer over ALL hidden states (not just the last one), plus explicit features for hour-of-day and day-of-week. Forecast MAPE dropped from 15% to 8%. The attention layer was, in retrospect, a mini-Transformer bolted onto an LSTM — and by 2020 they replaced the whole thing with a Temporal Fusion Transformer.
🎓 Lesson to steal
The "last hidden state" of an LSTM is a lossy summary of the whole sequence. If your task depends on distant history, you need attention over the full sequence — which is why Transformers replaced LSTMs.
Post-mortem
War story OpenAI · Char-RNN 2016 experiments· 2016research
🔥 What broke
OpenAI trained a large char-LSTM on 82M Amazon reviews. During analysis, a single neuron in the LSTM's hidden state turned out to perfectly predict the sentiment of the review — despite the model being trained only on next-character prediction. Flipping that one neuron in the middle of generation would turn a positive review into a negative one.
🧯 The fix
Not a bug — an insight. The paper showed that unsupervised sequence modelling learns semantic features "for free" as a byproduct of predicting the next character. This finding is the theoretical grandparent of GPT: scale unsupervised next-token prediction hard enough and you get intelligence as a side effect.
🎓 Lesson to steal
Every LLM you use today is doing what that 2016 LSTM did — predicting the next token — just at 100,000× the scale. The lesson from LSTMs: architecture matters less than data + compute + a self-supervised objective.
Post-mortem

Where this shows up in the rest of the plan

RNN/LSTM ideas ripple through every sequence model that follows
S107 · Attention Intuition
Attention was invented as an add-on to LSTM encoders. You'll see how it made the leap to a standalone architecture.
S111 · Full Transformer
The encoder-decoder shape of a Transformer comes directly from seq2seq LSTMs. Same idea, no recurrence.
S085 · Time Series Forecasting
LSTMs are still standard for numerical time series (energy load, retail demand). Transformers are catching up but not always winning.
S121 · Speech-to-Text
Wav2Vec2 replaced LSTMs, but pre-2020 speech pipelines were all LSTM-based (Deep Speech, LAS).
S110 · Positional Encoding
The reason Transformers need positional encoding is that they DON'T have RNN's implicit order. You'll appreciate this contrast.
S068 · Model Serving
Serving LSTMs is a case study in why sequential architectures are expensive to deploy.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. Why does a plain RNN struggle past 20 timesteps? What does the vanishing gradient actually look like mathematically?
  2. What is the LSTM cell state, and how do the three gates work together to update it?
  3. 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.