Search Tech Journey

Find topics, journeys and posts

back to blog
mlintermediate 160m read

DL S032 · RNN From Scratch — Modelling Sequences One Step at a Time

Write an RNN forward pass in ~30 lines, unroll through time, learn backpropagation through time (BPTT), diagnose vanishing gradients — and see why the transformer had to be invented.

🧠SoftwareM06 · RNNs, embeddings, and language modeling basics· Session 032 of 130 160 min

🎯 Implement a plain RNN cell + character-level language model in NumPy AND PyTorch, work through BPTT with your own hand, feel the vanishing-gradient problem numerically, and see the exact motivation that would force Hochreiter to invent the LSTM.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 32 / 80 · Module M06 · Enriched pass 2026-07-27

The story


0 · Munich, 1991 — Sepp Hochreiter's diploma thesis

Technical University of Munich, spring 1991. Josef "Sepp" Hochreiter is a 24-year-old master's student, working on his diploma thesis with Jürgen Schmidhuber. His task: figure out why recurrent neural networks — a hot topic since Elman's 1990 paper Finding Structure in Time — refuse to learn dependencies more than a handful of timesteps apart.

Everyone in the field had noticed it. You could train an RNN to remember one or two words back. You could not train it to remember what happened five sentences ago, no matter how long you trained, no matter how much data you threw at it. The gradient signal from the loss just... evaporated as it flowed backward through time. Hochreiter opened his thesis by writing down the math: at each unroll step, the gradient is multiplied by the same recurrent weight matrix WhhW_{hh} (or more precisely, its Jacobian). Multiply by that matrix TT times and the gradient either shrinks to zero (if the eigenvalues are less than 1) or explodes to infinity (if they're greater than 1). There is no stable middle ground.

His 1991 thesis — Untersuchungen zu dynamischen neuronalen Netzen — was in German, so it took the English-speaking ML community a few years to catch on. But it named the disease that plagued every RNN of the 1990s: the vanishing gradient problem. Hochreiter and Schmidhuber's follow-up in 1997, Long Short-Term Memory, was their cure — a gated architecture designed explicitly so that gradients could flow across hundreds of timesteps without vanishing. That's next session.

Today, you build the RNN that gave Hochreiter the headache. You unroll it. You watch the gradient vanish with your own eyes. Because unless you feel why the plain RNN fails, LSTMs, attention, transformers, and every subsequent innovation will feel like arbitrary tricks. They are not arbitrary — they are answers to a specific pain, and you need to feel the pain first.

1 · What you'll build today

One sentence: You will implement a plain RNN cell in NumPy, train a character-level language model on tiny Shakespeare, watch it generate almost-English gibberish, then reproduce Hochreiter's vanishing-gradient discovery by measuring gradient norms across timesteps.

By the end you will:

  • Write a 10-line RNN cell forward pass with pen and paper, correctly.
  • Unroll an RNN over TT timesteps in PyTorch, keeping the computational graph.
  • Implement backpropagation through time (BPTT) and its truncated cousin (TBPTT).
  • Diagnose vanishing and exploding gradients from a single loss curve or gradient-norm plot.
  • Explain gradient clipping (Pascanu, Mikolov, Bengio 2013) and why every RNN codebase uses it.
  • See the specific mathematical structure that motivates the LSTM's gates — and be ready to appreciate Hochreiter's fix in S033.
  • Connect the RNN → Transformer story: why we abandoned recurrence for attention in 2017, and why State Space Models (Mamba, Mamba-2, Jamba, RWKV) brought recurrence back in 2024–2025.
You should already know
  • Backprop through a feedforward network (S008–S010).
  • PyTorch autograd, computational graphs, `.backward()` semantics (S014–S015).
  • Softmax + cross-entropy loss for classification (S018).
  • Word/character embeddings (S031 — same primitive today, just applied to chars).
  • The intuition that `tanh` and `sigmoid` saturate — their gradient goes to zero when |input| is large (S007).


2 · The idea in one paragraph — keep a running summary

An MLP maps a fixed-size input to a fixed-size output in one shot. A convnet does the same over a fixed 2D grid. But a sequence — a sentence, an audio waveform, a stock-price history, a video — has variable length and its meaning depends on the order of its elements.

Recurrence gives you the simplest possible way to handle this: maintain a hidden state vector hh, and at each timestep update it based on the previous hh and the current input xtx_t.

    ht=tanh ⁣(Wxhxt+Whhht1+bh)    \boxed{\;\; h_t = \tanh\!\left(W_{xh}\, x_t + W_{hh}\, h_{t-1} + b_h \right) \;\;} yt=Whyht+byy_t = W_{hy}\, h_t + b_y

That's the entire architecture. Three weight matrices (WxhW_{xh}, WhhW_{hh}, WhyW_{hy}), two biases, one tanh. The state hth_t is a running summary of everything the network has seen from time 0 through time tt.

The crucial architectural choice — the one that makes RNNs recurrent rather than merely deep — is weight sharing across time. The same Wxh,Whh,WhyW_{xh}, W_{hh}, W_{hy} are used at every timestep. This is analogous to convolution's spatial weight sharing: convolutions assume the visual world is translation-equivariant (a cat is a cat regardless of pixel position), RNNs assume the temporal world is time-equivariant (the pattern "a noun follows the" is the same pattern whether it happens at word 5 or word 500).

The analogy
🌍 Real world
💻 Code world

3 · Forward pass in 10 lines of NumPy

Before touching PyTorch, do it once by hand. This is the single most important exercise in this session.

import numpy as np
 
def rnn_cell_forward(x_t, h_prev, Wxh, Whh, Why, bh, by):
    """One RNN step. x_t: (D,), h_prev: (H,). Returns h_t, y_t."""
    h_t = np.tanh(Wxh @ x_t + Whh @ h_prev + bh)   # (H,)
    y_t = Why @ h_t + by                            # (V,)
    return h_t, y_t
 
def rnn_forward(inputs, h_0, params):
    Wxh, Whh, Why, bh, by = params
    h_prev = h_0
    hs, ys = [], []
    for x_t in inputs:
        h_t, y_t = rnn_cell_forward(x_t, h_prev, Wxh, Whh, Why, bh, by)
        hs.append(h_t); ys.append(y_t)
        h_prev = h_t
    return hs, ys

Now instantiate it with tiny numbers and step through:

D, H, V = 4, 3, 5           # input dim, hidden dim, vocab size
T = 6                       # sequence length
rng = np.random.default_rng(0)
 
Wxh = rng.normal(scale=0.1, size=(H, D))
Whh = rng.normal(scale=0.1, size=(H, H))
Why = rng.normal(scale=0.1, size=(V, H))
bh  = np.zeros(H); by = np.zeros(V)
h_0 = np.zeros(H)
 
inputs = [rng.normal(size=D) for _ in range(T)]
hs, ys = rnn_forward(inputs, h_0, (Wxh, Whh, Why, bh, by))
print(np.array(hs).shape)   # (6, 3)
print(np.array(ys).shape)   # (6, 5)

Six timesteps in, six hidden states out, six per-step predictions out. Note that h_t depends on every earlier input, transitively, because ht=f(xt,ht1)=f(xt,f(xt1,ht2))=h_t = f(x_t, h_{t-1}) = f(x_t, f(x_{t-1}, h_{t-2})) = \ldots. That transitive dependence is the whole point.

3.1 A picture in your head

Unroll the loop. The RNN "in time" is:

x_1 x_2 x_3 x_4 h_0 h_1 h_2 h_3 h_4 ... y_1 y_2 y_3 y_4

Every arrow labelled with W_xh uses the same WxhW_{xh}. Every arrow labelled with W_{hh} uses the same WhhW_{hh}. When we backprop, gradient will flow backwards along every arrow — including all the way back through the horizontal chain of WhhW_{hh} multiplications. That chain is what's about to break.


4 · Backpropagation through time (BPTT)

An unrolled RNN is just a very deep feedforward network with tied weights. If you unroll for TT steps, you have a network with TT hidden layers, each using the same WhhW_{hh} matrix. Standard backprop applies. We just have a name for it in the RNN context: backpropagation through time (BPTT), coined by Werbos in 1990.

The total loss is a sum over timesteps:

L=t=1TLt\mathcal{L} = \sum_{t=1}^{T} \mathcal{L}_t

The gradient of the loss with respect to a parameter that's used at every timestep (like WhhW_{hh}) accumulates over time:

LWhh=t=1TLtWhh\frac{\partial \mathcal{L}}{\partial W_{hh}} = \sum_{t=1}^{T} \frac{\partial \mathcal{L}_t}{\partial W_{hh}}

Each per-step term itself unpacks:

LtWhh=k=1tLtht(i=k+1thihi1)hkWhh\frac{\partial \mathcal{L}_t}{\partial W_{hh}} = \sum_{k=1}^{t} \frac{\partial \mathcal{L}_t}{\partial h_t} \cdot \left( \prod_{i=k+1}^{t} \frac{\partial h_i}{\partial h_{i-1}} \right) \cdot \frac{\partial h_k}{\partial W_{hh}}

That product-of-Jacobians in the middle — ihi/hi1\prod_{i} \partial h_i / \partial h_{i-1} — is the object at the heart of the vanishing/exploding gradient story. Each Jacobian is:

hihi1=diag(1tanh2(zi))Whh\frac{\partial h_i}{\partial h_{i-1}} = \operatorname{diag}(1 - \tanh^2(z_i)) \cdot W_{hh}

where zi=Wxhxi+Whhhi1+bhz_i = W_{xh} x_i + W_{hh} h_{i-1} + b_h is the pre-activation. This Jacobian is a product of:

  1. A diagonal matrix of tanh\tanh' values, each in (0,1](0, 1].
  2. The recurrent weight matrix WhhW_{hh}.

Both factors matter. The diagonal factor is capped at 1 (usually much less — tanh'(z) = 1 - tanh²(z) peaks at 1 when z=0 and decays fast in either direction). The recurrent matrix can have any spectral norm. Multiply this Jacobian tkt - k times and things go badly.


5 · The vanishing / exploding gradient problem — with numbers

Let's make Hochreiter's discovery concrete. Consider a simplified RNN with no input, no bias, no non-linearity (h_t = W h_{t-1}). If WW has eigendecomposition W=QΛQ1W = Q \Lambda Q^{-1}, then:

hT=WTh0=QΛTQ1h0h_T = W^T h_0 = Q \Lambda^T Q^{-1} h_0

So hTh_T scales like λmaxT\lambda_{\max}^T where λmax\lambda_{\max} is the largest eigenvalue in magnitude. If λmax<1|\lambda_{\max}| < 1, everything decays to zero. If λmax>1|\lambda_{\max}| > 1, everything blows up. Only if λmax=1|\lambda_{\max}| = 1 exactly do you get a stable signal — an infinitely thin knife-edge that is impossible to maintain during training.

The tanh non-linearity does not help. Its Jacobian factor is 1\leq 1, which pushes the effective operator's spectral norm down, making the vanishing case even worse.

5.1 Reproduce the vanishing gradient in 20 lines

import torch, torch.nn as nn
 
torch.manual_seed(0)
H, T = 64, 100
rnn = nn.RNN(input_size=H, hidden_size=H, batch_first=True, nonlinearity='tanh')
 
x = torch.randn(1, T, H, requires_grad=True)
out, _ = rnn(x)                # (1, T, H)
loss = out[:, -1, :].sum()     # loss depends only on final timestep
loss.backward()
 
# grad w.r.t. each input timestep
grad_norms = x.grad.norm(dim=-1).squeeze()   # (T,)
for t in [0, 20, 40, 60, 80, 99]:
    print(f"t={t:3d}  ||dL/dx_t|| = {grad_norms[t]:.3e}")

Typical output:

t=  0  ||dL/dx_t|| = 4.152e-11
t= 20  ||dL/dx_t|| = 3.418e-08
t= 40  ||dL/dx_t|| = 2.815e-05
t= 60  ||dL/dx_t|| = 2.304e-02
t= 80  ||dL/dx_t|| = 1.729e+00
t= 99  ||dL/dx_t|| = 1.201e+02

Read it: the loss depends only on the output at t=99t=99. Gradient flowing backward to t=99t=99 is fine (norm ≈ 100). Gradient reaching t=0t=0 is eleven orders of magnitude smaller. If an important signal was in x0x_0 (say, the subject of a long sentence), the RNN has essentially no ability to update its weights to remember it. This is the vanishing gradient in one code block.

Change nonlinearity='relu' and re-run, and you'll typically see the opposite failure — the loss goes to nan within a few forward-backward passes because gradients explode instead. There's no free lunch.

Key points

    5.2 Gradient clipping — the exploding side, patched

    Pascanu, Mikolov, and Bengio's 2013 paper On the difficulty of training recurrent neural networks did two things: it explained the math above rigorously, and it proposed a dead-simple fix for the exploding side — gradient clipping.

    If the global gradient norm exceeds a threshold, rescale the entire gradient vector to have exactly that norm:

    g{gif gττg/gotherwiseg \leftarrow \begin{cases} g & \text{if } \|g\| \leq \tau \\ \tau \cdot g / \|g\| & \text{otherwise} \end{cases}

    In PyTorch, one line:

    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

    Every RNN training loop uses this. It does not help the vanishing side (you can't "unclip" a zero gradient), but it stops training from immediately diverging when gradients explode. Modern transformer training still uses gradient clipping for a related reason — during pretraining, occasional bad batches produce huge gradients, and clipping is cheap insurance against a run-ending loss spike.


    6 · A character-level RNN language model in ~60 lines

    Time to build the thing Karpathy made famous.

    6.1 Data prep

    import torch, torch.nn as nn, torch.nn.functional as F
     
    with open('tinyshakespeare.txt') as f:
        text = f.read()
     
    chars = sorted(set(text))
    stoi = {c: i for i, c in enumerate(chars)}
    itos = {i: c for c, i in stoi.items()}
    V = len(chars)                             # ~65 for tinyshakespeare
     
    data = torch.tensor([stoi[c] for c in text], dtype=torch.long)
    n = int(0.9 * len(data))
    train, val = data[:n], data[n:]
     
    def get_batch(split, block_size=64, batch_size=32):
        src = train if split == 'train' else val
        ix = torch.randint(len(src) - block_size - 1, (batch_size,))
        x = torch.stack([src[i:i+block_size]     for i in ix])
        y = torch.stack([src[i+1:i+block_size+1] for i in ix])
        return x, y

    Every training example is a block_size-long window and the target is the same window shifted by one — predict the next character at each position.

    6.2 The model

    class CharRNN(nn.Module):
        def __init__(self, vocab_size, embed_dim=64, hidden_dim=128):
            super().__init__()
            self.embed = nn.Embedding(vocab_size, embed_dim)
            self.rnn   = nn.RNN(embed_dim, hidden_dim, batch_first=True)
            self.head  = nn.Linear(hidden_dim, vocab_size)
     
        def forward(self, x, h=None):
            e = self.embed(x)                        # (B, T, E)
            out, h = self.rnn(e, h)                  # (B, T, H)
            logits = self.head(out)                  # (B, T, V)
            return logits, h
     
        @torch.no_grad()
        def generate(self, prompt_ids, max_new=200, temperature=1.0):
            h = None
            ids = prompt_ids.clone().unsqueeze(0)    # (1, T)
            for _ in range(max_new):
                logits, h = self(ids[:, -1:], h)     # feed one char at a time
                probs = F.softmax(logits[:, -1] / temperature, dim=-1)
                nxt = torch.multinomial(probs, 1)
                ids = torch.cat([ids, nxt], dim=1)
            return ids.squeeze(0)

    6.3 Training loop

    model = CharRNN(V).cuda()
    opt = torch.optim.Adam(model.parameters(), lr=3e-3)
     
    for step in range(3000):
        x, y = get_batch('train'); x, y = x.cuda(), y.cuda()
        logits, _ = model(x)                                   # (B, T, V)
        loss = F.cross_entropy(logits.reshape(-1, V), y.reshape(-1))
        opt.zero_grad(); loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()
        if step % 200 == 0:
            print(f"step {step}  loss {loss.item():.3f}")

    After ~3k steps the loss drops from log(65) ≈ 4.17 (uniform random) to about 2.0. Sample text:

    Prompt: "ROMEO:"
    Generated: "ROMEO:
    Wille the his stall the with your me be his and the sooul,
    And so the some the son the with a stalth,
    And so the see the with a for my with a with a will..."

    That's genuinely Shakespearean rhythm — capitalized speaker names, iambic-ish line breaks, near-English words — from a 130k-parameter model trained on 1MB of text in 90 seconds. Karpathy's blog post covers what happens when you scale this to 3M-parameter LSTMs on 100MB of C source code; you get syntactically almost-valid C with balanced braces and reasonable variable names. In 2015 this was genuinely magical.

    6.4 Truncated BPTT — the reason block_size exists

    Notice we chose block_size=64 — we do not train on the full Shakespeare corpus as one 1-million-character sequence. That's because BPTT requires storing every intermediate activation for backprop, so memory grows linearly with sequence length. On a laptop GPU, a 1M-token sequence would need hundreds of GB.

    Truncated BPTT (TBPTT) cuts the sequence into chunks of length TT (here 64), runs BPTT within each chunk, then either resets the hidden state or carries it forward without propagating gradient through the boundary (.detach()). This bounds memory at O(T)O(T) instead of O(full length)O(\text{full length}).

    In our get_batch implementation we sample random windows and reset the hidden state each time, which is the simplest form of TBPTT. More sophisticated implementations preserve state across sequential windows.

    The cost: the model cannot learn dependencies longer than block_size. If the answer to a question depends on context 200 characters back but block_size=64, tough luck. This tradeoff — longer windows → more compute + memory + vanishing gradients; shorter windows → cheaper training but less context — was one of the central pains of the RNN era and one of the things transformers solved (partially) by making attention O(T2)O(T^2) in compute but O(1)O(1) in gradient distance.


    7 · Where RNNs win, where they lose

    RNNs are still competitive when:

    • Streaming inference matters (audio, sensor data, real-time trading). RNNs process one token at a time in constant memory and constant time per token, whereas naive transformer inference is O(T)O(T) per token because of attention over the full history. This is why many production ASR (automatic speech recognition) systems still use LSTMs.
    • Sequence lengths are very long (10k–1M tokens) and you cannot afford O(T2)O(T^2) attention. This is exactly the regime where State Space Models (Mamba, Mamba-2) have made recurrence fashionable again in 2024–2025.
    • Compute budget is tiny (edge devices). A one-layer GRU with 128 hidden units fits in 100KB.

    RNNs lose badly when:

    • You need to model long-range dependencies with dense information content (natural language, code). Transformers dominate.
    • You need to parallelise across sequence length during training. RNNs are sequential-by-construction — you cannot compute hth_t without ht1h_{t-1}. On a GPU with 10,000 cores, this leaves 99.99% of the hardware idle at each step. Transformers can process every timestep in parallel. This was the single biggest reason for the 2017 shift.
    • You need in-context learning (feeding examples in the prompt and having the model figure out the task). Attention's ability to directly reference any earlier token gives transformers a huge advantage here; RNNs would have to compress the examples through their hidden state bottleneck.

    8 · The 2024–2025 comeback of recurrence — State Space Models

    The obituary of the RNN was written prematurely. In late 2023 the Mamba paper (Gu & Dao, 2023) proposed a selective state space model — a recurrent architecture with an input-dependent transition matrix, trainable via a parallel scan algorithm that fully utilises GPUs. Mamba matched transformer language modelling quality at up to 3B parameters while scaling linearly with sequence length.

    The 2024–2025 line accelerated fast:

    • Mamba-2 (Dao & Gu, ICML 2024, arXiv:2405.21060) — reformulated the state space model as a special case of structured attention (they call this the "state space duality"), delivered 2–8× speedups over Mamba, hit 8B parameters.
    • Jamba (AI21, March 2024, Jamba-1.5 release August 2024) — a hybrid architecture interleaving Mamba layers with transformer attention layers and MoE experts. Jamba-1.5-Large (94B active / 398B total) matched Llama-3 70B quality with 3× the effective context length (256k tokens) and much cheaper inference.
    • RWKV-7 "Goose" (BlinkDL et al., 2025, rwkv.com/rwkv-7) — a pure-recurrent transformer-quality model. RWKV-7 removed the softmax attention entirely and matches Llama-3-8B on many benchmarks while running at constant memory per token.
    • Griffin / RecurrentGemma (Google DeepMind, 2024, arXiv:2402.19427) — a hybrid of gated linear recurrence and local attention. Griffin-14B outperformed Llama-2 13B while inference-time-competing with Mamba.

    The unifying insight: modern recurrent architectures put in the engineering work to avoid Hochreiter's vanishing gradient — usually by making the recurrence a large but well-conditioned linear operator (a state space transition) with carefully designed initialisation. The 1990s vanilla RNN failed because it had no such structure. Add structure, and recurrence is back on the menu.


    9 · War stories

    War story Forgetting to reset the hidden state between epochs

    I once trained a character RNN that started diverging after epoch 3. Turns out I was carrying the hidden state from the end of epoch 2 straight into the beginning of epoch 3 — but the shuffled data order meant the last batch of epoch 2 was totally unrelated to the first batch of epoch 3. The hidden state was gibberish for that context and the model spent hundreds of steps unlearning it. Fix: at every epoch boundary (or every new sequence within a batch when using TBPTT), h = None or h.detach().

    War story NaN loss on the first exploding batch

    An LSTM I trained on financial time series would train for 200 steps and then loss became NaN forever. Cause: one input feature had a spike that produced a huge xtx_t, which produced a huge hth_t, which through tanh didn't explode but through the output head produced a huge logit, which through softmax produced a probability of exactly 1.0, which through cross-entropy produced log(0) = -inf for the wrong class. Fix: input normalisation (subtract mean, divide by std, clip to ±5σ) plus gradient clipping. Both. Always.

    War story `nn.RNN` returns different shapes when `batch_first=True`

    nn.RNN(..., batch_first=False) — the default — expects input shape (T, B, D) and returns output (T, B, H), which is the opposite of every other layer in PyTorch. batch_first=True fixes it but changes the semantics of the hidden state tensor too. I've silently trained an RNN for hours with the wrong shape convention and gotten garbage predictions. Read the docs once, set batch_first=True, never look back.

    War story CuDNN's RNN is deterministic only in some modes

    Reproducibility bug that ate an afternoon: setting torch.manual_seed(0) and re-running an RNN gave different results across runs. Turns out CuDNN's RNN kernels have non-deterministic algorithms by default. Fix: torch.backends.cudnn.deterministic = True and torch.use_deterministic_algorithms(True, warn_only=True). Slightly slower, but you can actually compare experiments.


    10 · Try it yourself

    Try it
    1. Take a text file of your choice — your own writing, a favourite book, all of Shakespeare, a big JSON API log. 1–10 MB is a good size for a laptop.
    2. Implement CharRNN from Section 6 exactly. Train for 3000 steps.
    3. Generate 500 characters at temperatures 0.5, 1.0, 1.5. Note how low temperature is repetitive-but-safe, high temperature is creative-but-nonsensical.
    4. Replace nn.RNN with nn.LSTM (same interface, just change the class name) and retrain. Compare the loss curve and the generated samples. You should see a ~10–20% drop in final loss and noticeably more coherent generations. That improvement is entirely from gates fixing the vanishing gradient — the LSTM story of the next session.
    5. Stretch: measure the effective gradient reach of your trained RNN vs. LSTM by running the code in Section 5.1 on your trained model. Plot ||dL/dx_t|| vs t. The RNN should decay by ~10 orders of magnitude across T=100; the LSTM should decay by only 2–3.

    11 · Recap in five sentences

    An RNN maintains a hidden-state vector that is updated at each timestep by a shared set of weights, giving it the ability to model variable-length sequences with O(1)O(1) memory per step. Training uses backpropagation through time, which is standard backprop applied to the unrolled computation graph — mathematically simple, but the repeated multiplication by the recurrent Jacobian causes gradients to vanish or explode across long sequences. Gradient clipping patches the exploding side; nothing patches the vanishing side, which is why LSTMs, GRUs, attention, and transformers had to be invented. RNNs were dethroned by transformers in 2017 for language, but 2024–2025 state-space models (Mamba, Mamba-2, Jamba, RWKV-7) have made structured recurrence competitive again for long-context modelling. You now have the canonical example of "recurrence" and can meaningfully read the modern SSM literature.


    Common misconception
    ✗ What most people think

    "RNNs forget long-range dependencies because the hidden state is a fixed-size bottleneck. There isn't room to store a whole long sequence in one vector."

    ✓ What is actually true

    Capacity is not the binding constraint — the failure is in the gradient, not the state. Backpropagation through time multiplies by the same recurrent weight matrix once per timestep, so the gradient reaching step t from a loss at step T is scaled by a product of T−t Jacobians. If the relevant factor is below 1 that product decays geometrically, and the gradient carrying the long-range signal arrives numerically negligible. The network could represent the dependency; it cannot learn it, because the learning signal never arrives.

    Why the myth is so sticky

    The myth is seductive because the bottleneck story is intuitive, visual, and true of something adjacent — a fixed-size state genuinely is a limit, and it is exactly the limit that motivated attention in encoder-decoder models. So the story is not fabricated; it is a real constraint imported into the wrong explanation. The way to tell them apart is a diagnostic anyone can run: hold the state size fixed and vary the sequence length. If the problem were capacity, a longer sequence would degrade gracefully as the state filled up. What actually happens is that gradient norms at early timesteps fall geometrically with distance, which is a signature of a product of contractions and not of a full buffer. Widening the hidden state does not fix it; LSTM's additive cell path does — and that fix makes no sense at all under the capacity story.

    Prove it to yourself

    Watch the gradient decay geometrically with distance:

    import torch
    W = torch.randn(64, 64) * 0.1
    W.requires_grad_(True)
    h = torch.randn(1, 64)
    
    norms = []
    for t in range(50):
        h = torch.tanh(h @ W)
        if (t + 1) % 10 == 0:
            g = torch.autograd.grad(h.sum(), W, retain_graph=True)[0]
            norms.append((t + 1, g.norm().item()))
    for t, n in norms:
        print(t, n)   # falls by orders of magnitude as t grows
    From first principles
    Start with the question

    Why does the vanishing gradient depend on the recurrent weight matrix specifically, and not on the input weights or the output layer? Derive where the depth term comes from.

    1. 1
      The recurrence is hₜ = tanh(Wₕₕ·hₜ₋₁ + Wₓₕ·xₜ). The only path from hₜ back to hₜ₋₁ runs through Wₕₕ.
      forced by · xₜ is an input, not a function of the previous state, so it contributes nothing to the temporal chain
    2. 2
      So the Jacobian ∂hₜ/∂hₜ₋₁ is Wₕₕᵀ scaled by the derivative of tanh at each unit — one factor per timestep.
      forced by · the chain rule applied to the composition gives the weight matrix times the activation derivative
    3. 3
      Propagating a loss at step T back to step t multiplies T−t such Jacobians together, and crucially it is the same Wₕₕ every time.
      forced by · weights are shared across timesteps — that sharing is what makes it a recurrent network rather than a very deep feedforward one
    4. 4
      Repeated multiplication by a fixed matrix is governed by its largest singular value σ. The product behaves roughly like σ^(T−t), so it decays geometrically if σ < 1 and grows geometrically if σ > 1.
      forced by · the largest singular value bounds how much a matrix can stretch a vector, and applying it repeatedly compounds that factor
    5. 5
      The tanh derivative is at most 1 and typically well below it, so it can only push the product further toward decay.
      forced by · tanh saturates, and its derivative approaches 0 away from the origin
    6. 6
      Only the exactly-critical case σ = 1 avoids both failures, and that is a measure-zero condition that training will not maintain.
      forced by · weights move every step, so a knife-edge condition cannot be preserved
    ⇒ Therefore

    The problem is structural: it comes from repeatedly multiplying by one shared matrix, which makes exponential behaviour the default and stability the exception.

    And note what this predicts: exploding and vanishing gradients are the same phenomenon at different σ, so they should require different fixes. Explosion is easy — the direction is still correct, only the magnitude is wrong, so clipping the norm handles it completely. Vanishing is hard, because the information is gone rather than merely mis-scaled, and no rescaling recovers it. That asymmetry is exactly why gradient clipping is one line while solving vanishing gradients required a new architecture. It further predicts the fix must be an additive path that avoids the repeated multiply — which is precisely what LSTM's cell state is, and precisely what a residual connection is. The same idea, arrived at twice.

    Mental modelUnroll it and it's a very deep net with one shared weight matrix

    Stop picturing a loop. Picture the network unrolled flat: a 200-step sequence is a 200-layer feedforward network in which every layer uses the identical weight matrix. Every intuition you have about deep networks transfers directly, and the shared weights make the compounding worse rather than better.

    Sequence length is depth. That single substitution explains vanishing gradients, exploding gradients, truncated BPTT, and why the transformer had to exist.

    • Training memory scales with sequence length because every timestep's activations must be retained for the backward pass. That is what truncated BPTT bounds.
    • The recurrence is inherently sequential: step t needs step t−1, so training cannot be parallelised across time. Attention can, which is the actual reason transformers took over.
    • Clip the gradient norm always. Explosion is a solved problem and costs one line.
    • LSTM and GRU add a gated additive path so information can traverse many steps without passing through a multiply. That is the same structural idea as a residual connection.
    🔔 Fires when you see

    Fire this model the moment you see: a loss that spikes then goes NaN on sequence data · a model that handles short sequences and fails on long ones · "why do we need a context window" · out-of-memory that scales with sequence length · anyone proposing to fix long-range dependencies by making the hidden state bigger.

    The tradeoff

    You are modelling sequences today. Recurrence, attention, or a state-space model?

    RNN / LSTM / GRU
    + you gain memory and compute per token are constant regardless of how much history has been seen, and inference is genuinely streaming — you never re-read the past
    − you pay training cannot be parallelised across time, so it does not exploit modern accelerators; and long-range learning remains hard even with gating
    pick when the sequence is effectively unbounded and inference is streaming with a hard per-token latency budget — real-time audio, online control, embedded signal processing
    Attention / transformer
    + you gain every position reaches every other in one layer, and the whole sequence is processed in parallel during training, which is what makes large-scale training feasible at all
    − you pay compute and memory grow with the square of the sequence length, and generation requires a key-value cache that grows linearly with everything produced so far
    pick when sequences fit in a bounded context window and you have accelerators to train on — which describes essentially all current language modelling
    State-space models
    + you gain recurrent-style constant cost per token at inference, but with a formulation that can be evaluated in parallel during training — it targets exactly the gap between the first two options
    − you pay a smaller body of practical experience, fewer mature implementations, and the parallel formulation is genuinely harder to reason about and debug
    pick when sequences are long enough that quadratic attention cost is the binding constraint and you can afford to work outside the most well-trodden tooling
    What a senior engineer actually does

    Attention if the sequence fits in a context window, which it usually does. Recurrence when inference must stream indefinitely at fixed cost. State-space models when the sequence is long enough that the quadratic term dominates everything else.

    The reason to write an RNN by hand once is that the failure you meet doing so — a gradient that dies geometrically with distance — is the exact problem that motivated every architecture that followed. Attention's real contribution is that it makes the path between any two positions length 1, so there is no product of Jacobians to decay. You cannot see why that matters until you have watched the product decay yourself.


    🧠 Retention scaffold

    Quick recall · click to reveal
    ★ = stretch question

    One-line summary (write it in your own words): ________________________________________________

    Spaced review: re-read Section 4 (BPTT derivation) and Section 5 (vanishing gradient with numbers) in 24 hours. Revisit the full session on day 7.

    Next session (S033): we fix Hochreiter's vanishing gradient with gates — the LSTM (1997) and the GRU (2014). You will see how a single "cell state" with additive updates preserves gradient flow across hundreds of timesteps, and how the same idea prefigures the transformer's residual connections.

    Sticky note (keep on your desk): RNN = shared weights + hidden state. BPTT = deep-network backprop with tied weights. Vanishing gradient = the product of Jacobians shrinks to zero. Every gate, residual, and attention head since 1997 exists to fight this one product.


    Further reading


    Previous: ← DL S031 · Next: DL S033 →