Weeks
Deep Learning and GenAIWeek 7 · RNN, BPTT and LSTM
Week 7 Notes

Deep Learning,
Week 7

Introduction to DL and GenAI, Week 7: sequence models. Why order matters, language modelling and perplexity, the RNN, backpropagation through time with vanishing and exploding gradients, gradient clipping and truncated BPTT, and the LSTM with its gates and gradient highway. Every number was really run, including an honest test of when an LSTM does and does not help.

7 chapters12 worked examples~46 min read
SequencesLanguage modelsPerplexityRNNBPTTGradient clippingLSTMInterview prep

Chapters

  1. 01Sequential DataPart 1
  2. 02Language Modelling and PerplexityPart 2
  3. 03The Recurrent Neural NetworkPart 3
  4. 04Backpropagation Through TimePart 4
  5. 05The LSTMPart 5
  6. 06Interview Question BankInterview
  7. 07Week 7 Summary SheetSummary
01
Part 1

Sequential Data

Images are grids; text, speech, music, stock prices and sensor logs are sequences: ordered lists where the order carries the meaning. "Dog bites man" and "man bites dog" use the same three words and mean opposite things. Weeks 7 and 8 build networks for this kind of data, and this chapter explains why the networks of earlier weeks are the wrong tool.

1.1Three kinds of sequence task

sequence classification: spam or not?x1x2x3x4x5RNNy1sequence generation: continue the textx1RNNy1y2y3y4y5sequence to sequence: English to Hindix1x2x3x4RNNy1y2y3y4order matters: "dog bites man" and "man bites dog" are different sentences
Figure 1. Many inputs to one label, one seed to many outputs, many inputs to many outputs
  • Sequence classification: a whole sequence in, one label out (is this email spam? is this review positive?).
  • Sequence generation: produce a sequence one element at a time (continue a sentence, compose a melody).
  • Sequence to sequence: a sequence in, a sequence out, possibly of a different length (translate English into Hindi; Week 8).

1.2Why an MLP or a CNN is the wrong tool

  • Fixed-size input. An MLP has one input neuron per position, so sequences must all have the same length. Sentences do not.
  • No sharing across positions. The word "cat" at position 1 and at position 100 meets completely different weights, so everything must be learned separately at every position.
  • No memory. The output depends only on the current input window, with no running summary of the past.

A CNN (Week 3) does share weights and can slide over text, but its view is limited to a small window and it has no state carried forward. The three failures show up directly in numbers:

main.py
import torch, torch.nn as nn
torch.manual_seed(0)
vocab = {"dog": 0, "bites": 1, "man": 2}
a, b = ["dog", "bites", "man"], ["man", "bites", "dog"]
bow = lambda s: torch.bincount(torch.tensor([vocab[w] for w in s]), minlength=3)        # bag of words: counts only
print("bag of words  'dog bites man':", bow(a).tolist(), "  'man bites dog':", bow(b).tolist(), " <- identical, order is lost")

emb = nn.Embedding(3, 4)
rnn = nn.RNN(4, 6, batch_first=True)
last = lambda s: rnn(emb(torch.tensor([[vocab[w] for w in s]])))[0][0, -1]
print("RNN final hidden state, same words in a different order differ by:", round((last(a) - last(b)).norm().item(), 3))

# parameters needed to read a sequence of length T with d features per step into h hidden units
d, h = 50, 100
print("\nlength T   fixed-input MLP weights (T*d*h)   RNN weights (d*h + h*h)")
for T in (10, 100, 1000):
    print(f"{T:8d}   {T * d * h:>28,}   {d * h + h * h:>22,}")
print("the MLP needs a fixed T and a separate weight for 'cat' at every position; the RNN reuses one set of weights at every step")
Output
bag of words  'dog bites man': [1, 1, 1]   'man bites dog': [1, 1, 1]  <- identical, order is lost
RNN final hidden state, same words in a different order differ by: 0.997

length T   fixed-input MLP weights (T*d*h)   RNN weights (d*h + h*h)
      10                         50,000                   15,000
     100                        500,000                   15,000
    1000                      5,000,000                   15,000
the MLP needs a fixed T and a separate weight for 'cat' at every position; the RNN reuses one set of weights at every step

A bag of words (counting words, ignoring order) gives [1, 1, 1] for both sentences: all information about who bit whom is gone. A recurrent network reading them in order ends in different final states (they differ by 0.997). And for a sequence of length 1000 with 50 features per step and 100 hidden units, a fixed-input MLP needs 5 million weights against the recurrent network's 15,000, which also does not change when the sequence gets longer. That is the idea of this week: read the sequence one step at a time with the same weights, carrying a memory forward.

1.3Chapter summary

  • Sequence data: order matters. Tasks: classification, generation, sequence to sequence.
  • MLPs/CNNs: fixed input size, no sharing across positions (MLP), no memory.
  • Measured: bag of words cannot tell the two sentences apart; a fixed MLP needs 5,000,000 weights where an RNN needs 15,000.
Next · Part 2Language Modelling and Perplexity
02
Part 2

Language Modelling and Perplexity

The most basic sequence task is language modelling: give a sentence a probability, or, equivalently, predict the next word. It is the task behind autocomplete, speech recognition, translation, and (Weeks 9-12) every large language model.

2.1The chain rule

P(x1,x2,…,xT)=P(x1) P(x2∣x1) P(x3∣x1,x2)⋯P(xT∣x1,…,xT−1)=∏t=1TP(xt∣x<t)P(x_1,x_2,\dots,x_T)=P(x_1)\,P(x_2\mid x_1)\,P(x_3\mid x_1,x_2)\cdots P(x_T\mid x_1,\dots,x_{T-1})=\prod_{t=1}^{T}P(x_t\mid x_{\lt t})

This is exact, not an approximation: the probability of a sentence is the product of "probability of each word given everything before it". A language model is therefore any machine that outputs P(xt∣x<t)P(x_t\mid x_{\lt t}), a distribution over the vocabulary. The difficulty is the conditioning on the whole history.

2.2The Markov assumption and n-grams

The classical shortcut is the Markov assumption: only the last n−1n-1 words matter. A bigram model uses P(xt∣xt−1)P(x_t\mid x_{t-1}), a trigram P(xt∣xt−2,xt−1)P(x_t\mid x_{t-2},x_{t-1}). The probabilities are just counts: P(w∣h)=count(h,w)/count(h)P(w\mid h)=\text{count}(h,w)/\text{count}(h). Build all three from ten tiny sentences and test on three unseen ones:

main.py
import math
from collections import Counter
train = ["the cat sat on the mat", "the dog sat on the rug", "the cat saw the dog", "the dog saw the cat",
         "a cat sat on the rug", "the dog ran to the park", "the cat ran to the mat", "a dog sat on the mat",
         "the cat sat on the rug", "the dog ran on the mat"]
test = ["the dog sat on the mat", "the cat ran to the park", "a cat saw a dog"]
V = sorted({w for s in train + test for w in s.split()} | {"</s>"})
pad = lambda s, n: ["<s>"] * (n - 1) + s.split() + ["</s>"]          # n-1 start symbols, then the words, then an end symbol

def fit(n):
    ctx, full = Counter(), Counter()
    for s in train:
        t = pad(s, n)
        for i in range(n - 1, len(t)):
            ctx[tuple(t[i - n + 1:i])] += 1; full[tuple(t[i - n + 1:i + 1])] += 1
    return ctx, full

def logprob(sent, n, ctx, full, smooth):
    t, lp = pad(sent, n), 0.0
    for i in range(n - 1, len(t)):                                   # P(word | previous n-1 words) = count(history+word) / count(history)
        c = tuple(t[i - n + 1:i])
        num = full[c + (t[i],)] + (1 if smooth else 0); den = ctx[c] + (len(V) if smooth else 0)
        if num == 0: return float("-inf")
        lp += math.log(num / den)
    return lp

n_pred = sum(len(s.split()) + 1 for s in test)                         # predicted tokens in the test set (words + end symbol)
for n, name in ((1, "unigram"), (2, "bigram "), (3, "trigram")):
    ctx, full = fit(n)
    smooth = [logprob(s, n, ctx, full, True) for s in test]
    raw = [math.exp(logprob(s, n, ctx, full, False)) for s in test]
    print(f"{name}: P('the dog sat on the mat') = {math.exp(smooth[0]):.2e}  | perplexity on the 3 test sentences {math.exp(-sum(smooth) / n_pred):5.1f} (add-one smoothing)  | without smoothing the 3 sentences get {[round(r, 5) for r in raw]}")
print("vocabulary", len(V), "-> possible bigrams", len(V) ** 2, ", trigrams", len(V) ** 3, "; the corpus has only", sum(len(s.split()) + 1 for s in train), "word positions")
Output
unigram: P('the dog sat on the mat') = 2.55e-07  | perplexity on the 3 test sentences  11.8 (add-one smoothing)  | without smoothing the 3 sentences get [0.0, 0.0, 0.0]
bigram : P('the dog sat on the mat') = 6.97e-05  | perplexity on the 3 test sentences   5.9 (add-one smoothing)  | without smoothing the 3 sentences get [0.01646, 0.00137, 0.0]
trigram: P('the dog sat on the mat') = 4.27e-05  | perplexity on the 3 test sentences   6.3 (add-one smoothing)  | without smoothing the 3 sentences get [0.04, 0.04, 0.0]
vocabulary 13 -> possible bigrams 169 , trigrams 2197 ; the corpus has only 68 word positions

Read it in three steps. (1) Looking at the previous word helps a lot: perplexity falls from 11.8 (unigram, no context) to 5.9 (bigram). (2) Looking at two previous words does not help here: trigram perplexity is 6.3, slightly worse, because there are 2,197 possible trigrams and only 68 word positions of training text. (3) Without smoothing, the unseen combination in "a cat saw a dog" makes every n-gram model give the whole sentence probability exactly 0, and that is why add-one (or better) smoothing is essential. This is the sparsity problem: the number of possible contexts grows as Vn−1V^{n-1} while the data stays finite, so n-grams cannot look far back, and they treat "cat" and "dog" as unrelated symbols. A neural language model fixes both: it generalises across similar words and can carry a long context in a hidden state.

2.3Perplexity

How good is a language model? Not by accuracy (predicting the exact next word is hopeless for a good model), but by how much probability it gave the words that actually came next. The standard measure is perplexity:

PPL=exp⁡ ⁣(−1T∑t=1Tlog⁡P(xt∣xltt))=exp⁡(cross-entropy)\mathrm{PPL}=\exp\!\Big(-\frac1T\sum_{t=1}^{T}\log P(x_t\mid x_{\\lt t})\Big)=\exp\big(\text{cross-entropy}\big)
main.py
import math, torch
# perplexity = exp(average negative log-likelihood) = exp(cross-entropy)
p_correct = [0.1] * 8                                   # the model gives the true next word probability 0.1 at each of 8 steps
nll = -sum(math.log(p) for p in p_correct) / len(p_correct)
print("average -log P =", round(nll, 3), "-> perplexity", round(math.exp(nll), 2), "(as unsure as a uniform choice among 10 words)")

for k in (2, 10, 1000):
    logits = torch.zeros(5, k)                          # a model that spreads probability evenly over k words
    targets = torch.randint(0, k, (5,))
    ce = torch.nn.functional.cross_entropy(logits, targets)
    print(f"uniform over {k:4d} words: cross-entropy {ce.item():.4f}, exp = {math.exp(ce.item()):.1f}")

# a better model gives the right words more probability
logits = torch.zeros(5, 10); targets = torch.arange(5)
for strength in (0.0, 1.0, 3.0, 6.0):
    l = logits.clone(); l[torch.arange(5), targets] = strength
    print(f"logit boost {strength:3.1f} on the correct word: perplexity {math.exp(torch.nn.functional.cross_entropy(l, targets).item()):.2f}")
print("lower perplexity = better; perplexity 1 would mean the model is certain of every word")
Output
average -log P = 2.303 -> perplexity 10.0 (as unsure as a uniform choice among 10 words)
uniform over    2 words: cross-entropy 0.6931, exp = 2.0
uniform over   10 words: cross-entropy 2.3026, exp = 10.0
uniform over 1000 words: cross-entropy 6.9078, exp = 1000.0
logit boost 0.0 on the correct word: perplexity 10.00
logit boost 1.0 on the correct word: perplexity 4.31
logit boost 3.0 on the correct word: perplexity 1.45
logit boost 6.0 on the correct word: perplexity 1.02
lower perplexity = better; perplexity 1 would mean the model is certain of every word

Read perplexity as the effective number of equally likely choices the model is torn between: a model that gives each correct word probability 0.1 has perplexity 10 ("as uncertain as a uniform choice among 10 words"). The model that spreads probability evenly over kk words has perplexity exactly kk (2, 10, 1000 above). As the correct word gets more probability the perplexity falls to 1.02. Lower is better, and 1 is the floor. Because PyTorch's cross_entropy is exactly −log⁡P-\log P averaged over positions, perplexity is simply exp(loss).

The classic mix-up Students often read perplexity the wrong way round. Lower perplexity = better model. It is also only comparable between models that use the same vocabulary and tokenisation: a character-level model's perplexity (about 1.2 below) cannot be compared with a word-level one's (about 6).

2.4Chapter summary

  • Chain rule: P(x1:T)=∏tP(xt∣x<t)P(x_{1:T})=\prod_tP(x_t\mid x_{\lt t}), exact. Markov/n-gram: keep only the last n−1n-1 words.
  • n-grams hit sparsity: 2,197 possible trigrams against 68 training positions; unsmoothed unseen n-grams give probability 0. Measured perplexity unigram 11.8, bigram 5.9, trigram 6.3.
  • Perplexity =exp⁡(cross-entropy)=\exp(\text{cross-entropy}) = effective number of equally likely choices; lower is better.
Next · Part 3The Recurrent Neural Network
03
Part 3

The Recurrent Neural Network

A recurrent neural network (RNN) answers both problems of the last chapter with one idea: keep a vector hth_t, the hidden state, that is the network's memory of everything read so far, and update it with the same function at every step.

3.1The equations

ht=tanh⁡ ⁣(Wxh xt+Whh ht−1+bh),yt=softmax(Why ht+by),h0=0h_t=\tanh\!\big(W_{xh}\,x_t+W_{hh}\,h_{t-1}+b_h\big),\qquad y_t=\mathrm{softmax}\big(W_{hy}\,h_t+b_y\big),\qquad h_0=\mathbf 0
  • WxhW_{xh} (input to hidden): how much the current input matters.
  • WhhW_{hh} (hidden to hidden): how much the past memory matters. This feedback loop is what makes the network recurrent.
  • WhyW_{hy} (hidden to output): turns the memory into a prediction, e.g. a distribution over the next word.
  • The same Wxh,Whh,WhyW_{xh},W_{hh},W_{hy} are used at every time step: parameter sharing across time.
RNNh feeds backfoldedh1same Wx1y1h2same Wx2y2h3same Wx3y3h4same Wx4y4unfolding: one set of weights reused at every step, so a long sequence is a deep network
Figure 2. The loop (left) and the same network unfolded in time (right)

3.2Unfolding, and a check against PyTorch

Unfold the loop in time and an RNN looks like a very deep feed-forward network with one layer per time step, all layers sharing the same weights. Computing it costs O(T)O(T) for a sequence of length TT. Write the loop yourself and compare it with nn.RNN:

main.py
import torch, torch.nn as nn
torch.manual_seed(0)
d, h, T = 3, 4, 5
rnn = nn.RNN(d, h, batch_first=True)                                  # tanh RNN
x = torch.randn(1, T, d)
Wxh, Whh = rnn.weight_ih_l0, rnn.weight_hh_l0
bh = rnn.bias_ih_l0 + rnn.bias_hh_l0

hs, h_t = [], torch.zeros(h)
for t in range(T):                                                    # h_t = tanh(Wxh x_t + Whh h_{t-1} + b): the SAME weights every step
    h_t = torch.tanh(Wxh @ x[0, t] + Whh @ h_t + bh)
    hs.append(h_t)
by_hand = torch.stack(hs)
out, h_last = rnn(x)
print("hand-written loop matches nn.RNN:", torch.allclose(by_hand, out[0], atol=1e-6))
print("x:", tuple(x.shape), " hidden states:", tuple(out.shape), " Wxh:", tuple(Wxh.shape), " Whh:", tuple(Whh.shape))
n = sum(p.numel() for p in rnn.parameters())
print("parameters:", n, "= h*(d+h) + 2h =", h * (d + h) + 2 * h, "  (independent of the sequence length T =", T, ")")
for T2 in (5, 50, 500):
    print(f"  T = {T2:3d}: the same network reads it, output {tuple(rnn(torch.randn(1, T2, d))[0].shape)}")
Output
hand-written loop matches nn.RNN: True
x: (1, 5, 3)  hidden states: (1, 5, 4)  Wxh: (4, 3)  Whh: (4, 4)
parameters: 36 = h*(d+h) + 2h = 36   (independent of the sequence length T = 5 )
  T =   5: the same network reads it, output (1, 5, 4)
  T =  50: the same network reads it, output (1, 50, 4)
  T = 500: the same network reads it, output (1, 500, 4)

The hand-written loop reproduces PyTorch exactly. The parameter count is h(d+h)+2hh(d+h)+2h (the two bias vectors are how PyTorch splits bhb_h) and does not depend on the length of the sequence: the very same 36 numbers read 5 steps or 500.

3.3Training a language model with an RNN

To turn this into a language model, make yty_t a distribution over the next character or word, and at every position score it with cross-entropy against the actual next token. Below, a small character-level LSTM (the gated RNN of Chapter 5; the training loop is identical for a plain RNN) learns a toy language of sentences like "the cat chased the fish." The toy language is built so that its best possible perplexity can be computed exactly: each sentence hides three independent 3-way choices, so no model can do better than exp⁡(3ln⁡3/avg length)=1.152\exp(3\ln3/\text{avg length})=1.152.

18.606.75251.351001.19400floor 1.152training stepperplexity (log scale)a character-level LSTMon a toy language:uniform guess = 18best possible = 1.152trained model = 1.19
Figure 3. Perplexity (log scale) falls from "uniform guess" towards the floor
main.py
import math, random, torch, torch.nn as nn
torch.manual_seed(0); random.seed(0); torch.set_num_threads(1)
# a toy language: "the <animal> <verb> the <thing>."
animals, verbs, things = ["cat", "dog", "bird"], ["saw", "chased", "ate"], ["fish", "ball", "bone"]
def sentence(): return f"the {random.choice(animals)} {random.choice(verbs)} the {random.choice(things)}. "
text = "".join(sentence() for _ in range(1500))
chars = sorted(set(text)); idx = {c: i for i, c in enumerate(chars)}
data = torch.tensor([idx[c] for c in text])
V = len(chars); print("characters:", V, " corpus length:", len(text))
avg_len = len(text) / 1500
print(f"each sentence hides 3 independent 3-way choices = {3 * math.log(3):.2f} nats over {avg_len:.1f} characters, so NO model can beat perplexity {math.exp(3 * math.log(3) / avg_len):.3f}")

class CharLM(nn.Module):
    def __init__(self):
        super().__init__()
        self.emb = nn.Embedding(V, 16); self.lstm = nn.LSTM(16, 64, batch_first=True); self.out = nn.Linear(64, V)
    def forward(self, x, state=None):
        o, state = self.lstm(self.emb(x), state); return self.out(o), state
model = CharLM(); opt = torch.optim.Adam(model.parameters(), lr=3e-3)
L = 40
def batch(B=64):
    starts = torch.randint(0, len(data) - L - 1, (B,))
    return torch.stack([data[s:s + L] for s in starts]), torch.stack([data[s + 1:s + L + 1] for s in starts])
def perplexity():
    with torch.no_grad():
        x, y = batch(256); return math.exp(nn.functional.cross_entropy(model(x)[0].reshape(-1, V), y.reshape(-1)).item())
print(f"before training: perplexity {perplexity():.1f}  (a uniform guess over {V} characters would be {V})")
for step in range(1, 401):
    x, y = batch()
    loss = nn.functional.cross_entropy(model(x)[0].reshape(-1, V), y.reshape(-1))
    opt.zero_grad(); loss.backward(); nn.utils.clip_grad_norm_(model.parameters(), 1.0); opt.step()
    if step in (25, 100, 400): print(f"step {step:3d}: perplexity {perplexity():.2f}")

# generate: feed the sampled character back in (this is inference: no teacher forcing)
model.eval(); prime, state = ". the ", None
for ch in prime[:-1]: _, state = model(torch.tensor([[idx[ch]]]), state)          # read the prompt
x, out = torch.tensor([[idx[prime[-1]]]]), prime
for _ in range(110):
    logits, state = model(x, state)
    x = torch.multinomial(torch.softmax(logits[0, -1], -1), 1).unsqueeze(0); out += chars[x.item()]
print("sample:", out)
Output
characters: 18  corpus length: 34972
each sentence hides 3 independent 3-way choices = 3.30 nats over 23.3 characters, so NO model can beat perplexity 1.152
before training: perplexity 18.6  (a uniform guess over 18 characters would be 18)
step  25: perplexity 6.75
step 100: perplexity 1.35
step 400: perplexity 1.19
sample: . the cat chased the ball. the bird chased the bone. the cat ate the bone. the bird chased the fish. the bird saw th

Perplexity falls from 18.6 (a random guess among 18 characters) to 6.75 after 25 steps, 1.35 after 100 and 1.19 after 400, close to the theoretical floor 1.152. The sample the trained network writes is grammatical, "the cat chased the ball. the bird saw the bone.", and it chose every word itself. Two things to notice about how this was trained and used:

  • Teacher forcing in training. At each position the network is fed the true previous character and asked to predict the next one, not its own earlier guess. This keeps training stable and fast.
  • No teacher at inference. To generate, the network's own sampled character is fed back in as the next input. A model that was never shown its own mistakes can drift, a mismatch ("exposure bias") that Week 8 returns to.

3.4Chapter summary

  • ht=tanh⁡(Wxhxt+Whhht−1+b)h_t=\tanh(W_{xh}x_t+W_{hh}h_{t-1}+b), yt=softmax(Whyht+by)y_t=\mathrm{softmax}(W_{hy}h_t+b_y); the same weights at every step.
  • Parameters h(d+h)+2hh(d+h)+2h, independent of the sequence length; cost O(T)O(T); unfolding gives a deep network sharing weights.
  • Measured: a character LSTM reached perplexity 1.19 against a true floor of 1.152. Train with teacher forcing; generate by feeding back your own samples.
Next · Part 4Backpropagation Through Time
04
Part 4

Backpropagation Through Time

Training an RNN means computing the gradient of a loss that depends on the weights through every time step. The algorithm is backpropagation applied to the unfolded network, and it comes with two famous troubles.

4.1Backpropagation through time (BPTT)

Because the same WhhW_{hh} is used at every step, its gradient is the sum of the gradients from each step: ∂L∂Whh=∑t∂L∂Whh∣step t\dfrac{\partial L}{\partial W_{hh}}=\sum_t\dfrac{\partial L}{\partial W_{hh}}\Big|_{\text{step }t}. Check it by unfolding explicitly: give every step its own copy of WW, run the same computation, and add up the copies' gradients.

main.py
import torch
torch.manual_seed(0)
h, T = 3, 6
W = torch.randn(h, h) * 0.5; U = torch.randn(h, 2) * 0.5; xs = torch.randn(T, 2); target = torch.randn(h)

def loss_fn(Ws):                                          # Ws: one weight matrix per step (equal values or independent copies)
    state = torch.zeros(h)
    for t in range(T):
        state = torch.tanh(Ws[t] @ state + U @ xs[t])
    return ((state - target) ** 2).sum()

shared = W.clone().requires_grad_()
loss_fn([shared] * T).backward()                          # ONE matrix used at every step (a real RNN)
copies = [W.clone().requires_grad_() for _ in range(T)]   # unfold in time: a separate copy per step
loss_fn(copies).backward()
total = sum(c.grad for c in copies)
print("gradient of the shared W:\n", shared.grad.numpy().round(4))
print("sum over time steps of the per-step gradients:\n", total.numpy().round(4))
print("equal:", torch.allclose(shared.grad, total, atol=1e-6), " -> BPTT = backprop on the unrolled network, then add up the contribution of every step")
print("per-step contributions (norm), step 1 ... step", T, ":", [round(c.grad.norm().item(), 4) for c in copies])
Output
gradient of the shared W:
 [[-0.2379 -0.0027  0.0718]
 [-0.1086  0.0422  0.7295]
 [-0.5028 -0.8557  0.5535]]
sum over time steps of the per-step gradients:
 [[-0.2379 -0.0027  0.0718]
 [-0.1086  0.0422  0.7295]
 [-0.5028 -0.8557  0.5535]]
equal: True  -> BPTT = backprop on the unrolled network, then add up the contribution of every step
per-step contributions (norm), step 1 ... step 6 : [0.0, 0.0208, 0.04, 0.679, 0.6575, 1.1105]

The gradient of the shared matrix equals the sum of the per-step gradients, entry by entry. That is all BPTT is: ordinary backpropagation on the unrolled graph, with the gradients of the shared weights added up. (The first step's contribution is 0.0 because the initial state is zero, so WhhW_{hh} multiplied nothing at that step.) The later steps contribute more here, a first hint of the problem below: the influence of a weight use shrinks with its distance from the loss.

4.2Vanishing and exploding gradients

The gradient that travels from step TT back to step 00 passes through TT copies of the same Jacobian, roughly (Whh⊤ diag(tanh⁡′))T\big(W_{hh}^{\top}\,\mathrm{diag}(\tanh')\big)^{T}. A repeated product behaves like ρT\rho^T, where ρ\rho is the size of the matrix (its largest singular value, or the spectral radius): ρ<1\rho<1 makes it vanish exponentially, ρ>1\rho>1 makes it explode exponentially. To see this cleanly, give the RNN a recurrent matrix that is an orthogonal matrix times ρ\rho, so every singular value equals ρ\rho:

1e-101e01e101e20050100rho = 1.5rho = 1.1rho = 1.0rho = 0.9rho > 1: explodesrho < 1: vanishesnumber of time steps Tgradient reaching step 0
Figure 4. The gradient reaching step 0, on a log scale, for different ρ\rho
main.py
import torch
torch.manual_seed(0)
h = 32
Q, _ = torch.linalg.qr(torch.randn(h, h))                   # an orthogonal matrix: every singular value is exactly 1

def jacobian_norm(rho, T, activation):
    W = rho * Q                                              # all singular values equal rho
    h0 = torch.randn(h, requires_grad=True)
    state = h0
    for _ in range(T):
        state = activation(W @ state)
    state.sum().backward()                                   # gradient of the final state w.r.t. the first
    return h0.grad.norm().item()

print("gradient size reaching step 0 from step T   (linear RNN: exactly rho^T times the starting size)")
print("   rho      T=10        T=50       T=100")
for rho in (0.5, 0.9, 1.0, 1.1, 1.5):
    row = [jacobian_norm(rho, T, lambda z: z) for T in (10, 50, 100)]
    print(f"  {rho:4.1f}   " + "  ".join(f"{v:9.2e}" for v in row))
print("same with tanh (its derivative is at most 1, and tanh also saturates):")
for rho in (0.5, 0.9, 1.5, 3.0):
    row = [jacobian_norm(rho, T, torch.tanh) for T in (10, 50, 100)]
    print(f"  {rho:4.1f}   " + "  ".join(f"{v:9.2e}" for v in row))
Output
gradient size reaching step 0 from step T   (linear RNN: exactly rho^T times the starting size)
   rho      T=10        T=50       T=100
   0.5    5.52e-03   5.02e-15   0.00e+00
   0.9    1.97e+00   2.92e-02   1.50e-04
   1.0    5.66e+00   5.66e+00   5.66e+00
   1.1    1.47e+01   6.64e+02   7.80e+04
   1.5    3.26e+02   3.61e+09   2.30e+18
same with tanh (its derivative is at most 1, and tanh also saturates):
   0.5    4.36e-03   3.37e-15   0.00e+00
   0.9    9.16e-01   1.15e-02   5.31e-05
   1.5    1.00e+01   2.42e+01   1.10e+03
   3.0    4.99e+01   2.55e+07   5.17e+10

For the linear RNN the table is exactly ρT\rho^T times 5.66: with ρ=0.9\rho=0.9 the gradient after 100 steps is 1.5×10−41.5\times10^{-4}; with ρ=1.1\rho=1.1 it is 7.8×1047.8\times10^{4}; with ρ=1.5\rho=1.5 it is 2.3×10182.3\times10^{18}. Only ρ=1\rho=1 exactly stays put (5.66 everywhere), a knife edge no learned matrix will stay on. With tanh the picture is the same but softer: its derivative is at most 1 and shrinks when it saturates, which makes vanishing easier and explosion harder (ρ=1.5\rho=1.5 still grows, ρ=3\rho=3 explodes). The consequence in practice: a plain RNN cannot learn from something that happened 50 or 100 steps ago, because by then the learning signal has either vanished or blown up.

Exploding gradientVanishing gradient
Causeρ>1\rho>1 (large WhhW_{hh} eigenvalues)ρ<1\rho<1 or small tanh⁡′\tanh'
Symptomloss jumps to NaN, huge weight updatesloss plateaus; early steps learn nothing, long-range dependencies lost
Fixgradient clippingLSTM / GRU (Chapter 5), good initialisation

4.3Gradient clipping

Exploding gradients have a cheap, reliable cure. If the gradient's norm exceeds a threshold, rescale it to the threshold, keeping its direction: g←g⋅threshold∥g∥g\leftarrow g\cdot\dfrac{\text{threshold}}{\|g\|} whenever ∥g∥>threshold\|g\|>\text{threshold}. The step stays a sensible size and still points downhill.

main.py
import torch
torch.manual_seed(0)
w = torch.nn.Parameter(torch.randn(3))
# a gradient that exploded
w.grad = torch.tensor([300.0, -400.0, 0.0])
print("gradient norm before clipping:", w.grad.norm().item())
threshold = 5.0
manual = w.grad * (threshold / w.grad.norm()) if w.grad.norm() > threshold else w.grad      # g <- g * threshold / ||g||
total_norm = torch.nn.utils.clip_grad_norm_([w], max_norm=threshold)                       # PyTorch does it in place
print("torch returned the ORIGINAL norm:", total_norm.item(), " new norm:", round(w.grad.norm().item(), 4))
print("matches the formula:", torch.allclose(w.grad, manual), " direction unchanged:", (w.grad / w.grad.norm()).tolist())

# why it matters: a linear recurrence with spectral radius > 1 has an enormous gradient, and one step can wreck the weights
torch.manual_seed(1)
W = (1.3 * torch.linalg.qr(torch.randn(8, 8))[0]).requires_grad_()
x = torch.randn(8)
state = x
for _ in range(40): state = W @ state
state.sum().backward()
print("\n40-step linear RNN with spectral radius 1.3: gradient norm", f"{W.grad.norm().item():.2e}")
torch.nn.utils.clip_grad_norm_([W], max_norm=1.0)
print("after clipping to 1.0:", round(W.grad.norm().item(), 4), "(a sane step, in the same direction)")
Output
gradient norm before clipping: 500.0
torch returned the ORIGINAL norm: 500.0  new norm: 5.0
matches the formula: True  direction unchanged: [0.5999999642372131, -0.800000011920929, 0.0]

40-step linear RNN with spectral radius 1.3: gradient norm 2.60e+06
after clipping to 1.0: 1.0 (a sane step, in the same direction)

A gradient of norm 500 became norm 5 with the direction untouched (0.6, −0.8-0.8), and PyTorch's clip_grad_norm_ matches the formula (it returns the original norm, handy for logging). In the second experiment a 40-step linear RNN with ρ=1.3\rho=1.3 has gradient norm 2.6×1062.6\times10^6, which one update would turn into garbage; clipped, it is 1.0. In code, call it between loss.backward() and optimizer.step(). Clipping fixes only explosion: it cannot bring back a vanished gradient.

4.4Truncated BPTT

For a sequence of length 100,000 (a book) full BPTT would unroll a 100,000-layer network in memory. Truncated BPTT processes the sequence in chunks of kk steps: the hidden state is carried forward across chunks, but it is detached at each boundary so the gradient flows back at most kk steps.

h1h2h3h4h5h6h7h8h9h10h11h12detachdetachgradient reaches only the last blockthe hidden state still flows forward across every cut; only the backward signal stops
Figure 5. The state flows across the cuts; the gradient does not
main.py
import torch, torch.nn as nn
torch.manual_seed(0)
d, h, T, k = 2, 8, 12, 4
rnn = nn.RNN(d, h, batch_first=True)
x = torch.randn(1, T, d, requires_grad=True)

def run(truncate):
    state = torch.zeros(1, 1, h)
    outs = []
    for t in range(T):
        if truncate and t % k == 0:
            state = state.detach()                       # cut the graph: no gradient flows back past this point
        out, state = rnn(x[:, t:t + 1], state)
        outs.append(out)
    return torch.cat(outs, 1)[0, -1].sum()

for truncate in (False, True):
    x.grad = None
    run(truncate).backward()
    g = x.grad[0].norm(dim=1)
    label = "truncated every 4 steps" if truncate else "full BPTT              "
    print(label, " gradient norm reaching input step 0..11:", [f"{v:.1e}" if v > 0 else "0" for v in g.tolist()])
print("truncated BPTT only lets the loss reach the last", k, "steps (here the inputs 8..11): earlier steps get exactly zero gradient")
print("the hidden STATE is still carried forward, so the forward pass still remembers; only the learning signal is cut")
Output
full BPTT                gradient norm reaching input step 0..11: ['1.5e-04', '2.3e-04', '3.0e-04', '5.9e-04', '1.4e-03', '4.2e-03', '4.0e-03', '5.5e-03', '7.3e-03', '7.7e-02', '1.0e-01', '1.0e+00']
truncated every 4 steps  gradient norm reaching input step 0..11: ['0', '0', '0', '0', '0', '0', '0', '0', '7.3e-03', '7.7e-02', '1.0e-01', '1.0e+00']
truncated BPTT only lets the loss reach the last 4 steps (here the inputs 8..11): earlier steps get exactly zero gradient
the hidden STATE is still carried forward, so the forward pass still remembers; only the learning signal is cut

With full BPTT every input step receives some gradient (tiny for the early ones). With truncation every four steps the first 8 inputs get exactly zero and only the last block (inputs 8 to 11) learns from this loss. It is the standard compromise: memory and time stay bounded, at the price that dependencies longer than kk cannot be learned (they can still be used, since the state carries the information forward).

4.5Chapter summary

  • BPTT = backprop on the unrolled network; the gradient of shared weights is the sum over time steps (verified: identical).
  • Gradient over TT steps ∼ρT\sim\rho^T: vanishes for ρ<1\rho<1 (1.5×10−41.5\times10^{-4} at T=100T=100, ρ=0.9\rho=0.9), explodes for ρ>1\rho>1 (2.3×10182.3\times10^{18} at ρ=1.5\rho=1.5).
  • Clip to fix explosion: g←g⋅thr/∥g∥g\leftarrow g\cdot\text{thr}/\|g\|; truncate BPTT (detach every kk steps) to bound cost; gated cells fix vanishing.
Next · Part 5The LSTM
05
Part 5

The LSTM

The previous chapter ended on a problem: a plain RNN rewrites its entire hidden state at every step and multiplies the gradient by WhhW_{hh} each time, so it cannot hold a fact for long. The Long Short-Term Memory (LSTM) network (Hochreiter and Schmidhuber, 1997) fixes this by separating long-term memory from working memory, and giving the network gates to decide what to keep, add and show.

5.1Two states and four blocks

c(t-1)c(t)the cell state: an additive highwayx+fforgetiinputgcandidateooutputxxtanhh(t)c(t) = f*c(t-1) + i*g | h(t) = o*tanh(c(t)) | f, i, o: sigmoid; g: tanhevery gate reads the same concatenation [h(t-1), x(t)]
Figure 6. The cell state runs along the top; gates decide what enters, stays and leaves
  • Cell state ctc_t: the long-term memory, a conveyor belt that runs through time with only small, additive changes.
  • Hidden state hth_t: the short-term, working memory and the cell's output.
ft=σ(Wf[ht−1,xt]+bf)forget gate: how much of the old memory to keepit=σ(Wi[ht−1,xt]+bi)input gate: how much of the new to writegt=tanh⁡(Wg[ht−1,xt]+bg)candidate: what could be writtenct=ft⊙ct−1+it⊙gtcell-state updateot=σ(Wo[ht−1,xt]+bo)output gate: how much of the memory to showht=ot⊙tanh⁡(ct)hidden state\begin{aligned}f_t&=\sigma(W_f[h_{t-1},x_t]+b_f)&&\text{forget gate: how much of the old memory to keep}\\ i_t&=\sigma(W_i[h_{t-1},x_t]+b_i)&&\text{input gate: how much of the new to write}\\ g_t&=\tanh(W_g[h_{t-1},x_t]+b_g)&&\text{candidate: what could be written}\\ c_t&=f_t\odot c_{t-1}+i_t\odot g_t&&\text{cell-state update}\\ o_t&=\sigma(W_o[h_{t-1},x_t]+b_o)&&\text{output gate: how much of the memory to show}\\ h_t&=o_t\odot\tanh(c_t)&&\text{hidden state}\end{aligned}

Memory aid, "FICO": Forget, Input, Candidate, Output. All four read the same concatenation [ht−1,xt][h_{t-1},x_t]. The three gates use the sigmoid (values in 0 to 1, a dimmer switch); the candidate uses tanh (values in −1-1 to 1, the actual content). The gates are modulators: they are multiplied with a state (⊙\odot is element-wise multiplication), they are not outputs themselves. (The source notes call the candidate c~\tilde c; the code and PyTorch call it gg.) Write the cell by hand and compare it with nn.LSTM:

main.py
import torch, torch.nn as nn
torch.manual_seed(0)
d, h, T = 3, 4, 5
lstm = nn.LSTM(d, h, batch_first=True)
x = torch.randn(1, T, d)
W, U, b = lstm.weight_ih_l0, lstm.weight_hh_l0, lstm.bias_ih_l0 + lstm.bias_hh_l0     # the four gates stacked: i, f, g, o
sig = torch.sigmoid

hs, h_t, c_t = [], torch.zeros(h), torch.zeros(h)
for t in range(T):
    z = W @ x[0, t] + U @ h_t + b
    i, f, g, o = sig(z[:h]), sig(z[h:2 * h]), torch.tanh(z[2 * h:3 * h]), sig(z[3 * h:])
    c_t = f * c_t + i * g                  # cell state: forget some of the old, add some of the new  (ADDITIVE)
    h_t = o * torch.tanh(c_t)              # hidden state: a filtered view of the cell
    hs.append(h_t)
print("hand-written LSTM matches nn.LSTM:", torch.allclose(torch.stack(hs), lstm(x)[0][0], atol=1e-6))
print("gate values at the last step: forget", [round(v, 2) for v in f.tolist()], " input", [round(v, 2) for v in i.tolist()], " output", [round(v, 2) for v in o.tolist()])

d, h = 10, 20
count = lambda m: sum(p.numel() for p in m.parameters())
print(f"\nparameters for d={d}, h={h}:  RNN {count(nn.RNN(d, h))},  GRU {count(nn.GRU(d, h))},  LSTM {count(nn.LSTM(d, h))}")
print("RNN = h(d+h)+2h =", h * (d + h) + 2 * h, "; LSTM = 4x that (4 weight blocks: input, forget, candidate, output) =", 4 * (h * (d + h) + 2 * h), "; GRU = 3x =", 3 * (h * (d + h) + 2 * h))
Output
hand-written LSTM matches nn.LSTM: True
gate values at the last step: forget [0.49, 0.48, 0.61, 0.33]  input [0.51, 0.52, 0.65, 0.4]  output [0.61, 0.51, 0.55, 0.51]

parameters for d=10, h=20:  RNN 640,  GRU 1920,  LSTM 2560
RNN = h(d+h)+2h = 640 ; LSTM = 4x that (4 weight blocks: input, forget, candidate, output) = 2560 ; GRU = 3x = 1920

The hand-written cell reproduces PyTorch. Note the gate values: at random initialisation they sit near 0.5, "half open", and they only become meaningful through training. The cell has four times the weights of a plain RNN (four blocks: forget, input, candidate, output), 2,560 against 640 for d=10,h=20d=10,h=20; a GRU (Week 8) has three times.

Intuition for the gates

Reading "John ate food. He was full.": at "He" the forget gate can drop "food" and keep "John", the input gate can write "He = John", and the output gate can reveal what is needed to predict "was full". That is the idea the design is built for. A trained LSTM does not usually have such a neat, human-readable gate for each word; treat the story as motivation, not as something you can read off the weights.

5.2Why it helps: the additive highway

Look at ct=ft⊙ct−1+it⊙gtc_t=f_t\odot c_{t-1}+i_t\odot g_t. The path from ct−1c_{t-1} to ctc_t has derivative ftf_t: no matrix multiplication and no squashing function. Over TT steps the gradient along the cell state is a product of forget gates, ∏ft\prod f_t. If the gates are near 1 the product survives; if they are 0.5 it does not. Measure fTf^T with autograd for a constant forget gate:

1e-301e-201e-101e0050100f = 1f = 0.99f = 0.9f = 0.5gradient to c(0) = f^Tf near 1: survives 100 stepsnumber of steps Tgradient from c(T) back to c(0)
Figure 7. Gradient along the cell state: fTf^T on a log scale
main.py
import torch
# The LSTM cell-state recurrence  c_t = f * c_{t-1} + (new stuff)  has derivative  dc_t/dc_{t-1} = f.
# Over T steps the gradient from c_T back to c_0 is  f^T. Measure it with autograd for a constant forget gate.
def cell_gradient(f, T):
    c0 = torch.tensor(1.0, requires_grad=True)
    c = c0
    for _ in range(T):
        c = f * c + 0.0                       # the additive "input" term does not affect the path back to c_0
    c.backward()
    return c0.grad.item()

print("forget gate   T=10        T=50        T=100       (and f^100 from the formula)")
for f in (0.5, 0.9, 0.99, 1.0):
    print(f"  {f:5.2f}     " + "   ".join(f"{cell_gradient(f, T):9.2e}" for T in (10, 50, 100)) + f"    {f ** 100:9.2e}")
print("with f close to 1 the gradient survives 100 steps; a plain RNN multiplies by W and (1 - h^2) at every step instead")
print("the forget gate is not magic: if it sits at 0.5 the highway is as lossy as a plain RNN (the first row)")
Output
forget gate   T=10        T=50        T=100       (and f^100 from the formula)
   0.50      9.77e-04    8.88e-16    7.89e-31     7.89e-31
   0.90      3.49e-01    5.15e-03    2.66e-05     2.66e-05
   0.99      9.04e-01    6.05e-01    3.66e-01     3.66e-01
   1.00      1.00e+00    1.00e+00    1.00e+00     1.00e+00
with f close to 1 the gradient survives 100 steps; a plain RNN multiplies by W and (1 - h^2) at every step instead
the forget gate is not magic: if it sits at 0.5 the highway is as lossy as a plain RNN (the first row)

With f=0.99f=0.99 the gradient is still 0.37 after 100 steps and with f=1f=1 it is exactly 1; with f=0.5f=0.5 it is 10−3110^{-31}, as bad as a plain RNN. This is the real meaning of "the cell state is a gradient highway" and of the notes' "forget gate near 1 gives an identity connection": the LSTM does not remove the vanishing-gradient problem, it makes a path on which it can be avoided, and the gates have to learn to use it.

5.3An honest test: does a default LSTM already solve it?

Measure the gradient of the last output with respect to the very first input, for four recurrent nets at their default PyTorch initialisation, plus an LSTM whose forget-gate bias is set to 5 (so f≈0.99f\approx0.99 at the start):

main.py
import torch, torch.nn as nn
torch.set_num_threads(1)
def first_input_gradient(kind, T, forget_bias=None, d=8, h=32, seed=0):
    torch.manual_seed(seed)
    net = {"RNN": nn.RNN, "LSTM": nn.LSTM, "GRU": nn.GRU}[kind](d, h, batch_first=True)
    if forget_bias is not None:
        with torch.no_grad(): net.bias_hh_l0[h:2 * h].fill_(forget_bias)           # the forget-gate block of the LSTM bias
    x = torch.randn(1, T, d, requires_grad=True)
    net(x)[0][0, -1].sum().backward()                                              # loss depends on the LAST output only
    return x.grad[0, 0].norm().item()                                              # how much does the FIRST input matter?

print("gradient norm of the last output with respect to the FIRST input (default PyTorch initialisation)")
print("  T      RNN         GRU         LSTM        LSTM, forget bias 5")
for T in (10, 30, 60, 100):
    print(f"{T:4d}   " + "   ".join(f"{first_input_gradient(k, T):9.2e}" for k in ("RNN", "GRU", "LSTM")) + f"   {first_input_gradient('LSTM', T, forget_bias=5.0):9.2e}")
print("at default initialisation NONE of them keeps the gradient alive; the LSTM only does once its forget gate starts near 1")
Output
gradient norm of the last output with respect to the FIRST input (default PyTorch initialisation)
  T      RNN         GRU         LSTM        LSTM, forget bias 5
  10    2.90e-03    2.25e-03    2.20e-03    2.84e-01
  30    8.50e-09    2.26e-07    2.00e-07    2.38e-01
  60    5.86e-17    4.13e-14    4.02e-13    1.17e-01
 100    0.00e+00    3.80e-22    1.12e-21    5.45e-02
at default initialisation NONE of them keeps the gradient alive; the LSTM only does once its forget gate starts near 1

The result is humbling and important. At default initialisation the RNN, GRU and LSTM all lose the gradient (10−2110^{-21} or less at 100 steps; the LSTM is no better than the plain RNN). Only the LSTM with its forget gate pushed toward 1 keeps a usable gradient (0.05 at 100 steps). The forget-gate bias initialisation is part of what makes an LSTM work; PyTorch's default of 0 (so f≈0.5f\approx0.5) is not it.

Does it matter for learning? A pure memory task: a sequence of TT numbers where the first is ±1\pm1 and all the rest are noise, and the label is the sign of the first number. The only way to answer is to remember it for TT steps. Same size networks, 1,500 training steps each, three random seeds:

runs (out of 3) that learned to remember the first numberT = 20T = 40tanh RNNLSTM, default initLSTM, forget bias 3filled = learned (accuracy above 0.9); empty = stuck at chance
Figure 8. Runs that learned to remember (of 3 seeds)
main.py
import torch, torch.nn as nn
torch.set_num_threads(1)

def run(kind, T, seed, forget_bias=None, steps=1500, h=16):
    # the label is the SIGN of the very first number; every later number is noise (a pure long-memory task)
    torch.manual_seed(seed)
    net = nn.RNN(1, h, batch_first=True) if kind == "RNN" else nn.LSTM(1, h, batch_first=True)
    if forget_bias is not None:
        with torch.no_grad(): net.bias_hh_l0[h:2 * h].fill_(forget_bias)
    head = nn.Linear(h, 1)
    params = list(net.parameters()) + list(head.parameters())
    opt = torch.optim.Adam(params, lr=3e-3)
    def batch(B):
        x = torch.randn(B, T, 1) * 0.5
        s = (torch.rand(B) < 0.5).float() * 2 - 1
        x[:, 0, 0] = s
        return x, (s > 0).float()
    for _ in range(steps):
        x, y = batch(64)
        loss = nn.functional.binary_cross_entropy_with_logits(head(net(x)[0][:, -1]).squeeze(1), y)
        opt.zero_grad(); loss.backward(); nn.utils.clip_grad_norm_(params, 1.0); opt.step()
    with torch.no_grad():
        x, y = batch(2000)
        return ((head(net(x)[0][:, -1]).squeeze(1) > 0).float() == y).float().mean().item()

print("accuracy at remembering the first number across T steps (3 seeds each, chance = 0.50)")
print("  model                       T=20                  T=40")
for name, kind, fb in (("tanh RNN", "RNN", None), ("LSTM, default init", "LSTM", None), ("LSTM, forget bias 3", "LSTM", 3.0)):
    cells = []
    for T in (20, 40):
        accs = [run(kind, T, s, fb) for s in (0, 1, 2)]
        cells.append(" ".join(f"{a:.2f}" for a in accs) + f"  ({sum(a > 0.9 for a in accs)}/3 learned)")
    print(f"  {name:24s}  {cells[0]:36s}  {cells[1]}")
Output
accuracy at remembering the first number across T steps (3 seeds each, chance = 0.50)
  model                       T=20                  T=40
  tanh RNN                  1.00 1.00 1.00  (3/3 learned)         0.50 0.48 0.52  (0/3 learned)
  LSTM, default init        0.51 0.50 1.00  (1/3 learned)         0.50 0.49 0.51  (0/3 learned)
  LSTM, forget bias 3       1.00 1.00 1.00  (3/3 learned)         1.00 1.00 1.00  (3/3 learned)

At 20 steps the plain tanh RNN learns it in all three runs, and the default LSTM in just one of three. At 40 steps the plain RNN and the default LSTM both fail (accuracy 0.50, pure chance), while the LSTM with forget bias 3 learns it in every run, at both lengths. Two lessons. The idea behind LSTM is real: when the forget gate starts open, a signal can be carried across 40 steps and learned. But "use an LSTM" is not by itself a cure for long memory; initialisation and training matter, and a default LSTM can do worse than a plain RNN on a short task. (This is one small task with a small network, not a benchmark.)

5.4Chapter summary

  • LSTM: cell state cc (long-term) and hidden state hh (working). Gates f,i,of,i,o use sigmoid and modulate; candidate gg uses tanh. ct=f⊙ct−1+i⊙gc_t=f\odot c_{t-1}+i\odot g, ht=o⊙tanh⁡cth_t=o\odot\tanh c_t.
  • 4 times the parameters of an RNN (2,560 vs 640). The additive path has gradient ∏ft\prod f_t: 0.37 after 100 steps at f=0.99f=0.99, 10−3110^{-31} at f=0.5f=0.5.
  • Measured: at default initialisation LSTM also vanishes; with forget bias 3-5 it learns a 40-step memory task in 3 of 3 runs where RNN and default LSTM fail.
Next · InterviewInterview Question Bank
06
Interview

Interview Question Bank

What is likely to be asked from Week 7, with 30-second answers, deeper explanations, a lecture-or-extra tag on each question, and code you can run.

Week 7 questions are about what a recurrent network is, how it is trained, and why it fails at long range. Each answer starts with a 30-second version. Questions are tagged From the notes or Extra.

1Definition

One clear sentence: what it is.

2Intuition or tiny example

Why it works, in one picture or three lines.

3The failure it prevents

What goes wrong without it.

7.1Interview heat-map: what is most likely

Very likely6 topics
RNN equations"Write the RNN equations and explain each weight."Notes 3
LSTM equations"Write all LSTM equations. What does each gate do?"Notes 5.1
Why LSTM helps with vanishing gradients"Why does LSTM solve the vanishing gradient problem?"Notes 5.1
BPTT, vanishing and exploding gradients"Explain BPTT and why RNN gradients vanish or explode."Notes 4
Gradient clipping"What is gradient clipping and which problem does it solve?"Notes 4
Perplexity"Define perplexity. What does PPL = 10 mean?"Notes 2.3
Likely4 topics
n-gram models"What are the limitations of n-gram language models?"Notes 2.2
Truncated BPTT"What is truncated BPTT and what is its trade-off?"Notes 4
Parameter counts"Count the parameters of an RNN, LSTM and GRU."Notes 5
MLP versus RNN for sequences"Why not use an MLP or a CNN for sequences?"Notes 1
Sometimes2 topics
Teacher forcing"What is teacher forcing?"Notes 3
Forget-gate bias initialisation"Why initialise the LSTM forget-gate bias to 1?"Extra

7.2Language models and sequences

Interview 1Give three reasons an MLP is unsuitable for sequence data.
Week 7LikelyNotes 1From the lecture

Fixed size, no sharing, no memory.

Prints
bag of words  'dog bites man': [1, 1, 1]   'man bites dog': [1, 1, 1]  <- identical, order is lost
RNN final hidden state, same words in a different order differ by: 0.997
Answer
Say this first (30 seconds)

(1) Fixed input size: sentences have variable length. (2) No parameter sharing across positions: "cat" at position 1 and 100 meets different weights, so each position is learned separately (5,000,000 weights for T=1000, d=50, h=100 against the RNN's 15,000). (3) No memory of earlier context beyond the fixed window. A CNN shares weights but still sees only a small window.

Go deeper
  • Bag of words cannot separate "dog bites man" from "man bites dog".
Follow-up they may askCan a CNN capture long-range dependencies?
Interview 2Describe the Markov assumption and the limitations of n-gram models.
Week 7LikelyNotes 2.2From the lecture

Sparsity and short memory.

Prints
unigram: P('the dog sat on the mat') = 2.55e-07  | perplexity on the 3 test sentences  11.8 (add-one smoothing)  | without smoothing the 3 sentences get [0.0, 0.0, 0.0]
bigram : P('the dog sat on the mat') = 6.97e-05  | perplexity on the 3 test sentences   5.9 (add-one smoothing)  | without smoothing the 3 sentences get [0.01646, 0.00137, 0.0]
trigram: P('the dog sat on the mat') = 4.27e-05  | perplexity on the 3 test sentences   6.3 (add-one smoothing)  | without smoothing the 3 sentences get [0.04, 0.04, 0.0]
vocabulary 13 -> possible bigrams 169 , trigrams 2197 ; the corpus has only 68 word positions
Answer
Say this first (30 seconds)

The Markov assumption keeps only the last n−1n-1 words: P(xt∣x<t)≈P(xt∣xt−n+1:t−1)P(x_t\mid x_{\lt t})\approx P(x_t\mid x_{t-n+1:t-1}), estimated from counts. Limits: the number of contexts grows as Vn−1V^{n-1} while data is finite (sparsity; unseen n-grams get probability 0 without smoothing); a short fixed memory; and no notion that "cat" and "dog" are similar. Neural models share statistical strength across similar words and carry a longer context.

Go deeper
  • Measured: bigram 5.9 beat trigram 6.3 on 68 training positions; unsmoothed unseen n-gram gave probability 0.
Follow-up they may askWhat does add-one smoothing do?
Interview 3Define perplexity and interpret a perplexity of 10. Is lower or higher better?
Week 7Very likelyNotes 2.3From the lecture

exp of cross-entropy.

Prints
average -log P = 2.303 -> perplexity 10.0 (as unsure as a uniform choice among 10 words)
uniform over    2 words: cross-entropy 0.6931, exp = 2.0
uniform over   10 words: cross-entropy 2.3026, exp = 10.0
uniform over 1000 words: cross-entropy 6.9078, exp = 1000.0
logit boost 0.0 on the correct word: perplexity 10.00
logit boost 1.0 on the correct word: perplexity 4.31
logit boost 3.0 on the correct word: perplexity 1.45
logit boost 6.0 on the correct word: perplexity 1.02
lower perplexity = better; perplexity 1 would mean the model is certain of every word
Answer
Say this first (30 seconds)

PPL=exp⁡(−1T∑tlog⁡P(xt∣x<t))=exp⁡(cross-entropy)\mathrm{PPL}=\exp\big(-\tfrac1T\sum_t\log P(x_t\mid x_{\lt t})\big)=\exp(\text{cross-entropy}). PPL 10 means the model is as uncertain as if it chose uniformly among 10 words at each position. Lower is better; 1 is perfect. A model that gives every correct word probability 0.1 has −log⁡0.1=2.303-\log0.1=2.303 and PPL e2.303=10e^{2.303}=10.

Go deeper
  • Only comparable across models with the same tokenisation (character vs word perplexities differ in scale).
Follow-up they may askWhy can perplexity never be below 1?
Interview 4What is teacher forcing and when is it used?
Week 7SometimesNotes 3From the lecture

Training only: feed the truth.

Prints
each sentence hides 3 independent 3-way choices = 3.30 nats over 23.3 characters, so NO model can beat perplexity 1.152
before training: perplexity 18.6  (a uniform guess over 18 characters would be 18)
step  25: perplexity 6.75
step 100: perplexity 1.35
step 400: perplexity 1.19
sample: . the cat chased the ball. the bird chased the bone. the cat ate the bone. the bird chased the fish. the bird saw th
Answer
Say this first (30 seconds)

During training, the input at step tt is the true previous token, not the model's own prediction. It makes training fast and stable. At inference there is no ground truth, so the model's own output is fed back. The mismatch between the two (exposure bias) can let errors compound. It is never used at inference.

Go deeper
  • In the character LM: trained on true text, sampled by feeding back its own characters.
Follow-up they may askWhat is scheduled sampling?

7.3The RNN and its training

Interview 5Write the equations of a simple RNN and explain Wxh,Whh,WhyW_{xh},W_{hh},W_{hy}. What is shared?
Week 7Very likelyNotes 3From the lecture

The same three matrices at every step.

Prints
hand-written loop matches nn.RNN: True
x: (1, 5, 3)  hidden states: (1, 5, 4)  Wxh: (4, 3)  Whh: (4, 4)
parameters: 36 = h*(d+h) + 2h = 36   (independent of the sequence length T = 5 )
  T =   5: the same network reads it, output (1, 5, 4)
  T =  50: the same network reads it, output (1, 50, 4)
  T = 500: the same network reads it, output (1, 500, 4)
Answer
Say this first (30 seconds)

ht=tanh⁡(Wxhxt+Whhht−1+bh)h_t=\tanh(W_{xh}x_t+W_{hh}h_{t-1}+b_h) and yt=softmax(Whyht+by)y_t=\mathrm{softmax}(W_{hy}h_t+b_y), h0=0h_0=0. WxhW_{xh}: how much the current input matters; WhhW_{hh}: how much the previous hidden state (memory) matters; WhyW_{hy}: hidden to output. The same weights are used at every time step (parameter sharing across time), so the parameter count h(d+h)+2hh(d+h)+2h does not depend on the sequence length.

Go deeper
  • Verified against nn.RNN: 36 parameters for d=3, h=4, for T = 5, 50 or 500.
Follow-up they may askWhy is the cost O(T)O(T) and why can it not be parallelised over time?
Interview 6Explain backpropagation through time, and why gradients vanish or explode in an RNN.
Week 7Very likelyNotes 4From the lecture

Backprop on the unrolled net; a repeated product ρT\rho^T.

Prints
 [-0.1086  0.0422  0.7295]
 [-0.5028 -0.8557  0.5535]]
Prints
gradient size reaching step 0 from step T   (linear RNN: exactly rho^T times the starting size)
   rho      T=10        T=50       T=100
   0.5    5.52e-03   5.02e-15   0.00e+00
   0.9    1.97e+00   2.92e-02   1.50e-04
   1.0    5.66e+00   5.66e+00   5.66e+00
   1.1    1.47e+01   6.64e+02   7.80e+04
   1.5    3.26e+02   3.61e+09   2.30e+18
same with tanh (its derivative is at most 1, and tanh also saturates):
   0.5    4.36e-03   3.37e-15   0.00e+00
   0.9    9.16e-01   1.15e-02   5.31e-05
   1.5    1.00e+01   2.42e+01   1.10e+03
   3.0    4.99e+01   2.55e+07   5.17e+10
Answer
Say this first (30 seconds)

BPTT unrolls the RNN over time and runs ordinary backpropagation on the unrolled graph; the gradient of the shared weights is the sum over time steps. The gradient from step TT to step 0 is a product of TT Jacobians ≈(Whh⊤diag(tanh⁡′))T\approx\big(W_{hh}^\top\mathrm{diag}(\tanh')\big)^T, which behaves like ρT\rho^T: vanishes if ρ<1\rho<1 (small eigenvalues or saturated tanh), explodes if ρ>1\rho>1. Fixes: clipping for explosion; LSTM/GRU for vanishing.

Go deeper
  • Measured: ρ=0.9\rho=0.9 gives 1.5e-4 after 100 steps; ρ=1.5\rho=1.5 gives 2.3e18.
Follow-up they may askWhy is ρ=1\rho=1 not a practical solution?
Interview 7What is gradient clipping? Which problem does it fix and which does it not?
Week 7Very likelyNotes 4From the lecture

Rescale, keep the direction.

main.py
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)   # rescale if the total norm exceeds 1
optimizer.step()
Prints
gradient norm before clipping: 500.0
torch returned the ORIGINAL norm: 500.0  new norm: 5.0
matches the formula: True  direction unchanged: [0.5999999642372131, -0.800000011920929, 0.0]
Answer
Say this first (30 seconds)

If ∥g∥>threshold\|g\|>\text{threshold}, replace gg by g⋅threshold/∥g∥g\cdot\text{threshold}/\|g\|: the step is capped but still points the same way (500 becomes 5, direction unchanged). It fixes exploding gradients (a huge step that would destroy the weights or give NaN). It does not fix vanishing gradients: a tiny gradient is not scaled up.

Go deeper
  • Measured: norm 500 to 5; a 2.6e6 gradient to 1.0.
Follow-up they may askClip by norm or clip by value: what is the difference?
Interview 8What is truncated BPTT and what does it cost?
Week 7LikelyNotes 4From the lecture

Cut the gradient, keep the state.

main.py
state = None
for chunk in chunks:                       # k steps at a time
    if state is not None:
        state = tuple(s.detach() for s in state)   # carry the value forward, cut the gradient
    out, state = lstm(chunk, state)
    loss = criterion(out, target); loss.backward(); opt.step(); opt.zero_grad()
Prints
full BPTT                gradient norm reaching input step 0..11: ['1.5e-04', '2.3e-04', '3.0e-04', '5.9e-04', '1.4e-03', '4.2e-03', '4.0e-03', '5.5e-03', '7.3e-03', '7.7e-02', '1.0e-01', '1.0e+00']
truncated every 4 steps  gradient norm reaching input step 0..11: ['0', '0', '0', '0', '0', '0', '0', '0', '7.3e-03', '7.7e-02', '1.0e-01', '1.0e+00']
truncated BPTT only lets the loss reach the last 4 steps (here the inputs 8..11): earlier steps get exactly zero gradient
Answer
Say this first (30 seconds)

Process a long sequence in chunks of kk steps. The hidden state is carried across chunks, but detached at each boundary, so gradients flow back at most kk steps. Memory and time per update stay bounded. Cost: dependencies longer than kk cannot be learned (they can still be used, as the state carries them).

Go deeper
  • Measured with k=4 over 12 steps: inputs 0 to 7 got exactly zero gradient.
Follow-up they may askIs the final hidden state of one chunk used as the start of the next?

7.4The LSTM

Interview 9Write the LSTM equations and explain each gate.
Week 7Very likelyNotes 5.1From the lecture

FICO, with a cell-state update in the middle.

Prints
hand-written LSTM matches nn.LSTM: True
gate values at the last step: forget [0.49, 0.48, 0.61, 0.33]  input [0.51, 0.52, 0.65, 0.4]  output [0.61, 0.51, 0.55, 0.51]

parameters for d=10, h=20:  RNN 640,  GRU 1920,  LSTM 2560
RNN = h(d+h)+2h = 640 ; LSTM = 4x that (4 weight blocks: input, forget, candidate, output) = 2560 ; GRU = 3x = 1920
Answer
Say this first (30 seconds)

With z=[ht−1,xt]z=[h_{t-1},x_t]: forget f=σ(Wfz+bf)f=\sigma(W_fz+b_f) (how much old memory to keep); input i=σ(Wiz+bi)i=\sigma(W_iz+b_i) (how much new to write); candidate g=tanh⁡(Wgz+bg)g=\tanh(W_gz+b_g) (what to write); cell ct=f⊙ct−1+i⊙gc_t=f\odot c_{t-1}+i\odot g; output o=σ(Woz+bo)o=\sigma(W_oz+b_o); hidden ht=o⊙tanh⁡cth_t=o\odot\tanh c_t. Gates are sigmoid (0 to 1) modulators, not outputs. cc is long-term, hh is working memory.

Go deeper
  • Four weight blocks: 4x the parameters of an RNN (2,560 vs 640 at d=10, h=20).
Follow-up they may askWhy is the candidate tanh and the gates sigmoid?
Interview 10Why does the LSTM mitigate the vanishing gradient problem? Is it guaranteed?
Week 7Very likelyNotes 5.1From the lecture

An additive path whose derivative is the forget gate.

Prints
forget gate   T=10        T=50        T=100       (and f^100 from the formula)
   0.50      9.77e-04    8.88e-16    7.89e-31     7.89e-31
   0.90      3.49e-01    5.15e-03    2.66e-05     2.66e-05
   0.99      9.04e-01    6.05e-01    3.66e-01     3.66e-01
   1.00      1.00e+00    1.00e+00    1.00e+00     1.00e+00
with f close to 1 the gradient survives 100 steps; a plain RNN multiplies by W and (1 - h^2) at every step instead
the forget gate is not magic: if it sits at 0.5 the highway is as lossy as a plain RNN (the first row)
Prints
gradient norm of the last output with respect to the FIRST input (default PyTorch initialisation)
  T      RNN         GRU         LSTM        LSTM, forget bias 5
  10    2.90e-03    2.25e-03    2.20e-03    2.84e-01
  30    8.50e-09    2.26e-07    2.00e-07    2.38e-01
  60    5.86e-17    4.13e-14    4.02e-13    1.17e-01
 100    0.00e+00    3.80e-22    1.12e-21    5.45e-02
at default initialisation NONE of them keeps the gradient alive; the LSTM only does once its forget gate starts near 1
Answer
Say this first (30 seconds)

The cell state is updated additively, ct=f⊙ct−1+i⊙gc_t=f\odot c_{t-1}+i\odot g, so ∂ct/∂ct−1=f\partial c_t/\partial c_{t-1}=f: no repeated multiplication by WhhW_{hh} and no squashing. The gradient over TT steps is ∏ft\prod f_t, which stays large if the forget gates are near 1 (0.37 after 100 steps at f=0.99f=0.99). It is not guaranteed: with f=0.5f=0.5 it is 10−3110^{-31}. The gates must learn to stay open, and a forget-gate bias of 1 to 5 helps them start so.

Go deeper
  • Measured at default initialisation: RNN, GRU and LSTM all lost the gradient (about 1e-21 by 100 steps); the LSTM with forget bias 5 kept 0.05.
Follow-up they may askWhat is the exam-safe wording for the notes' "identity connection"?
Interview 11How many parameters do an RNN, LSTM and GRU layer have for input size dd and hidden size hh?
Week 7LikelyNotes 5Extra

k×(h(d+h)+2h)k\times(h(d+h)+2h), with k=1,4,3k=1,4,3.

Prints
parameters for d=10, h=20:  RNN 640,  GRU 1920,  LSTM 2560
RNN = h(d+h)+2h = 640 ; LSTM = 4x that (4 weight blocks: input, forget, candidate, output) = 2560 ; GRU = 3x = 1920
Answer
Say this first (30 seconds)

RNN: h(d+h)+2hh(d+h)+2h (input weights hdhd, recurrent weights h2h^2, two bias vectors in PyTorch). LSTM: 4 times that (forget, input, candidate, output). GRU: 3 times (reset, update, candidate). For d=10,h=20d=10,h=20: 640, 2,560, 1,920. None depend on the sequence length.

Go deeper
  • Counted with PyTorch.
Follow-up they may askHow many parameters in a bidirectional LSTM?
Interview 12Why do practitioners initialise the forget-gate bias to a positive value such as 1?
Week 7SometimesNotes -Extra

Start with the highway open.

Prints
accuracy at remembering the first number across T steps (3 seeds each, chance = 0.50)
  model                       T=20                  T=40
  tanh RNN                  1.00 1.00 1.00  (3/3 learned)         0.50 0.48 0.52  (0/3 learned)
  LSTM, default init        0.51 0.50 1.00  (1/3 learned)         0.50 0.49 0.51  (0/3 learned)
  LSTM, forget bias 3       1.00 1.00 1.00  (3/3 learned)         1.00 1.00 1.00  (3/3 learned)
Answer
Say this first (30 seconds)

With bias 0 the forget gate starts at σ(0)=0.5\sigma(0)=0.5 and the cell-state gradient shrinks like 0.5T0.5^T. A positive bias starts the gate near 1 (σ(5)=0.993\sigma(5)=0.993), so memory and gradient flow from the first step and the network can discover long dependencies. Measured: with bias 3 the LSTM learned a 40-step memory task in 3 of 3 runs, the default in 0 of 3.

Go deeper
  • PyTorch stores the forget-gate bias in bias_hh[h:2h] and bias_ih[h:2h].
Follow-up they may askDoes a very large bias have a downside?
Next · SummaryWeek 7 Summary Sheet
07
Summary

Week 7 Summary Sheet

Week 7 on one page: chapter glance, every equation, the pairs to tell apart, a problem-and-fix table, a checklist and corrections to the source notes.

7.1Week 7 at a glance

C1Sequences

Order matters. Classification, generation, seq2seq. MLPs: fixed size, no sharing, no memory (5,000,000 vs 15,000 weights).

C2Language modelling

Chain rule; Markov/n-grams (sparsity); perplexity =exp⁡(CE)=\exp(\text{CE}), lower is better.

C3RNN

ht=tanh⁡(Wxhxt+Whhht−1+b)h_t=\tanh(W_{xh}x_t+W_{hh}h_{t-1}+b); same weights every step; parameters h(d+h)+2hh(d+h)+2h.

C4BPTT

Unroll, sum gradients. Gradient ∼ρT\sim\rho^T: vanish or explode. Clip; truncate.

C5LSTM

ct=f⊙ct−1+i⊙gc_t=f\odot c_{t-1}+i\odot g, ht=o⊙tanh⁡cth_t=o\odot\tanh c_t. Additive highway: gradient ∏f\prod f. Needs forget bias near 1.

7.2Every equation

ModelEquationsNote
RNNht=tanh⁡(Wxhxt+Whhht−1+bh)h_t=\tanh(W_{xh}x_t+W_{hh}h_{t-1}+b_h), yt=softmax(Whyht+by)y_t=\mathrm{softmax}(W_{hy}h_t+b_y)shared across time
Forgetf=σ(Wf[h,x]+bf)f=\sigma(W_f[h,x]+b_f)keep old memory
Inputi=σ(Wi[h,x]+bi)i=\sigma(W_i[h,x]+b_i)write new
Candidateg=tanh⁡(Wg[h,x]+bg)g=\tanh(W_g[h,x]+b_g)content to write
Cellct=f⊙ct−1+i⊙gc_t=f\odot c_{t-1}+i\odot gadditive
Outputo=σ(Wo[h,x]+bo)o=\sigma(W_o[h,x]+b_o), ht=o⊙tanh⁡cth_t=o\odot\tanh c_twhat to show
Perplexityexp⁡(−1T∑log⁡P)\exp(-\tfrac1T\sum\log P)lower is better
Clippingg←g thr/∥g∥g\leftarrow g\,\text{thr}/\|g\|if ∥g∥>thr\|g\|>\text{thr}

7.3Key terms

C1-C2
sequence
data where order matters.
Markov assumption
only the last n−1n-1 words.
perplexity
effective number of choices.
smoothing
no zero probabilities.
C3-C4
hidden state
the memory hth_t.
unfolding
one layer per time step.
BPTT
backprop through the unrolled net.
clipping
cap the gradient norm.
C5
cell state
long-term memory ctc_t.
gate
sigmoid modulator.
highway
additive gradient path.
forget bias
init near 1.

7.4The pairs you must tell apart

explodingρ>1\rho>1: clip
VS
vanishingρ<1\rho<1: gated cells
cell state cclong-term memory
VS
hidden state hhworking memory, output
gatessigmoid modulators
VS
candidatetanh content
full BPTTall steps
VS
truncated BPTTlast kk steps
trainingteacher forcing
VS
inferencefeed back own output
n-gramcounts, short fixed context
VS
RNN LMlearned, carries a state
perplexitylower = better
VS
accuracyhigher = better

7.5Last-minute checklist

C1-C2
  • order matters; MLP fails: fixed size, no sharing, no memory
  • chain rule is exact; Markov is an approximation
  • perplexity = exp(cross-entropy), lower better
C3-C4
  • RNN weights shared over time
  • BPTT sums gradients over time
  • ρT\rho^T: vanish/explode; clip for explode
C5
  • FICO; gates sigmoid, candidate tanh
  • ct=f⊙ct−1+i⊙gc_t=f\odot c_{t-1}+i\odot g
  • LSTM = 4x RNN parameters
  • highway only if forget gate near 1

7.6Corrections and additions to the source notes

WhereThe source saysBetter
LSTM and vanishing"forget gate ≈1\approx1 gives an identity connection, so the gradient does not vanish"true only if the gate is near 1; at default initialisation the LSTM lost the gradient just like an RNN (1e-21 at 100 steps); init the forget bias to 1-5
LSTM vs GRU"translation with long sentences: LSTM preferred"a common heuristic, not a rule; LSTM and GRU are usually comparable and the better one is found by trying both
Gate wording"candidate c~\tilde c"PyTorch and most code call it gg; same thing
Gate intuition"forget gate forgets food, remembers John"a motivating story; trained gates are rarely that neat and cannot be read off the weights
Gradient clippinglisted as a fix for RNN gradient problemsfixes explosion only; it cannot restore a vanished gradient
n-gram orderhigher nn means a better modelonly with enough data: trigram 6.3 was worse than bigram 5.9 on a tiny corpus
Revision overview
Sequencesorder matters
MLP fails
language models
Language modelchain rule
n-gram sparsity
perplexity
RNNshared weights
unfolding
teacher forcing
Week 7Recurrent networks
BPTTsum over time
rho^T
clip, truncate
LSTMf, i, g, o
cell state
additive highway
In practiceforget bias 1-5
4x parameters
test, do not assume
Revision: all of Week 7 on one page
Back to top