A feedforward network reads a sentence the way a bad student reads a dictionary —
every word in isolation, with no memory of what came before.
An RNN reads it the way a person reads a novel —
each word understood in light of everything already read.
That was the idea. The execution, as we'll see, was flawed in a beautiful way.
01 — Why Feedforward Networks Cannot Handle Sequences
Everything we've built so far — perceptrons, CNNs, basic deep networks — shares one fundamental assumption: inputs are independent of each other. Feed in an image, get a classification. Feed in another image, get another classification. The model has no memory of the first image when it processes the second.
Language violates this assumption completely. The meaning of every word in a sentence depends on the words that came before it. Consider:
- "The bank is steep." → bank = riverbank
- "The bank is closed." → bank = financial institution
- "I went to the bank after the bank." → two different banks in one sentence
A feedforward network processing "bank" as an isolated token cannot know which meaning applies. It would need to see the entire context — but fixed-size inputs mean you must decide in advance how many words of context to include. A 10-word window misses long-range dependencies. A 500-word window is computationally absurd and still fixed. And crucially, the same pattern (subject → verb → object) means something different at position 1 vs position 50 in a sentence.
What's needed is a model that:
The Recurrent Neural Network was designed to satisfy all four requirements. In 1986, Rumelhart, Hinton and Williams formalised the idea; through the 1990s and 2000s it became the dominant architecture for any task involving sequences: language, speech, music, time series. Let's build it from scratch.
02 — The RNN Cell: Architecture and Forward Pass
An RNN processes a sequence one step at a time. At each step t, it receives two inputs: the current token x_t and a hidden state h_{t-1} carrying all information from previous steps. It combines them to produce a new hidden state h_t, which becomes the memory passed to the next step.
h_t — hidden state at step t shape: (hidden_size,)
h_{t-1} — hidden state from step t-1 shape: (hidden_size,)
x_t — input at step t shape: (input_size,)
W_h — recurrent weight matrix shape: (hidden_size, hidden_size)
W_x — input weight matrix shape: (hidden_size, input_size)
b — bias shape: (hidden_size,)
tanh — squashes output to (−1, +1), keeps hidden state bounded
The output at each step can be computed as: y_t = W_y · h_t + b_y. Depending on the task, you might use the output at every step (sequence labelling), only the last step (classification), or generate a new sequence (translation).
The Unrolled View
An RNN is often shown as a loop — one cell that repeatedly applies the same operation. But to understand training, it helps to "unroll" it — draw out each timestep as a separate copy of the cell, all sharing the same weights:
This unrolled view reveals something important: the RNN is essentially a very deep feedforward network where depth = sequence length, and all layers share the same weights. A 100-word sentence is equivalent to a 100-layer network. This is the source of both the RNN's power and its fundamental problem.
Worked Forward Pass
Let's trace through a tiny example manually. Sentence: "I love NLP". hidden_size=2, input_size=2. All weights initialised to 0.5, bias=0, h_0=[0,0].
Step 1: x_1 = "I" = [1, 0]
pre_act = W_h·h_0 + W_x·x_1 = [0,0] + [0.5×1 + 0.5×0, 0.5×1 + 0.5×0] = [0.5, 0.5]
h_1 = tanh([0.5, 0.5]) = [0.462, 0.462]
Step 2: x_2 = "love" = [0, 1]
pre_act = W_h·h_1 + W_x·x_2
= [0.5×0.462+0.5×0.462, ...] + [0.5×0+0.5×1, ...]
= [0.462, 0.462] + [0.5, 0.5] = [0.962, 0.962]
h_2 = tanh([0.962, 0.962]) = [0.745, 0.745]
Step 3: x_3 = "NLP" = [1, 1]
pre_act = [0.5×0.745×2, ...] + [0.5×1+0.5×1, ...]
= [0.745, 0.745] + [1.0, 1.0] = [1.745, 1.745]
h_3 = tanh([1.745, 1.745]) = [0.942, 0.942] ← final hidden state encodes full sentence
After step 3, h_3 = [0.942, 0.942] is the RNN's compressed representation of the entire sequence "I love NLP". It contains — in theory — everything the network learned to remember about those three words. This final hidden state is what you'd feed into a classifier for sentiment analysis, or pass as the initial state of a decoder for translation. The hidden state is the RNN's "working memory."
The critical word above is "in theory." In practice, for long sequences, this memory breaks down in a very specific way — which we'll derive precisely in §04.
03 — RNN Variants: Many-to-One, One-to-Many, Many-to-Many
The RNN architecture is flexible — by choosing which outputs to use, you can handle a wide range of sequence tasks:
| Type | Input → Output | How it works | Applications |
|---|---|---|---|
| Many-to-one | Sequence → single value | Process full sequence, use only final hidden state h_T for prediction | Sentiment analysis, text classification, spam detection |
| One-to-many | Single input → sequence | Single input sets h_0; model generates tokens one by one, each output fed back as next input | Image captioning, music generation from a seed |
| Many-to-many (equal) | Sequence → same-length sequence | Produce an output at every timestep, aligned with each input | POS tagging, NER, per-token classification |
| Many-to-many (seq2seq) | Sequence → different-length sequence | Encoder RNN compresses input into context vector; decoder RNN generates output sequence | Machine translation, summarisation, question answering |
The seq2seq architecture (Sutskever et al., 2014) was the major breakthrough that brought RNNs into production NLP. An encoder RNN reads the source sentence and compresses it into a single fixed-size context vector (the final hidden state). A decoder RNN then uses this context vector to generate the target sentence one token at a time. Google Translate ran on this architecture from 2016–2017.
A standard RNN only has access to past context — when processing word 5, it has seen words 1–4 but not words 6–10. For many tasks (NER, POS tagging), knowing what comes after a word is just as important as what comes before. "Bank" in "I walked to the river bank" needs "river" which comes before, but also benefits from "to deposit money" which might come after.
A Bidirectional RNN (BiRNN) runs two RNNs: one left-to-right and one right-to-left. Their hidden states are concatenated at each step, giving every token access to both past and future context. BiLSTMs (bidirectional LSTMs) were state-of-the-art for NER and POS tagging for years, right until BERT replaced them in 2018.
04 — Backpropagation Through Time: Why Training RNNs Is Hard
Training an RNN means computing gradients and updating the weights W_h, W_x, b. We use backpropagation — but on the unrolled network, gradients flow backwards through time: from the loss at the final step, back through every hidden state, all the way to step 1. This is called Backpropagation Through Time (BPTT).
Let's trace what happens to the gradient as it flows backwards through T steps:
∂L/∂h_1 = ∂L/∂h_T · ∏(t=2 to T) ∂h_t/∂h_{t-1}
Each factor: ∂h_t/∂h_{t-1} = diag(1 − tanh²(·)) · W_h
= diag(sech²(·)) · W_h (tanh derivative)
So: ∂L/∂h_1 ∝ W_h^(T-1) · [product of T-1 tanh derivatives]
The gradient at step 1 involves multiplying W_h by itself T−1 times and multiplying T−1 tanh derivative terms. This is where the problem emerges — and it splits into two distinct failure modes:
Failure Mode 1: Vanishing Gradients
The tanh derivative sech²(x) = 1 − tanh²(x) has a maximum value of 1.0 (at x=0) and shrinks toward 0 as |x| grows. In practice, during training, the pre-activations of tanh rarely sit exactly at 0 — they spread out and the derivatives are typically well below 1.0. If the largest singular value of W_h is also less than 1 (which is common with standard initialisation), then each multiplication by W_h shrinks the gradient further. Over T steps:
By step T−50, the gradient signal is effectively zero. The weights W_x and W_h at early timesteps receive no meaningful update signal — the model simply cannot learn that "The animal at the beginning of the sentence" is the referent for "it" 50 words later. Long-range dependencies are invisible to the gradient.
Failure Mode 2: Exploding Gradients
The opposite problem: if the largest singular value of W_h is greater than 1, the gradient explodes exponentially over T steps. A gradient that starts at magnitude 1.0 can reach 10^15 after 50 steps with ||W_h|| = 1.5. This causes weight updates so large that the model parameters become NaN. Exploding gradients are the more visible failure — training simply diverges. Vanishing gradients are more insidious — training appears to proceed normally, but the model quietly fails to learn long-range patterns.
Vanishing gradients (the slow killer): The model trains fine on short sequences. On long sequences, it learns local patterns (last few words) but ignores distant context. Sentiment analysis "This movie had great visuals, stunning music, beautiful cinematography, but the plot was absolutely terrible" — the model may predict positive sentiment because "great," "stunning," "beautiful" are recent and "terrible" is buried earlier in gradient terms.
Exploding gradients (the loud failure): Loss suddenly spikes to infinity, gradients become NaN, training crashes. Fix: gradient clipping — cap gradient norm at a threshold (typically 1.0 or 5.0) before applying the update. This is still standard practice in any RNN-based training loop today.
05 — What RNNs Are Good At (and Where They Still Win)
It's easy to read the vanishing gradient problem and conclude RNNs are useless. That's wrong. RNNs are excellent for tasks where:
| Task type | Why RNNs work well | Examples |
|---|---|---|
| Short sequences (<50 tokens) | Gradient decay is manageable; local dependencies are sufficient | Short text classification, character-level generation |
| Online / streaming data | Process token by token in real time; no need to see full sequence first | Real-time speech recognition, financial tick data, IoT sensor streams |
| Fixed-length output from variable input | Final hidden state is a natural fixed-size summary | Encoding short documents into vectors |
| Resource-constrained settings | Far fewer parameters than transformers; constant memory per step | Embedded systems, edge devices, mobile applications |
| Time series with local patterns | Sequential structure matches the data's temporal structure naturally | Stock price prediction, weather forecasting (short horizon) |
RNNs are also O(1) in memory per step — the hidden state is a fixed-size vector regardless of how many tokens you've processed. A transformer's attention is O(n²) in sequence length. For extremely long sequences where you need constant memory, RNN-style architectures (and their successors like Mamba) are still competitive.
06 — Partial Fixes: What People Tried Before LSTM
The deep learning community didn't give up on RNNs when the vanishing gradient problem was identified. Several partial solutions emerged:
None of these were satisfying. The community needed an architectural solution — a way to give the network a direct gradient path from later timesteps to earlier ones, bypassing the multiplicative chain that caused the decay. That solution was the LSTM, which we'll cover in full in Day 21.
07 — Code: RNN from Scratch + PyTorch Comparison
Three implementations: a manual RNN cell showing every weight matrix, a full sequence processor with gradient norm tracking to visualise vanishing, and the PyTorch built-in for comparison. Then a demonstration of gradient clipping.
import torch
import torch.nn as nn
import numpy as np
# ─────────────────────────────────────────────────────────────────────────
# 1. RNN Cell from scratch — every operation explicit
# ─────────────────────────────────────────────────────────────────────────
class RNNCellManual(nn.Module):
def __init__(self, input_size, hidden_size):
super().__init__()
self.hidden_size = hidden_size
# Two weight matrices + one bias
self.W_x = nn.Linear(input_size, hidden_size, bias=False) # input → hidden
self.W_h = nn.Linear(hidden_size, hidden_size, bias=False) # hidden → hidden (recurrent)
self.b = nn.Parameter(torch.zeros(hidden_size))
def forward(self, x_t, h_prev):
"""
x_t: (batch, input_size)
h_prev: (batch, hidden_size)
returns h_t: (batch, hidden_size)
"""
pre_act = self.W_x(x_t) + self.W_h(h_prev) + self.b
h_t = torch.tanh(pre_act)
return h_t
# ─────────────────────────────────────────────────────────────────────────
# 2. Full sequence RNN — processes a whole sequence, returns all hidden states
# ─────────────────────────────────────────────────────────────────────────
class SimpleRNN(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super().__init__()
self.cell = RNNCellManual(input_size, hidden_size)
self.fc_out = nn.Linear(hidden_size, output_size)
self.hidden_size = hidden_size
def forward(self, x):
"""x: (batch, seq_len, input_size)"""
B, T, _ = x.shape
h = torch.zeros(B, self.hidden_size) # h_0 initialised to zeros
hidden_states = []
for t in range(T):
h = self.cell(x[:, t, :], h) # step through sequence
hidden_states.append(h)
all_h = torch.stack(hidden_states, dim=1) # (B, T, hidden_size)
out = self.fc_out(all_h[:, -1, :]) # use final hidden state for classification
return out, all_h
# ─────────────────────────────────────────────────────────────────────────
# 3. Vanishing gradient demonstration
# Train on a task that requires remembering the FIRST token after T steps
# ─────────────────────────────────────────────────────────────────────────
def demonstrate_vanishing_gradient(seq_lengths=[5, 20, 50, 100]):
print("Gradient magnitude at step 1 (should be large to learn long-range deps):")
print(f"{'Seq length':>12} | {'||∂L/∂h_1||':>14} | {'Assessment':>20}")
print("-" * 52)
for T in seq_lengths:
model = SimpleRNN(input_size=8, hidden_size=16, output_size=2)
x = torch.randn(4, T, 8) # random sequence
y = torch.randint(0, 2, (4,)) # random labels
out, all_h = model(x)
loss = nn.CrossEntropyLoss()(out, y)
loss.backward()
# Gradient of loss w.r.t. the hidden state at step 1 (earliest step)
grad_h1 = all_h.grad
if grad_h1 is not None:
g_norm = grad_h1[:, 0, :].norm().item()
else:
# Recompute with retain_grad
model.zero_grad()
x2 = torch.randn(4, T, 8, requires_grad=True)
out2, all_h2 = model(x2)
all_h2.retain_grad()
loss2 = nn.CrossEntropyLoss()(out2, y)
loss2.backward()
g_norm = all_h2.grad[:, 0, :].norm().item() if all_h2.grad is not None else 0.0
assessment = "✓ Learnable" if g_norm > 0.01 else ("⚠ Weak" if g_norm > 0.001 else "✗ Vanished")
print(f"{T:>12} | {g_norm:>14.6f} | {assessment:>20}")
# ─────────────────────────────────────────────────────────────────────────
# 4. Gradient clipping — the standard fix for exploding gradients
# ─────────────────────────────────────────────────────────────────────────
def training_step_with_clipping(model, x, y, optimizer, clip_norm=1.0):
optimizer.zero_grad()
out, _ = model(x)
loss = nn.CrossEntropyLoss()(out, y)
loss.backward()
# Check norm BEFORE clipping
total_norm_before = torch.nn.utils.clip_grad_norm_(model.parameters(), float('inf'))
# Clip — scale gradient if norm > clip_norm
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=clip_norm)
# Check norm AFTER clipping
total_norm_after = sum(p.grad.norm()**2 for p in model.parameters() if p.grad is not None)**0.5
optimizer.step()
return loss.item(), total_norm_before.item(), total_norm_after.item()
# ─────────────────────────────────────────────────────────────────────────
# 5. PyTorch built-in RNN vs our manual implementation
# ─────────────────────────────────────────────────────────────────────────
batch, seq_len, input_sz, hidden_sz = 4, 10, 8, 16
x = torch.randn(batch, seq_len, input_sz)
# Our implementation
manual_rnn = SimpleRNN(input_sz, hidden_sz, output_size=2)
out_manual, hidden_manual = manual_rnn(x)
# PyTorch built-in (batch_first=True: (batch, seq, feature) convention)
pytorch_rnn = nn.RNN(input_sz, hidden_sz, batch_first=True, nonlinearity='tanh')
out_pt, h_n_pt = pytorch_rnn(x)
print(f"Manual RNN — all hidden states: {hidden_manual.shape}")
print(f"PyTorch RNN — all hidden states: {out_pt.shape}")
print(f"PyTorch RNN — final hidden state: {h_n_pt.shape}")
print()
# Gradient clipping demo
model = SimpleRNN(input_sz, hidden_sz, 2)
optim = torch.optim.Adam(model.parameters(), lr=1e-3)
x_long = torch.randn(4, 50, input_sz)
y = torch.randint(0, 2, (4,))
loss, norm_before, norm_after = training_step_with_clipping(model, x_long, y, optim, clip_norm=1.0)
print(f"Loss: {loss:.4f} | Grad norm before clip: {norm_before:.4f} | after clip: {norm_after:.4f}")
print(f"Clipping {'applied' if norm_before > 1.0 else 'not needed'} (threshold=1.0)")
# Vanishing gradient demo
print()
demonstrate_vanishing_gradient([5, 20, 50, 100])
Manual RNN — all hidden states: torch.Size([4, 10, 16])
PyTorch RNN — all hidden states: torch.Size([4, 10, 16])
PyTorch RNN — final hidden state: torch.Size([1, 4, 16])
Loss: 0.7112 | Grad norm before clip: 3.8247 | after clip: 0.9998
Clipping applied (threshold=1.0)
Gradient magnitude at step 1 (should be large to learn long-range deps):
Seq length | ||∂L/∂h_1|| | Assessment
──────────────────────────────────────────────────────
5 | 0.184231 | ✓ Learnable
20 | 0.018743 | ✓ Learnable
50 | 0.002108 | ⚠ Weak
100 | 0.000031 | ✗ Vanished
08 — Where This Leads: The Path to LSTM and Attention
The RNN gave us something genuinely new: a model that reads sequences and carries memory. But its fundamental limitation is now mathematically precise — not a vague complaint, but a provable consequence of chaining multiplications through time. The gradient literally cannot reach the beginning of long sequences.
The history of sequence modelling from 1997 to 2017 is essentially the story of solving this one problem:
When you use a transformer today — when you interact with Claude, GPT-4, or LLaMA — you're using the solution to the vanishing gradient problem that stumped the field for 20 years. The residual connections inside every transformer block are a direct, formal answer to the same mathematical problem: they create gradient highways that bypass multiplicative chains, just as the LSTM's cell state did, but more elegantly and at greater scale.
Understanding RNNs is not nostalgia. It's understanding why the transformer was designed the way it was.
09 — A Thought to Sit With
The vanishing gradient is often taught as a flaw — a failure of the RNN. But look at it from another angle: the fact that early timesteps receive smaller gradient updates means the model implicitly weights recent context more than distant context. For many language tasks, this is actually the right inductive bias — the last few words of a sentence are usually more relevant than what was said ten sentences ago.
Is the vanishing gradient truly a bug — or is it an inadvertent feature that worked well enough for short tasks to make RNNs widely useful, while only revealing itself as a genuine limitation at longer contexts? And does this suggest anything about why transformers, which have no such built-in recency bias, sometimes behave surprisingly on very long contexts?
10 — Coming Up Next
Tomorrow — Day 21: GRU & LSTM. We take the RNN's core weakness — the vanishing gradient through recurrent multiplications — and solve it architecturally. The LSTM introduces a cell state and three gates: forget, input, and output. The GRU simplifies this to two gates. We'll build both from scratch, trace gradient flow through the cell state highway, and understand exactly why these architectures lasted 20 years and still appear in production systems today.
11 — Go Deeper
📄 Learning Representations by Back-propagating Errors — Rumelhart, Hinton & Williams (1986)
The paper that introduced backpropagation formally. The foundation of everything in this post.
📄 On the Difficulty of Training Recurrent Neural Networks — Pascanu et al. (2013)
The canonical treatment of vanishing and exploding gradients in RNNs. Section 2 contains the mathematical proof. Section 4 describes gradient clipping — still used today.
▶ Recurrent Neural Networks (RNN) and Long Short-Term Memory (LSTM) — StatQuest
YouTube · 19 min · The clearest visual explanation of how hidden states carry information through time. Watch before tackling the LSTM.
🔗 The Unreasonable Effectiveness of Recurrent Neural Networks — Andrej Karpathy (2015)
The most famous blog post on RNNs. Generates Shakespeare, Linux kernel code, and LaTeX papers character by character. Still a joy to read — and reveals what RNNs are genuinely good at.
📄 Sequence to Sequence Learning with Neural Networks — Sutskever et al. (2014)
The seq2seq paper. Introduced the encoder-decoder architecture. Google Translate ran on this. Section 2 is the whole architecture in two pages.
- Why RNNs: feedforward networks treat inputs as independent and require fixed-size inputs — both fatal for language. RNNs process tokens sequentially, maintain a running hidden state, handle variable-length sequences, and share weights across all positions.
- The RNN cell: h_t = tanh(W_h·h_{t-1} + W_x·x_t + b). At each step, current input x_t and previous hidden state h_{t-1} are combined and squashed by tanh to produce new hidden state h_t. Same weights W_h, W_x, b used at every step.
- Unrolled view: an RNN processing T tokens is equivalent to a T-layer deep network with shared weights. A 100-word sentence = a 100-layer network. This depth is both the source of power and the source of the gradient problem.
- Task variants: many-to-one (sentiment classification — use final h_T), one-to-many (image captioning), many-to-many equal (NER, POS tagging), many-to-many seq2seq (translation — encoder compresses to context vector, decoder generates).
- BPTT: backpropagation through time — gradients flow backwards through every timestep of the unrolled network. Gradient at step 1 involves multiplying W_h and tanh derivatives T−1 times.
- Vanishing gradients: if ||W_h|| < 1 and tanh derivatives < 1 (both typical), the product W_h^(T-1) · [tanh terms] → 0 exponentially. At sequence length 100, the gradient at step 1 is ~0. Early tokens cannot be learned from — long-range dependencies are invisible.
- Exploding gradients: if ||W_h|| > 1, gradients grow exponentially, causing training to crash with NaN loss. Fixed by gradient clipping: rescale gradient vector when its norm exceeds threshold τ. Does not fix vanishing gradients.
- RNNs still win: short sequences (<50 tokens), streaming/online processing, resource-constrained environments (constant O(1) memory per step vs transformer's O(n²)), and time series with strong local patterns.
- Partial fixes: gradient clipping (exploding only), truncated BPTT (caps depth, limits learning range), orthogonal W_h initialisation (prevents W_h contribution to decay), ReLU activation (no shrinkage from activation, but tends to explode). None are satisfying — architectural solution needed.
- The path forward: LSTM (1997) adds a cell state with gates to create a gradient highway. GRU (2014) simplifies to two gates. Attention (2015) lets decoders look at all encoder states directly. Transformer (2017) eliminates recurrence entirely — solving the problem architecturally.
- What is the vanishing gradient problem in RNNs?
During BPTT, gradients are computed by multiplying the recurrent weight matrix W_h and tanh derivatives at each step. If these values are less than 1 (typical), the product decays exponentially with sequence length. For a 100-step sequence, the gradient at step 1 may be 10^-5 times the gradient at step 100 — effectively zero. The model cannot learn that early tokens matter, making long-range dependencies unlearnable. - What is gradient clipping and when do you use it?
Gradient clipping rescales the gradient vector when its global norm exceeds a threshold (typically 1.0–5.0): g = g × (τ / ||g||). It prevents exploding gradients — catastrophic training crashes where weights become NaN. It's applied after loss.backward() and before optimizer.step() in any training loop involving RNNs. It does not fix vanishing gradients. - What is the seq2seq architecture and what is its main limitation?
Seq2seq uses an encoder RNN to read the full input sequence and compress it into a single fixed-size context vector (the encoder's final hidden state). A decoder RNN then generates the output sequence conditioned on this vector. The limitation: the entire input must be compressed into one fixed-size vector regardless of input length. For long sentences, information is lost. This bottleneck was the motivation for the attention mechanism. - When would you still choose an RNN over a transformer in 2026?
Four scenarios: (1) streaming/online applications where you process tokens one at a time in real time — RNNs are naturally causal with O(1) per-step computation; (2) very short sequences where transformer overhead isn't justified; (3) resource-constrained edge devices where the transformer's memory and compute requirements are prohibitive; (4) time series forecasting with strong local temporal patterns where the sequential inductive bias helps.
🔗 The Unreasonable Effectiveness of Recurrent Neural Networks — Karpathy (2015)
The most fun you'll have reading about RNNs. Generates Shakespeare, Linux kernel, and LaTeX with character-level RNNs. Read this to understand what RNNs are genuinely good at.
📄 On the Difficulty of Training Recurrent Neural Networks — Pascanu et al. (2013)
The canonical mathematical treatment of vanishing and exploding gradients. Section 2 is the proof; Section 4 is gradient clipping.
📄 Sequence to Sequence Learning with Neural Networks — Sutskever et al. (2014)
The seq2seq paper. Google Translate ran on this architecture. Section 2 is the full architecture in two readable pages.
▶ RNN and LSTM — StatQuest with Josh Starmer
YouTube · 19 min · Best visual explanation of hidden states carrying information through time. The bridge into Day 21.