Knowledge BaseArchitectures

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.

intermediate#rnn#lstm#gru#sequence

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 tt (for time).

The one thing this whole page is about

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.

How to read this page

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 nn-gram or fixed-window model, and it genuinely works — a bit. But it breaks in three ways:

  1. 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.
  2. Length is fixed. You have to pick the window size up front, and pad or truncate everything to fit.
  3. 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.
Try to recall

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.

One network, applied over and over, carrying a note

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 hth_t, and the loop itself — network feeding its own output back into its own input — is the . A network built this way is a .

Think of it like reading a book with one sticky note:

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 hth_t. 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" (whw_h) and one for "how much does the new input matter" (wxw_x) — and it squashes the result with tanh\tanh so the note can never blow up past ±1\pm 1.

Six timesteps of a one-number RNN

Set wh=0.5w_h = 0.5, wx=1w_x = 1, and start with an empty note, h0=0h_0 = 0. 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: x=[1,0,0,0,0,0]x = [1, 0, 0, 0, 0, 0].

  1. h1=tanh(0.5×0+1)=tanh(1)=0.762h_1 = \tanh(0.5 \times 0 + 1) = \tanh(1) = 0.762
  2. h2=tanh(0.5×0.762+0)=tanh(0.381)=0.363h_2 = \tanh(0.5 \times 0.762 + 0) = \tanh(0.381) = 0.363
  3. h3=tanh(0.5×0.363)=tanh(0.182)=0.180h_3 = \tanh(0.5 \times 0.363) = \tanh(0.182) = 0.180
  4. h4=tanh(0.5×0.180)=tanh(0.090)=0.090h_4 = \tanh(0.5 \times 0.180) = \tanh(0.090) = 0.090
  5. h5=tanh(0.5×0.090)=tanh(0.045)=0.045h_5 = \tanh(0.5 \times 0.090) = \tanh(0.045) = 0.045
  6. h6=tanh(0.5×0.045)=tanh(0.022)=0.022h_6 = \tanh(0.5 \times 0.045) = \tanh(0.022) = 0.022

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 wh=0.5w_h = 0.5 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 (wh=0.95w_h = 0.95 — keep 95% each step). Same single ping at step 1, same silence afterwards:

How long one input echoes in the hidden state— interactive, drag & zoom
Loading chart…
One number of input at step 1, nothing afterwards, and we watch what is left of it. The recurrent weight w_h is the whole story: at 0.5 the memory halves every step and is gone within six; at 0.95 it lingers. Nobody hand-picks this number — the network learns it, and how long the model can remember is decided by what it learns. Drag and zoom to explore.

Run it yourself — the code reproduces the hand calculation exactly, and you can change the weights:

Python · runs in your browser
What this does: Runs the six-step hand calculation from the worked example — one number of hidden state, one weight for memory and one for input — and prints each step so you can check it against the numbers above. Then it reruns with a stickier memory weight so you can see the echo last longer. This tiny loop is a complete RNN; a real one just swaps the numbers for vectors and matrices.
Try to recall

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?

Unroll the loop and it becomes an ordinary deep network

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 .

Think of it like tracing a mistake back through a relay race:

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.

Truncated BPTT — the practical version

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

The whole problem, in two multiplications

Take a single number and multiply it by itself over and over — that's what the product above does.

  • Slightly less than one: 0.950=0.0050.9^{50} = 0.005. Half a percent of the signal is left.
  • Even less: 0.5508.9×10160.5^{50} \approx 8.9 \times 10^{-16}. That is zero for any practical purpose, and it's below the resolution of ordinary 32-bit arithmetic.
  • Slightly more than one: 1.1501171.1^{50} \approx 117. And 1.5506.4×1081.5^{50} \approx 6.4 \times 10^{8} — 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:

What repeated multiplication does to a gradient— interactive, drag & zoom
Loading chart…
Each line is one number raised to a power — exactly what the chain rule does when the same factor repeats at every timestep. A factor of 0.9 leaves half a percent of the signal after 50 steps, so the model cannot connect events that far apart. A factor of 1.1 grows 117-fold, and larger values reach NaN. Only exactly 1.0 is stable, and nothing holds it there. Zoom in near the left to see how early the divergence starts.
Python · runs in your browser
What this does: Multiplies a single factor by itself over and over — the exact thing the chain rule does when a gradient travels back through many timesteps — and prints what survives after 10, 30 and 50 steps. Watch how 0.9 collapses to almost nothing while 1.1 grows enormous. It also shows why tanh makes vanishing worse: its slope is never above 1, so it can only shrink the factor further.
Try to recall

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.

Think of it like a whiteboard with three taps:

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 ctc_t; 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 tanh\tanh (which spans 1-1 to +1+1, 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 tanh\tanh acts as a signed, squashed value:

Activation Functions— interactive, try itOpen in lab →
f(x) — GELUf′(x) — derivative
The two functions an LSTM is built from. Sigmoid (0 to 1) is the shape of every gate — a soft on/off switch, so multiplying by it keeps a fraction of a signal. Tanh (-1 to +1) is the shape of the content written into memory, so an edit can be positive or negative. Notice that both flatten far from zero: that flattening is the saturation that kills gradients in a plain RNN, and the reason the LSTM routes its long-term memory around them.
One LSTM step, by hand

Keep everything one-dimensional again. Say the cell state currently holds ct1=0.8c_{t-1} = 0.8 — the network is remembering something. This step, the network computes three gate values and one candidate:

  • forget gate ft=0.9f_t = 0.9 — "keep 90% of what's on the board"
  • input gate it=0.2i_t = 0.2 — "let 20% of the new proposal through"
  • candidate c~t=0.5\tilde{c}_t = -0.5 — "the new proposal is to push this value down"
  • output gate ot=0.6o_t = 0.6 — "reveal 60% of the board to the rest of the network"

Now the two lines that matter:

  1. Update the board. New cell = (forget × old cell) + (input × candidate) =0.9×0.8+0.2×(0.5)=0.720.10=0.62= 0.9 \times 0.8 + 0.2 \times (-0.5) = 0.72 - 0.10 = \mathbf{0.62}
  2. Decide what to reveal. Hidden state = output gate × squashed cell =0.6×tanh(0.62)=0.6×0.551=0.331= 0.6 \times \tanh(0.62) = 0.6 \times 0.551 = \mathbf{0.331}

Now compare that first line with the plain RNN. The plain RNN did ht=tanh(wht1+)h_t = \tanh(w\,h_{t-1} + \dots) — the old value went through a matrix and a squash. The LSTM did ct=0.9ct1+small editc_t = 0.9\,c_{t-1} + \text{small edit} — the old value was multiplied by a number the network chose and then added to. If the network sets ft1f_t \approx 1 and it0i_t \approx 0, 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:

Python · runs in your browser
What this does: Starts a plain RNN and an LSTM cell both holding the value 1.0, then runs 100 steps with no new input, and prints how much of that memory each one still has. The plain RNN squashes its state through tanh every step and loses essentially everything; the LSTM only multiplies its cell state by a forget gate near 1, so most of the memory is still there at step 100. The plot uses a log scale because the two end up orders of magnitude apart.

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 (h\mathbf{h} and c\mathbf{c}) and four sets of weights. In 2014 a slimmer variant appeared alongside the encoder–decoder architecture: the .

One dial instead of two

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 ztz_t where "keep zz of the old" automatically means "write 1z1-z 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 RNNLSTMGRU
States carriedh\mathbf{h}h\mathbf{h} and c\mathbf{c}h\mathbf{h}
Gatesnoneforget, input, outputupdate, reset
Weight sets143
Useful memory~10 stepshundredshundreds
Best foralmost nothing in practicethe default when memory matters mostsmaller / faster models
Try to recall

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:

ShapeInput → OutputExample
One-to-manyone item → sequenceimage → caption
Many-to-onesequence → one itemreview → sentiment score
Many-to-many (aligned)sequence → sequence, same lengthtag every word with its part of speech
Many-to-many (seq2seq)sequence → sequence, different lengthEnglish 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 flaw you can see from a mile away

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:

An attention alignment — the decoder choosing where to look— interactive, drag & zoom
Loading chart…
A schematic alignment for the red house to la casa roja, drawn to show the shape rather than reproduce a specific model run. Notice the crossing: the second output word looks at the third input word, and the third looks at the second, because the adjective moves after the noun. Nothing told the model about adjective order — this pattern emerges from training, which is why attention maps became such a popular way to inspect what a model is doing.
A caution about reading attention maps

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 h1\mathbf{h}_1 before h2\mathbf{h}_2 before h3\mathbf{h}_3: 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.

So is this history?

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:

Python · needs a GPU — run on Colab
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()
Mistakes that bite everyone with RNNs
  • 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_sequence or 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 it yourself

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.

Recap — the key ideas
  • 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 ftf_t, 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

Learn it the way that actually works


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.

  1. By hand: carry out three LSTM steps with f=[0.99,0.1]f = [0.99, 0.1], i=[0.01,0.9]i = [0.01, 0.9], a candidate of [0.5,0.5][0.5, -0.5], and c0=[1,1]c_0 = [1, 1]. Which of the two slots is behaving like long-term memory, and which like a scratchpad?
  2. 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.
  3. Measure the forgetting: build the copy task — show the model a random 8-token code, then NN steps of noise, then ask it to reproduce the code. Sweep NN and plot accuracy for a plain RNN vs an LSTM. You will see the plain RNN's cliff.
  4. 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.

Practice lab
Your task: Find the memory horizon of each architecture. Run the starter as-is to see how many steps each one keeps at least 1 percent of an initial memory. Then do the TODOs: (1) lower the LSTM forget gate toward sigmoid(1.0) and watch its horizon collapse to the same handful of steps a plain RNN manages — proving the gate value, not the label LSTM, is what buys the memory; (2) set the RNN weight w_h to exactly 1.0 and explain why its horizon jumps but training such a network is still a bad idea.
editor
terminal
Press Run (⌘/Ctrl+Enter) to execute.
Ask Ada — she can read your terminal

Next, see what happened when the field dropped the recurrence entirely: Attention & Transformers.

Key papers