1 Why sequences need a new model
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:
| Domain | The sequence is⦠| Why order matters |
|---|---|---|
| Text / language | A list of words (or tokens) in order | "dog bites man" vs "man bites dog" β same words, opposite meaning. |
| Time series | A value sampled over time (stock price, temperature, sensor) | Tomorrow's value depends on today's and yesterday's, in that order. |
| Audio | A waveform sampled thousands of times per second | The same sounds in a different order are a different word entirely. |
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.
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.
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.
2 The RNN cell β hidden state as memory
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:
| Symbol | Name | Job |
|---|---|---|
x_t | input at step t | The new thing we're reading right now. |
h_{t-1} | previous hidden state | The memory of everything read before now. |
W_x | input weights | How much each part of the new input matters. Reused every step. |
W_h | recurrent weights | How the old memory carries forward. Reused every step. |
b | bias | A learned offset. |
f | activation (often tanh) | Squashes the result into a stable range (e.g. β1β¦1). |
h_t | new hidden state | The 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 )
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.
The very first hidden state hβ is usually just a vector of zeros β a blank
notepad before reading anything.
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.
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
3 Backprop Through Time (BPTT)
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
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.
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.
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.
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.
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.
4 The vanishing & exploding gradient problem
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⦠| Name | Symptom |
|---|---|---|---|
| < 1 (e.g. 0.8) | shrinks toward 0 | Vanishing gradient | early steps get almost no learning signal |
| > 1 (e.g. 1.5) | blows up toward β | Exploding gradient | weights jump wildly, loss becomes NaN |
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:
"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.
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.
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.
5 Sequence task types
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:
| Shape | Inputs β Outputs | Example task | Idea |
|---|---|---|---|
| One-to-one | 1 β 1 | Plain classification (no sequence) | A normal network; the degenerate case. |
| One-to-many | 1 β many | Image captioning, music generation | One input seeds a generated sequence. |
| Many-to-one | many β 1 | Sentiment classification | Read a whole sequence, emit one answer. |
| Many-to-many (aligned) | many β many (same length) | Part-of-speech tagging, naming each frame | One output per input step. |
| Many-to-many (seq2seq) | many β many (different length) | Machine translation | Read 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.
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.
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.
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.
- 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).
β 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
- SST Deep Learning handout (Session 12) β your course handout for this session, available via the class portal at priyanshsaxena.com/sst-deep-learning-nn.
Papers, docs & deep dives
- Andrej Karpathy β "The Unreasonable Effectiveness of Recurrent Neural Networks" β the classic, fun blog post that shows char-level RNNs generating text; the best intuition-builder for this whole session.
- Stanford CS231n β course notes (RNN / sequence material) β clear, authoritative lecture notes covering RNNs, BPTT, and sequence tasks with diagrams.
- Jeffrey L. Elman β "Finding Structure in Time" (1990) β the foundational paper introducing the recurrent ("Elman") network and the idea of hidden state as memory.
- Goodfellow, Bengio & Courville β Deep Learning, Ch. 10 "Sequence Modeling" β the rigorous textbook treatment of RNNs, BPTT, and the vanishing/exploding gradient analysis.
- Sutskever, Vinyals & Le β "Sequence to Sequence Learning with Neural Networks" (2014) β the seq2seq paper behind the encoderβdecoder translation design in Topic 5.
- Hochreiter & Schmidhuber β "Long Short-Term Memory" (1997) β the original LSTM paper that fixes vanishing gradients; the bridge into Session 13.