RNNs & Sequence Models
Recurrent networks, LSTMs, and sequence modeling — the pre-Transformer way to handle order. Built from zero: why order changes everything, how a loop with a memory works, why plain RNNs forget, and how gates fixed it.
Start here — why order changes everything
Everything you've built so far took in a fixed lump of data — one image, one row of a spreadsheet — and produced one answer. But an enormous amount of the world doesn't arrive as a lump. It arrives one piece at a time, in an order that matters:
- Words in a sentence: "dog bites man" and "man bites dog" use identical words and mean opposite things.
- A heartbeat trace, an audio waveform, a stock price, a sensor reading — a number per moment.
- Frames of a video: the same frames shuffled are a different event.
A is any data like this. Each position in it is a , usually written with the letter (for time).
A sequence has two awkward properties for a normal neural network: it can be any length (a tweet and a novel are both text), and what came earlier changes the meaning of what comes now. A plain network wants a fixed-size input and has no memory of what it saw a moment ago. This page is the story of one idea that fixes both problems — a network with a loop — and of the twenty years of trouble that loop caused.
It starts from zero and assumes only that you know what a neural network is and roughly how it learns from a gradient (Backpropagation). Flip the Depth switch at the top for the formal equations, derivations, and edge cases — they open automatically once you've cleared the prerequisites, and nothing is ever hidden for good.
Why not just use a normal network?
The obvious hack is: take the last 5 words, glue them into one long input vector, feed that to an ordinary network. This is called an -gram or fixed-window model, and it genuinely works — a bit. But it breaks in three ways:
- The window is a hard wall. Anything older than 5 words is invisible. "The keys I left on the kitchen counter this morning after the argument about the dog are ___" — the word you need ("keys") is far outside any reasonable window.
- Length is fixed. You have to pick the window size up front, and pad or truncate everything to fit.
- Nothing is shared across positions. The network learns "what does a verb in slot 3 mean" separately from "what does a verb in slot 4 mean." It has to relearn the same grammar once per position, wasting both parameters and data.
Give a pair of sentences that use exactly the same words but mean different things, and say which of the three problems above it demonstrates.
Hint: Think about what a bag of words throws away.
The core idea — a loop with a memory
Here is the whole invention, in one sentence: use one small network, feed it the sequence one item at a time, and let it pass a note to itself.
Instead of a big network that swallows the whole sequence at once, you have a small network that handles one item. It reads item 1 and writes a short note summarizing what it has seen. Then it reads item 2 together with its own note, and updates the note. Then item 3, together with the updated note. And so on to the end.
Two things fall out of this for free. The network handles any length (just keep looping), and it uses the same weights at every step (there's only one network), so grammar learned at position 3 works at position 300.
The note is called the , written , and the loop itself — network feeding its own output back into its own input — is the . A network built this way is a .
You're reading a novel, but you're only allowed one sticky note. After each page you rewrite the note to hold everything you'll need going forward. Read a page, glance at the note, rewrite the note, turn the page. By chapter 20 you have never re-read a page — everything you still know about chapters 1–19 lives on that one note.
That note is . Its size is fixed no matter how long the book is, which is exactly the RNN's superpower and, as you'll see shortly, its fatal weakness.
Do one by hand
Let's make everything as small as it can possibly be: the hidden state is a single number, and so is each input. The network has two weights — one for "how much of my old note do I keep" () and one for "how much does the new input matter" () — and it squashes the result with so the note can never blow up past .
Set , , and start with an empty note, . The rule at each step is:
new note = tanh( 0.5 × old note + 1 × this step's input )
Feed it a single "ping" at the first step and then silence: .
Read what happened. The ping arrived, the note jumped to 0.762 — and then, with no new input at all, the memory of it halved at every step. Six steps later it's practically gone.
That halving is not a bug in my arithmetic. It's doing exactly what it says: keep half the note each step. Multiply by a half enough times and you have nothing.
Here is that fade, plotted, next to the same network with a stickier memory ( — keep 95% each step). Same single ping at step 1, same silence afterwards:
Run it yourself — the code reproduces the hand calculation exactly, and you can change the weights:
The RNN above uses the same two weights at every one of the six steps. Name one thing that would get worse if it used different weights at each step instead.
Hint: Think about a sentence of a length you never trained on.
Backpropagation through time — how a loop learns
You now have a network with a loop. How do you take a gradient through a loop?
Draw the RNN not as one box with an arrow curling back, but as a row of copies — one per timestep, each handing its note to the next. That picture has no loop in it at all: it's just a very deep feedforward network whose layers happen to share the same weights.
And you already know how to train a deep feedforward network. Run backpropagation through the unrolled row, then add up each weight's gradient across all the copies (because they're all the same weight). That's the entire algorithm.
This is called , and the drawing move is called .
A relay team loses by a hair. Whose fault was it? The last runner's handoff — but also the third runner's pacing, and the first runner's start. The blame flows backwards down the whole chain, getting split at each handoff. BPTT is exactly that: the error at the end is traced back through every timestep, and each step gets its share of the blame.
The catch — and it's the whole rest of this page — is that by the time the blame has been passed back through 50 handoffs, there's usually nothing left of it.
Unrolling a 10,000-token document means storing 10,000 layers of activations before you can take a single step. Nobody does that. In practice you unroll a fixed window (say 128 steps), backpropagate within it, then carry the hidden state forward into the next window without carrying the gradient. This is truncated BPTT: the memory in the forward pass can outlive the window, but the learning signal cannot reach back past it.
Why plain RNNs forget — vanishing and exploding gradients
Take a single number and multiply it by itself over and over — that's what the product above does.
- Slightly less than one: . Half a percent of the signal is left.
- Even less: . That is zero for any practical purpose, and it's below the resolution of ordinary 32-bit arithmetic.
- Slightly more than one: . And — a gradient that will blow your weights to NaN in one step.
So a factor of 0.9 per step means the model cannot learn anything that requires connecting events 50 steps apart, and a factor of 1.1 means training explodes. The only stable value is exactly 1.0, and nothing keeps it there.
That knife-edge is the problem and its twin, the problem — the reason a plain RNN, in practice, has a memory of maybe ten or twenty steps.
See the two failure modes on one chart. The vertical axis is logarithmic, so a straight line is exponential growth or decay:
Your RNN trains fine for a while, then the loss suddenly jumps to NaN in a single step. Vanishing or exploding — and what is the standard one-line fix?
Hint: NaN means a number got too big, not too small.
Why can't you fix vanishing gradients just by raising the learning rate?
LSTM — build a memory you write to on purpose
The plain RNN's flaw is that it overwrites its entire note at every single step. Every memory must survive by being re-copied through a matrix multiply and a squash, thousands of times. Nothing survives that.
The fix, from 1997, is startlingly direct: give the network a separate memory that is left alone by default, and let it learn small, explicit decisions about what to erase, what to write, and what to reveal.
Picture a whiteboard that carries information down the corridor of time, untouched unless someone acts on it. Three taps control it:
- The forget tap decides how much of what's on the board gets wiped.
- The input tap decides how much of the new proposal gets written on.
- The output tap decides how much of the board is shown to the outside world right now.
Each tap is a dial from 0 (fully closed) to 1 (fully open), and — this is the crucial part — the network learns where to set each dial, freshly, at every timestep, based on what it is currently reading. "A new subject just appeared in the sentence? Open the forget tap and wipe the old subject's gender."
The whiteboard is the ; each tap is a . A network built this way is an .
Why gates are made of sigmoids
A gate has to output "how much gets through", so it needs to live strictly between 0 and 1. That is precisely what the sigmoid function does — and it's why every gate in every gated architecture is a sigmoid, while the content being written is a (which spans to , so a write can push a value up or down).
Play with the two curves — set the input low, high, and near zero, and watch the sigmoid act as a soft switch while acts as a signed, squashed value:
Keep everything one-dimensional again. Say the cell state currently holds — the network is remembering something. This step, the network computes three gate values and one candidate:
- forget gate — "keep 90% of what's on the board"
- input gate — "let 20% of the new proposal through"
- candidate — "the new proposal is to push this value down"
- output gate — "reveal 60% of the board to the rest of the network"
Now the two lines that matter:
- Update the board. New cell = (forget × old cell) + (input × candidate)
- Decide what to reveal. Hidden state = output gate × squashed cell
Now compare that first line with the plain RNN. The plain RNN did — the old value went through a matrix and a squash. The LSTM did — the old value was multiplied by a number the network chose and then added to. If the network sets and , the cell state passes through completely unchanged, forever.
That is the entire invention: a path through time along which memory travels by addition rather than by repeated transformation.
Watch the difference over 100 steps. Both start holding the value 1.0 and receive no new input; one is a plain RNN hidden state, the other an LSTM cell state with its forget gate open:
An LSTM's forget gate is stuck near 0 for a particular unit. What does that unit behave like?
GRU — the same trick with fewer moving parts
The LSTM works, but it carries two states ( and ) and four sets of weights. In 2014 a slimmer variant appeared alongside the encoder–decoder architecture: the .
The LSTM asks two separate questions — how much do I erase? and how much do I write? — and lets them disagree (you can keep everything and also write a lot). The GRU says: those are two sides of one decision. It uses a single update gate where "keep of the old" automatically means "write of the new", and it drops the separate cell state, gating the hidden state directly.
Fewer parameters, faster per step, and in practice usually about as good. Which one wins depends on the task; there is no universal answer, so people try both.
| Plain RNN | LSTM | GRU | |
|---|---|---|---|
| States carried | and | ||
| Gates | none | forget, input, output | update, reset |
| Weight sets | 1 | 4 | 3 |
| Useful memory | ~10 steps | hundreds | hundreds |
| Best for | almost nothing in practice | the default when memory matters most | smaller / faster models |
Both the LSTM cell update and the GRU state update fix vanishing gradients the same way. In one sentence, what is the shared trick?
Hint: Look at the operation between the old state and the new contribution.
The shapes sequence problems come in
Once you have a recurrent layer, you can wire it up in several ways depending on what goes in and what comes out:
| Shape | Input → Output | Example |
|---|---|---|
| One-to-many | one item → sequence | image → caption |
| Many-to-one | sequence → one item | review → sentiment score |
| Many-to-many (aligned) | sequence → sequence, same length | tag every word with its part of speech |
| Many-to-many (seq2seq) | sequence → sequence, different length | English sentence → French sentence |
Two more wiring tricks are worth knowing:
- Stacking. Feed one recurrent layer's hidden states into another as inputs. Deeper stacks build more abstract features over time, exactly like stacking convolutions in a CNN. Two to four layers is typical.
- Bidirectional. Run one RNN left-to-right and another right-to-left, then concatenate their states at each position. Now every position sees the whole sequence, past and future. This is superb for understanding tasks (tagging, classification) and impossible for generation — you can't peek at future words you haven't written yet.
Why can a bidirectional RNN not be used for next-word generation?
Seq2seq, the bottleneck, and the road to attention
The great application was translation. Sutskever, Vinyals and Le showed you could stack two LSTMs: an encoder reads the source sentence and compresses it into a single fixed-length vector, and a decoder reads that vector and generates the target sentence one word at a time.
The entire source sentence — every noun, every clause, every subtlety — has to squeeze through one vector before a single output word is produced. That works for a seven-word sentence. For a forty-word sentence it is like being asked to translate a paragraph after hearing it once and being allowed one sticky note. Translation quality fell off sharply as sentences got longer, and everyone could see exactly why.
The fix, in 2015, was to stop forcing everything through one vector. Keep all the encoder's hidden states, and let the decoder, at each output word, compute a set of weights over them and take a weighted average — looking hardest at the source words that matter for the word it's about to produce. Those weights are an , and this mechanism is attention.
Here is what such an alignment looks like. Each row is one output word choosing where to look; brighter means "I am paying attention here". English and Spanish put adjectives on opposite sides of the noun, and the model has to cross over:
Pictures like this are seductive, and they are genuinely useful for debugging. But attention weights are not a guaranteed explanation of a model's reasoning — a high weight means "this vector contributed a lot to this average", which is related to, but not the same as, "this word caused the decision." Treat these maps as evidence, not proof.
Why Transformers replaced RNNs
Attention was invented as a patch for the seq2seq bottleneck. Then came the observation that changed everything: if attention lets any output position look directly at any input position, why keep the recurrence at all? Drop it, and you get the Transformer.
The decisive advantage is not accuracy — it's parallelism. An RNN must compute before before : a sequence of length 1000 is 1000 unavoidably sequential operations, which is a terrible fit for a GPU that wants to do thousands of things at once. A Transformer computes all positions simultaneously. That single property is what made training on trillions of tokens practical, and it is the reason essentially every large language model today is a Transformer rather than an LSTM. Continue that story in Attention & Transformers.
Partly — but not entirely, and it's worth knowing why this page still earns its 300 minutes.
- Recurrence is O(1) memory per step. An RNN's state size doesn't grow with sequence length, while a Transformer's attention cost grows quadratically and its inference cache grows linearly. For very long streams, on-device models, and low-latency audio, that still matters.
- The ideas transfer directly. Gating, additive skip paths, hidden state as memory, and clipping are everywhere in modern architectures — residual connections are the same additive-highway idea that saved the LSTM.
- It's live research. Modern state-space and linear-attention models revisit recurrence explicitly to get sub-quadratic long-context behavior, so understanding what recurrence buys and what it costs is not a museum exercise.
- It's interview-standard. "Why do LSTMs help with vanishing gradients?" is asked constantly, and the honest answer is the additive path — not the vague "because gates."
In real code
In practice you never hand-roll the loop. Here's the PyTorch version — the same architecture, three lines of definition:
import torch
import torch.nn as nn
class SequenceClassifier(nn.Module):
def __init__(self, vocab_size, emb=128, hidden=256, n_classes=2):
super().__init__()
self.embed = nn.Embedding(vocab_size, emb)
# batch_first=True -> tensors are (batch, time, features)
# bidirectional -> reads left-to-right AND right-to-left (fine: we classify, not generate)
self.lstm = nn.LSTM(emb, hidden, num_layers=2, batch_first=True,
bidirectional=True, dropout=0.2)
self.head = nn.Linear(hidden * 2, n_classes) # *2 for the two directions
def forward(self, tokens):
x = self.embed(tokens) # (B, T, emb)
out, (h_n, c_n) = self.lstm(x) # out: every timestep's hidden state
last = out[:, -1, :] # many-to-one: read the final step
return self.head(last)
model = SequenceClassifier(vocab_size=20_000)
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
for tokens, labels in loader:
opt.zero_grad()
loss = nn.functional.cross_entropy(model(tokens), labels)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # never optional for RNNs
opt.step()- Forgetting to clip gradients. With RNNs this is not a nicety; expect NaN without it.
- Padding without masking. Batches need equal lengths, so short sequences get padded — and if you don't tell the model (via
pack_padded_sequenceor a mask), it happily learns from the padding. - Reading the wrong final state. With padding,
out[:, -1, :]is the last padded step, not the last real one. Use the true lengths. - Bidirectional layers in a generative model. It will train beautifully and be useless at generation, because it was quietly reading the future.
- Carrying the hidden state across batches without detaching. The graph grows forever and you run out of memory.
h = h.detach()between windows.
Explain to a friend why a plain RNN forgets, and what an LSTM's forget gate changes — using the sticky-note and whiteboard pictures, no equations. Then say what the plus sign in c_t = f·c_prev + i·c_new is doing that a plain RNN never does. If you stall on that plus sign, that is the exact paragraph to reread.
- A sequence is data where order carries meaning and length varies; plain networks handle neither.
- An RNN is one small network applied at every timestep, carrying a hidden state — a running note — with the same weights shared across all steps.
- Training uses BPTT: unroll the loop into a deep chain, backpropagate, sum each shared weight's gradients.
- Because the chain rule multiplies a near-identical factor once per step, gradients vanish (factor below 1) or explode (above 1). Plain RNNs remember roughly ten steps. Clipping fixes exploding; nothing simple fixes vanishing.
- An LSTM adds a protected cell state edited only by learned gates — forget, input, output. Memory moves forward by multiply-and-add, so the gradient's per-step factor is , a number the network chooses, and hundreds of steps become reachable.
- A GRU merges forget and input into one update gate and drops the separate cell state — fewer parameters, usually comparable results.
- Bidirectional layers see past and future (great for understanding, impossible for generation); stacking adds depth.
- Seq2seq compressed a whole sentence into one vector; attention removed that bottleneck by letting the decoder look at every encoder state — and removing the recurrence entirely gave us the Transformer, whose real win is parallelism.
Practice — and how to make it stick
• Retrieval practice: before you scroll back, try to state the LSTM cell update from memory and say what each of the three gates decides. Pulling it out of your head beats re-reading it.
• Spaced repetition: mark this topic complete and it joins your Review queue, resurfacing right before you'd forget it.
• Interleaving: mix these with problems from Backpropagation and Optimization rather than grinding one topic — messier practice, sturdier memory.
- By hand: carry out three LSTM steps with , , a candidate of , and . Which of the two slots is behaving like long-term memory, and which like a scratchpad?
- From scratch: implement a character-level RNN in NumPy and train it on a page of text, then sample from it. Karpathy's write-up is the canonical walkthrough of exactly this.
- Measure the forgetting: build the copy task — show the model a random 8-token code, then steps of noise, then ask it to reproduce the code. Sweep and plot accuracy for a plain RNN vs an LSTM. You will see the plain RNN's cliff.
- Break it on purpose: train an LSTM with gradient clipping turned off and a high learning rate. Watch the loss go NaN, then turn clipping on and watch it survive.
Start with the lab below — edit and run it right here, and if you get stuck or hit an error, ask Ada on the right: she can see your code and your terminal output.
Next, see what happened when the field dropped the recurrence entirely: Attention & Transformers.