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.
🎯 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 (or more precisely, its Jacobian). Multiply by that matrix 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 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.
- 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 , and at each timestep update it based on the previous and the current input .
That's the entire architecture. Three weight matrices (, , ), two biases, one tanh. The state is a running summary of everything the network has seen from time 0 through time .
The crucial architectural choice — the one that makes RNNs recurrent rather than merely deep — is weight sharing across time. The same 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).
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, ysNow 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 . That transitive dependence is the whole point.
3.1 A picture in your head
Unroll the loop. The RNN "in time" is:
Every arrow labelled with W_xh uses the same . Every arrow labelled with W_{hh} uses the same . When we backprop, gradient will flow backwards along every arrow — including all the way back through the horizontal chain of 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 steps, you have a network with hidden layers, each using the same 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:
The gradient of the loss with respect to a parameter that's used at every timestep (like ) accumulates over time:
Each per-step term itself unpacks:
That product-of-Jacobians in the middle — — is the object at the heart of the vanishing/exploding gradient story. Each Jacobian is:
where is the pre-activation. This Jacobian is a product of:
- A diagonal matrix of values, each in .
- The recurrent weight matrix .
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 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 has eigendecomposition , then:
So scales like where is the largest eigenvalue in magnitude. If , everything decays to zero. If , everything blows up. Only if 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 , 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+02Read it: the loss depends only on the output at . Gradient flowing backward to is fine (norm ≈ 100). Gradient reaching is eleven orders of magnitude smaller. If an important signal was in (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.
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:
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, yEvery 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 (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 instead of .
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 in compute but 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 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 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 without . 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
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().
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 , which produced a huge , 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.
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.
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
- 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.
- Implement
CharRNNfrom Section 6 exactly. Train for 3000 steps. - 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.
- Replace
nn.RNNwithnn.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. - 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||vst. The RNN should decay by ~10 orders of magnitude acrossT=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 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.
"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."
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.
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.
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 growsWhy 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.
- 1The 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
- 2So 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
- 3Propagating 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
- 4Repeated 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
- 5The 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
- 6Only 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
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.
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.
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.
You are modelling sequences today. Recurrence, attention, or a state-space model?
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
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
- Origin. Elman 1990, Finding Structure in Time. The paper that gave us the term "simple recurrent network".
- Vanishing gradient. Hochreiter 1991 thesis; Bengio, Simard, Frasconi 1994 Learning long-term dependencies with gradient descent is difficult; Pascanu, Mikolov, Bengio 2013, arXiv:1211.5063.
- Karpathy blog. The Unreasonable Effectiveness of Recurrent Neural Networks. Still one of the best pedagogical writeups in ML.
- Modern recurrence. Mamba (Gu & Dao 2023); Mamba-2 (Dao & Gu, ICML 2024); Jamba-1.5 (AI21 2024); Griffin / RecurrentGemma (DeepMind 2024); RWKV (Peng et al. 2023) and the newer RWKV-7 "Goose" release notes on rwkv.com.
- Why transformers won (for now). Vaswani et al. 2017, Attention Is All You Need. Read the abstract and Section 4; the parallelism argument is what killed RNNs for language.