GenAI Course · Module 4 · Session 14

Recurrent Neural Networks

⏱ 13 min read🎯 Intermediate🖼 1 diagram🧪 1 live demo🐍 Python code

The networks in Module 3 take a fixed-size input. But sentences have different lengths, and order matters: “not good” is different from “good, not bad”. A Recurrent Neural Network (RNN) reads one word at a time and keeps a running memory. Let us build one, unroll it, and find out why it forgets.

In this session

  • The hidden state: an RNN’s memory
  • Unrolling an RNN over time
  • An RNN from scratch in NumPy, and in PyTorch
  • Backpropagation Through Time (BPTT)
  • Bidirectional RNNs: reading both ways
  • Why RNNs forget long-range information

01The hidden state and unrolling

An RNN cell takes two things at each step: the current word and its own hidden state from the previous step. It mixes them and produces a new hidden state:

hₜ = tanh( xₜ · Wₓ + hₜ₋₁ · Wₕ + b )
“my”RNN cellh1t = 1“upi”RNN cellh2t = 2“payment”RNN cellh3t = 3“failed”RNN cellh4t = 4the hidden state h carries memory forward →same weights in every copy of the cell
An RNN unrolled over time: one cell, reused at every step, passing its hidden state to the next step.
📖

Like reading a story aloud to a friend. You don’t remember every word, but you carry a running summary in your head and update it with each new sentence. The hidden state is that summary.

rnn_numpy.py
import numpy as np
np.random.seed(0)

words = ["my", "upi", "payment", "failed"]
emb = {w: np.random.randn(4) * 0.5 for w in words}      # 4-number word vectors (random for the demo)

H = 3                                                    # hidden state size
Wx, Wh, b = np.random.randn(4, H) * 0.5, np.random.randn(H, H) * 0.5, np.zeros(H)

h = np.zeros(H)                                          # memory starts empty
for t, w in enumerate(words, 1):
    h = np.tanh(emb[w] @ Wx + h @ Wh + b)                # SAME weights at every step
    print(f"t={t} read {w!r:10s} -> hidden state {h.round(2)}")
print("final h summarises the whole sentence; parameters:", Wx.size + Wh.size + b.size)
t=1 read 'my' -> hidden state [-0.03 -0.46 0.57] t=2 read 'upi' -> hidden state [0.74 0.52 0.87] t=3 read 'payment' -> hidden state [-0.11 0.11 0.15] t=4 read 'failed' -> hidden state [ 0.15 -0.3 0.28] final h summarises the whole sentence; parameters: 24

Notice there are only 24 parameters, whatever the sentence length, because the same three weight matrices are reused at every step. “Unrolling” just means drawing that one cell once per time step.

02RNNs in PyTorch, and bidirectional RNNs

PyTorch’s nn.RNN processes a whole batch of sequences. A bidirectional RNN runs one RNN left-to-right and another right-to-left, then joins their outputs, so every word sees both its past and its future:

rnn_torch.py
import torch, torch.nn as nn
torch.set_num_threads(1)                    # same results on every run
torch.manual_seed(0)
x = torch.randn(2, 6, 8)                      # batch of 2 sentences, 6 tokens, 8 numbers per token

rnn = nn.RNN(input_size=8, hidden_size=16, batch_first=True)
out, h_n = rnn(x)
print("uni   outputs:", tuple(out.shape), " final hidden:", tuple(h_n.shape))

birnn = nn.RNN(input_size=8, hidden_size=16, batch_first=True, bidirectional=True)
out, h_n = birnn(x)
print("bi    outputs:", tuple(out.shape), " final hidden:", tuple(h_n.shape), " <- forward + backward")
uni outputs: (2, 6, 16) final hidden: (1, 2, 16) bi outputs: (2, 6, 32) final hidden: (2, 2, 16) <- forward + backward
Demo · Uni- or bidirectional?

03Backpropagation Through Time (BPTT)

To train an RNN we unroll it over the sequence and run normal backpropagation (Session 11) through the unrolled graph. This is Backpropagation Through Time. Because the same weights appear at every step, their gradients from all steps are added up.

The catch: the gradient that reaches early words has been multiplied by the recurrent weights and the tanh derivative once per step. We measure how much the first token still affects the last output:

bptt_rnn.py
import torch, torch.nn as nn
torch.set_num_threads(1)                    # same results on every run
torch.manual_seed(0); rnn = nn.RNN(8, 32, batch_first=True)

for T in [5, 20, 50, 100]:
    torch.manual_seed(0)
    x = torch.randn(1, T, 8, requires_grad=True)
    out, _ = rnn(x)
    out[0, -1].sum().backward()                     # loss uses only the last step
    print(f"length {T:3d}: gradient reaching token 1 = {x.grad[0, 0].abs().mean().item():.0e}")
length 5: gradient reaching token 1 = 5e-02 length 20: gradient reaching token 1 = 4e-06 length 50: gradient reaching token 1 = 3e-14 length 100: gradient reaching token 1 = 4e-28
This is the vanishing gradient problem over time. After ~20 words the first word has almost no effect on learning. So a plain RNN struggles with “The loan that the customer from Pune applied for last March … was approved”. LSTMs and GRUs (Session 15) were invented to fix this.

04Summary

05Quick quiz

1. What does the hidden state hold?

2. Why does an RNN have the same number of parameters for any sentence length?

3. Which task should NOT use a bidirectional RNN?

4. BPTT stands for…

5. After 50 steps the gradient to token 1 was about 10⁻¹⁴. This shows…

06References

Created by Ajeetkumar