πŸ“š Study Notes / Home / Neural Nets / Session 12
Session 12 Β· Sequence Modeling β€” RNNs

Teaching a network to remember: how RNNs read things in order

Until now our networks looked at one thing at a time β€” one image, one fixed row of features. But the world is full of things that come in order: words in a sentence, beats in a song, the daily price of a stock. This session is about giving a network a memory so it can read a sequence step by step and carry what it saw forward. We assume you've studied none of this before β€” every topic starts with a tiny "explain like I'm 5" story, then we slowly go deeper with real math, code, and diagrams.

⏱ 20 min readπŸ“– 5 topics

1 Why sequences need a new model


Explain like I'm 5

Imagine I read you a story one word at a time, but I make you forget every word the instant after you hear it. By the end you'd have no idea what happened β€” you can't understand a story if you can't remember the words that came before. That's exactly the problem with the networks we've built so far: they look at one snapshot and have no memory. A Recurrent Neural Network is a network that's allowed to remember as it goes.

So far in this course we built two big kinds of network. A Multi-Layer Perceptron (MLP) takes a fixed list of numbers and produces an answer. A Convolutional Neural Network (CNN) (Sessions 8–11) takes a fixed-size image and produces an answer. Both share two limits that turn out to matter a lot:

  • They expect a fixed-size input. An MLP built for 10 numbers cannot read 7 numbers or 3,000 numbers. But a sentence can be 3 words or 300.
  • They have no memory of order. Each input is processed on its own, from scratch. Nothing from the previous input carries over.

Why "order matters" is the whole game

A sequence is just data where the order of the items carries meaning. Shuffle it and you destroy the meaning. Three huge examples:

DomainThe sequence is…Why order matters
Text / languageA list of words (or tokens) in order"dog bites man" vs "man bites dog" β€” same words, opposite meaning.
Time seriesA value sampled over time (stock price, temperature, sensor)Tomorrow's value depends on today's and yesterday's, in that order.
AudioA waveform sampled thousands of times per secondThe same sounds in a different order are a different word entirely.
Why an MLP can't just "read a sentence"

Suppose you wanted to feed the sentence "the food was not good" into an MLP. You'd have to glue all the words into one fixed-length block of numbers. But:

  • A different sentence has a different length β€” the MLP's input size is fixed, so it breaks.
  • Even if you padded everything to the same length, the MLP has no notion that word 5 ("good") is being flipped by word 4 ("not"). It just sees a bag of numbers in fixed slots.

The word "not" needs to change how a later word is understood. That requires carrying information forward through the sequence β€” which is memory.

The core new idea: process step by step, and keep a memory

Instead of swallowing the whole input at once, a Recurrent Neural Network (RNN) reads a sequence one element at a time. After each step it updates a little internal summary of "everything I've seen so far," then carries that summary into the next step. That carried-forward summary is called the hidden state, and it is the network's memory.

πŸ“₯
Step 1
read "the", update memory
β†’
πŸ“₯
Step 2
read "food" + memory
β†’
πŸ“₯
Step 3
read "was" + memory
β†’
🧠
…
memory keeps growing
The big idea

An RNN handles any-length sequences by reusing the same small network at every step, and it remembers context by passing a hidden state from each step to the next. "Same network, applied repeatedly, with a memory" β€” that is the whole secret.

Where this is heading

Plain RNNs (this session) are the foundation. They struggle to remember things from long ago β€” a problem we'll diagnose in Topic 4 and cure in Session 13 with LSTMs and GRUs. The attention-based Transformer (the engine behind modern language models) grew out of fixing these same limits.

Recap Sequences (text, time series, audio) carry meaning in their order. MLPs and CNNs expect fixed-size inputs and have no memory, so they can't track how earlier items affect later ones. RNNs fix this by reading one step at a time and carrying a hidden state (a memory) forward through the sequence.

2 The RNN cell β€” hidden state as memory


Explain like I'm 5

Picture a person reading a book out loud while keeping a tiny notepad. After every sentence they scribble a quick summary on the notepad, then read the next sentence together with a glance at the notepad, and update the summary again. The book is the input sequence. The notepad is the hidden state. The same person doing the same routine over and over is the RNN cell.

An RNN is built from one small reusable piece called the RNN cell. The same cell β€” the same set of weights β€” is applied at every time step. At each step it takes two things in and produces an updated memory:

  • xt β€” the input at the current step t (e.g. the current word's vector).
  • ht-1 β€” the hidden state from the previous step (the memory so far).
  • It outputs ht β€” the updated hidden state, which is both the memory passed forward and (optionally) used to make a prediction at this step.

The recurrence formula

Here is the heart of the whole session β€” the recurrence relation. At each step the cell mixes the new input with the old memory and squashes the result:

ht = f( Wx Β· xt  +  Wh Β· ht-1  +  b )

Let's name every piece β€” none of it is mysterious:

SymbolNameJob
x_tinput at step tThe new thing we're reading right now.
h_{t-1}previous hidden stateThe memory of everything read before now.
W_xinput weightsHow much each part of the new input matters. Reused every step.
W_hrecurrent weightsHow the old memory carries forward. Reused every step.
bbiasA learned offset.
factivation (often tanh)Squashes the result into a stable range (e.g. βˆ’1…1).
h_tnew hidden stateThe updated memory, passed to the next step.

If we also want a prediction at a step (say, the next word, or a class), we read it off the hidden state with one more small layer:

yt = softmax( Wy Β· ht  +  by )
Key takeaway: weight sharing

There is only one set of weights (Wx, Wh, b), and it is used at every time step. This is why an RNN handles any length: 5 steps or 5,000 steps, it's the same little cell applied again and again. It also keeps the parameter count small.

Unrolling through time

It helps to unroll the loop: draw the same cell once per time step, side by side, with the hidden state flowing left to right like a conveyor belt. The picture below is the same cell three times β€” not three different cells.

x₁
Cell
hβ‚€ β†’ h₁ β†’ y₁
β†’ h₁ β†’
xβ‚‚
Cell
h₁ β†’ hβ‚‚ β†’ yβ‚‚
β†’ hβ‚‚ β†’
x₃
Cell
hβ‚‚ β†’ h₃ β†’ y₃

The very first hidden state hβ‚€ is usually just a vector of zeros β€” a blank notepad before reading anything.

A tiny RNN cell in code (NumPy)

This is a full forward pass over a sequence, written from scratch so nothing is hidden:

import numpy as np

# Learned parameters (random here; training would set them)
W_x = np.random.randn(hidden_size, input_size) # input  β†’ hidden
W_h = np.random.randn(hidden_size, hidden_size) # hidden β†’ hidden
b   = np.zeros(hidden_size)

def rnn_step(x_t, h_prev):
    # the recurrence: mix new input + old memory, then squash
    return np.tanh(W_x @ x_t + W_h @ h_prev + b)

def rnn_forward(inputs):           # inputs: list of x_t vectors
    h = np.zeros(hidden_size)        # h_0 = blank notepad
    states = []
    for x_t in inputs:           # step through the sequence
        h = rnn_step(x_t, h)         # SAME weights every step
        states.append(h)
    return states                   # h_1, h_2, …, h_T

Notice the for loop: that loop is the recurrence. And notice W_x, W_h, b never change inside the loop β€” that's weight sharing.

The same thing in PyTorch

In practice you don't write the loop by hand. PyTorch's nn.RNN does it for you, efficiently:

import torch.nn as nn

rnn = nn.RNN(input_size=50, hidden_size=128, batch_first=True)
# x shape: (batch, seq_len, input_size)
outputs, h_final = rnn(x)   # outputs = every h_t; h_final = last h_t
Recap The RNN cell applies one shared formula at every step: ht = f(Wxxt + Whht-1 + b). The hidden state is the memory carried forward; the same weights are reused at every step (weight sharing), which is what lets one small cell handle any-length sequences. Unrolling just draws that one cell once per time step.

3 Backprop Through Time (BPTT)


Explain like I'm 5

Imagine a row of friends passing a whisper down a line. At the end, the last friend says the message out loud and it's wrong. To fix it, you walk backwards down the line asking each friend "how much did you mess it up?" β€” all the way back to the first. Then everyone adjusts a little. Training an RNN is exactly that backwards walk through every step in time.

Recall from earlier sessions that we train a network with backpropagation: we measure how wrong the output is (the loss), then push that error backwards through the network to compute a gradient for each weight β€” a number saying "nudge me this way to reduce the loss." Backpropagation Through Time (BPTT) is just ordinary backprop applied to the unrolled RNN.

How BPTT works, step by step

▢️
1. Forward
run all T steps, save every h_t
β†’
πŸ“
2. Loss
compare outputs to targets
β†’
◀️
3. Backward
push error back through every step
β†’
βž•
4. Sum
add up each weight's gradient over all steps
β†’
πŸ”§
5. Update
one gradient-descent step

The twist that makes it "through time": because the same weights are used at every step (weight sharing, Topic 2), each weight gets a gradient contribution from every time step. So we compute the contribution at each step and add them all up before updating.

The big idea

Unroll the RNN into a deep chain (one layer per time step), run normal backprop down that chain, and because every "layer" shares the same weights, sum the gradients across all steps. A 100-step sequence trains like a 100-layer-deep network.

Why the gradient is a sum: a peek at the chain rule

The loss at the final step depends on hT, which depends on hT-1, which depends on hT-2, … all the way back. To get the gradient for Wh, the chain rule forces us to multiply derivatives along the whole chain:

βˆ‚L/βˆ‚Wh  =  Ξ£t  ( βˆ‚L/βˆ‚hT )Β·( βˆ‚hT/βˆ‚ht )Β·( βˆ‚ht/βˆ‚Wh )

where  βˆ‚hT/βˆ‚ht  =  ∏k=t+1..T  βˆ‚hk/βˆ‚hk-1   # a long product!

Keep an eye on that long product term β€” in Topic 4 it's the exact reason RNNs struggle with long-range memory.

Truncated BPTT β€” making it practical

For a very long sequence (say a whole book, or a long audio clip), unrolling all the steps is far too expensive in memory and time. The standard fix is Truncated BPTT: process the sequence in chunks of, say, 35 steps. You still carry the hidden state forward across chunks (so the memory continues), but you only backpropagate the error within each chunk, not all the way to the start.

Truncated BPTT in pseudocode
h = zeros(...)                  # persistent memory across chunks
for chunk in split(sequence, size=35):
    h = h.detach()              # stop gradients flowing past this chunk
    outputs, h = rnn(chunk, h)  # forward: memory continues
    loss = criterion(outputs, targets_for(chunk))
    loss.backward()             # backprop only within the 35 steps
    optimizer.step()
    optimizer.zero_grad()

The crucial line is h.detach(): the hidden state keeps flowing forward as memory, but we cut the gradient so we don't backprop through the entire history.

Watch out

Truncated BPTT is a trade-off. It makes training feasible, but the network can only learn dependencies that fit inside one chunk β€” it can't directly learn "this word matters because of something 500 steps ago" if your chunk is only 35 steps. Choosing the chunk length balances cost against how far back you need the network to learn relationships.

Recap BPTT = ordinary backprop on the unrolled RNN. Because the same weights repeat at every step, each weight's gradient is the sum of its contributions over all steps. For long sequences we use truncated BPTT: carry the hidden state forward but only backprop within fixed-size chunks to keep memory and compute manageable.

4 The vanishing & exploding gradient problem


Explain like I'm 5

Play "telephone" with a long line of people. By the time the whisper reaches the end, it's either faded into nothing ("I couldn't hear it, so I said almost nothing") or it snowballed into a shout. Multiply a number smaller than 1 by itself many times and it vanishes toward zero; multiply a number bigger than 1 and it explodes toward infinity. That repeated multiplication is exactly what happens to an RNN's gradient as it travels back through many time steps.

Remember that long product term from Topic 3: βˆ‚hT/βˆ‚ht = ∏ βˆ‚hk/βˆ‚hk-1. To send error from step T back to a much earlier step t, BPTT multiplies many small matrices together. Multiplying many numbers in a chain has a dramatic effect:

If each factor is…Over many steps the product…NameSymptom
< 1 (e.g. 0.8)shrinks toward 0Vanishing gradientearly steps get almost no learning signal
> 1 (e.g. 1.5)blows up toward ∞Exploding gradientweights jump wildly, loss becomes NaN
Feel the numbers

Say each step multiplies the gradient by 0.8. Over 50 steps: 0.8⁡⁰ β‰ˆ 0.000014 β€” essentially zero. The error signal from the end of the sentence is far too faint to teach the beginning anything. Now say each step multiplies by 1.5: 1.5⁡⁰ β‰ˆ 637,000,000 β€” a gigantic number that makes training blow up. Only a factor very close to 1 survives a long chain, and that's hard to maintain.

Why this breaks long-range dependencies

Language is full of long-range dependencies β€” relationships between words far apart:

The classic example

"I grew up in France … (50 words later) … so I speak fluent French."

To predict "French," the network must remember "France" from 50 steps earlier. But with vanishing gradients, the learning signal connecting "French" back to "France" shrinks to nothing β€” so the plain RNN never learns that long-distance link. It handles short context fine and forgets the distant past.

Two cures

Exploding gradients are the easy one. We use gradient clipping: if the gradient's total size exceeds a threshold, we scale it back down before the update. It caps the step so the weights can't jump off a cliff.

Gradient clipping (one line)
loss.backward()
# rescale gradients so their combined norm ≀ 5.0
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()

Vanishing gradients are the hard one β€” clipping doesn't help a signal that's already faded to zero. Partial mitigations help (using the ReLU family instead of tanh, careful weight initialisation), but the real fix is a better cell design with a protected "memory highway" that lets information flow across many steps without being squashed each time.

Key takeaway β€” this motivates Session 13

This single problem is why plain RNNs are rarely used today for long sequences. The LSTM and GRU cells β€” our entire next session β€” were invented specifically to defeat the vanishing gradient. They add gates that learn when to remember, forget, and update, giving the gradient a clear path across long distances.

Recap BPTT multiplies many factors along a chain. Factors below 1 make the gradient vanish (early steps stop learning β€” kills long-range memory); factors above 1 make it explode (training blows up). Gradient clipping fixes explosion; vanishing needs better cells β€” LSTMs and GRUs in Session 13.

5 Sequence task types


Explain like I'm 5

Think about how many things you read versus how many you say. Sometimes you read a whole paragraph and give a one-word answer ("thumbs up or down?"). Sometimes you say one word and it sparks a whole story. Sometimes you read a sentence and reply with another sentence (translating). RNNs come in these same shapes β€” based on how many things go in vs how many come out.

The flexibility of having a hidden state means we can wire an RNN up in several shapes depending on the task. The shapes are named by inputs-to-outputs:

ShapeInputs β†’ OutputsExample taskIdea
One-to-one1 β†’ 1Plain classification (no sequence)A normal network; the degenerate case.
One-to-many1 β†’ manyImage captioning, music generationOne input seeds a generated sequence.
Many-to-onemany β†’ 1Sentiment classificationRead a whole sequence, emit one answer.
Many-to-many (aligned)many β†’ many (same length)Part-of-speech tagging, naming each frameOne output per input step.
Many-to-many (seq2seq)many β†’ many (different length)Machine translationRead it all, then generate.

Many-to-one β€” sentiment analysis

Read the whole review one word at a time; ignore the intermediate outputs; take only the final hidden state (which has "seen everything") and classify it.

Sentiment in code
review = ["the", "food", "was", "amazing"]
outputs, h_final = rnn(embed(review))   # h_final = summary of WHOLE review
prediction = classifier(h_final)        # β†’ πŸ‘ positive / πŸ‘Ž negative

Only h_final is used β€” that one vector is the network's whole impression of the review.

One-to-many β€” generation

Start from one input (a topic, a starting note, or even nothing) and generate a sequence by feeding each output back in as the next input β€” the autoregressive trick.

Character-by-character text generation
h = seed_state
char = "T"
for _ in range(100):
    out, h = rnn_step(embed(char), h)
    char = sample(out)        # pick next char from the probabilities
    print(char, end="")  # …feed it back in as next input

This is exactly the "one token at a time, feeding itself" loop behind text generators β€” Karpathy's famous demo had a tiny RNN write Shakespeare-flavoured text this way.

Many-to-many (seq2seq) β€” translation

When input and output lengths differ (English "I am hungry" β†’ French "j'ai faim" β€” different word counts), we use the sequence-to-sequence (seq2seq) design: an encoder RNN reads the whole input into a single summary vector (the context vector), then a decoder RNN generates the output sequence from that summary.

πŸ‡¬πŸ‡§
Encoder
reads "I am hungry"
β†’
πŸ“¦
Context
one summary vector
β†’
πŸ‡«πŸ‡·
Decoder
writes "j'ai faim"
A famous bottleneck β€” and where attention came from

Squeezing an entire sentence into one fixed context vector is a tight bottleneck: long inputs get blurred. The fix β€” letting the decoder look back at all the encoder states and focus on the relevant ones β€” is attention, which eventually grew into the Transformer. Plain RNN seq2seq is the direct ancestor of today's translation and chat models.

Matching the shape to the task
  • Spam / sentiment? Many-to-one (read all, one verdict).
  • Write a story / caption an image? One-to-many (seed, then generate).
  • Translate / summarise / chat? Many-to-many seq2seq (encode, then decode).
  • Tag every word with its part of speech? Aligned many-to-many (one out per in).
Recap RNNs flex into shapes by inputs vs outputs: one-to-many (generation), many-to-one (sentiment β€” use the final hidden state), aligned many-to-many (one output per step), and seq2seq (encoder summarises, decoder generates β€” for translation). The seq2seq bottleneck motivated attention and the Transformer.

β˜… Putting it all together


You just learned how a network gains a memory and reads things in order. Here's the one-paragraph story that connects all five topics:

Real data β€” text, time series, audio β€” carries meaning in its order, but MLPs and CNNs are fixed-size and memoryless (Topic 1). The RNN cell solves this by reading one step at a time and carrying a hidden state forward via the shared recurrence ht = f(Wxxt + Whht-1 + b) (Topic 2). We train it by unrolling through time and backpropagating β€” BPTT, summing each shared weight's gradient over all steps, and truncating for long sequences (Topic 3). But that backward chain is a long product, so gradients vanish or explode; clipping fixes explosion, and the vanishing problem motivates the gated cells of Session 13 (Topic 4). Finally, by wiring inputs to outputs differently we get one-to-many, many-to-one, and seq2seq tasks like generation, sentiment, and translation (Topic 5).

Quick self-check

Why can't a plain MLP read a sentence the way an RNN can?

An MLP needs a fixed-size input and processes it all at once with no memory, so it can't handle variable-length sequences or carry information about earlier words forward to influence later ones. An RNN reads step by step and keeps a hidden state (memory).

What is the hidden state, and what does "weight sharing" mean in an RNN?

The hidden state ht is the network's memory β€” a summary of everything seen so far, passed from each step to the next. Weight sharing means the same parameters (Wx, Wh, b) are reused at every time step, which is what lets one cell handle any length.

In BPTT, why is each weight's gradient a sum over time steps?

Because the same weights are used at every step, each step contributes to the loss. The chain rule produces a gradient term for each step, and we add them all up before doing one update.

What causes the vanishing gradient, and which problem does gradient clipping actually solve?

Backprop multiplies many factors along the time chain; factors below 1 shrink the gradient toward zero (vanishing), so early steps stop learning long-range links. Gradient clipping solves the opposite problem β€” exploding gradients β€” by capping the gradient's size. Vanishing needs better cells (LSTM/GRU).

Which RNN shape fits sentiment classification, and which fits translation?

Sentiment is many-to-one: read the whole review, then use the final hidden state to produce one verdict. Translation is many-to-many seq2seq: an encoder reads the input into a context vector, then a decoder generates the output of a different length.

Why does truncated BPTT use detach() on the hidden state?

So the hidden state still flows forward as memory across chunks, but gradients don't flow backward past the current chunk β€” keeping memory and compute manageable on long sequences.

πŸ“š References & Further Reading


Class material

Papers, docs & deep dives