CS 601.471/671  ·  NLP: Self-supervised Models  ·  Fall 2026

Handout — Recurrent Neural Language Models

Session number TBD  ·  Course page

Every model so far has looked at a fixed number of previous tokens: $n-1$ for an n-gram, whatever the window is for a fixed-window neural LM. Both are the same admission — that $p(x_t \mid x_{<t})$ has been replaced by $p(x_t \mid x_{t-k}, \dots, x_{t-1})$ because the real conditional has an argument of unbounded size. A recurrent network is the first architecture in this course that does not make that concession.

Assigned papers
None. This handout is the reading for this session. §8 lists optional depth.
Time
60–80 minutes.
By the end you should be able to
  • write down the recurrence and say what is shared and what is not;
  • explain backpropagation through time as backprop on an unrolled graph, and why it is truncated in practice;
  • say precisely where vanishing and exploding gradients come from, and which one clipping fixes;
  • state the two structural costs that eventually cost RNNs the field;
  • read PyTorch's official word-level LM example and account for every line of its forward and its sampling loop.
Assumes
Handout #3 §1 (LM as a learning problem, the fixed-window model), Handout #4 §2 (batched algebra), and Handout #6 §1–§4 (backprop, autograd, the LM loss in code) throughout.

1What a fixed window cannot do

Handout #3's fixed-window model concatenates the embeddings of the previous $k$ tokens and pushes them through an MLP. It fixed the n-gram model's sparsity problem — similar contexts now share parameters — but it inherited the Markov assumption unchanged, and with it three separate defects.

Information older than $k$ is gone. Not down-weighted, not approximated — structurally unavailable. In "The keys that I left on the kitchen counter yesterday afternoon, next to the mail, are …", the number of the verb is determined by keys, thirteen tokens back. Any $k \le 13$ cannot get this right for the right reason.

Every position gets its own parameters. The input layer is $W \in \mathbb{R}^{d \times kd}$, so the weights that read the token at distance 1 are entirely separate from those that read distance 2. Nothing forces the model to learn "a noun three back" as the same kind of thing as "a noun two back"; it must learn each $k$ times over.

$k$ is a hyperparameter, and the cost of raising it is linear in parameters. Doubling the window doubles the first layer. Handling a document means a window of thousands.

The idea Instead of widening the window, keep a summary of everything seen so far in a fixed-size vector, and update it one token at a time with the same update rule at every step. The context is then unbounded while the parameter count is constant.

2The recurrence

2.1  Three equations

Let $e_t = E\,x_t$ be the embedding of token $t$ (a row lookup, as in Handout #3 §1). The hidden state $h_t \in \mathbb{R}^{d_h}$ evolves as

$$ h_t = \tanh\!\big(W_{hh}\, h_{t-1} + W_{eh}\, e_t + b_h\big), \qquad h_0 = \mathbf{0} $$

and the prediction at each step reads off that state:

$$ z_t = W_{ho}\, h_t + b_o, \qquad p(\cdot \mid x_{<t+1}) = \operatorname{softmax}(z_t) $$

and the training objective is the same one it has been since Handout #2:

$$ \mathcal{L} = -\frac{1}{T}\sum_{t=1}^{T} \log p_\theta\big(x_{t+1} \mid x_{\le t}\big) $$

The parameters are $E, W_{hh}, W_{eh}, W_{ho}, b_h, b_o$ — and that is the entire model, for any sequence length. Compare the fixed-window model, whose first layer grows with $k$.

QuantityShapeDepends on $T$?
$E$$|\Sigma| \times d_e$no
$W_{hh}$$d_h \times d_h$no
$W_{eh}$$d_h \times d_e$no
$W_{ho}$$|\Sigma| \times d_h$no
$h_t$ (activations kept for backprop)$T \times d_h$yes

The last row is the one that bites, and Handout #6 §2 already explained why: reverse-mode differentiation must keep every intermediate activation until the backward pass reaches it. Parameters are constant in $T$; memory is not.

2.2  What "recurrent" buys, stated carefully

Two distinct wins, often run together, and they are worth separating because only one of them survives into the Transformer.

  1. Unbounded context, in principle. $h_t$ is a function of every token up to $t$. There is no $k$. "In principle" is load-bearing — §3 is about how much of that reach is real.
  2. Weight sharing across time. One $W_{hh}$ applies at every step, so whatever the model learns about "how to update a summary given a new word" is learned once and used everywhere. This is the same argument as convolutional weight sharing across space, and it is the part the Transformer keeps: its per-position feed-forward and attention parameters are also shared across all positions.

2.3  An RNN is a deep net with tied weights

There is nothing new to learn about training it. Write out the recurrence for $T$ steps:

h1 = tanh(W_hh @ h0 + W_eh @ e1 + b)
h2 = tanh(W_hh @ h1 + W_eh @ e2 + b)      # same W_hh
h3 = tanh(W_hh @ h2 + W_eh @ e3 + b)      # same W_hh
...

That is a $T$-layer feed-forward network in which every layer uses the same weight matrix. Unrolling is the name for taking that view, and it means backpropagation applies with zero modification: it is Handout #6 §1's computation graph, just a very deep and narrow one. The special name — backpropagation through time — describes no new algorithm. It describes reverse-mode autodiff over the unrolled graph.

One consequence follows immediately from Handout #6 §4.2's gradient accumulation: $W_{hh}$ appears at every one of the $T$ steps, so its gradient is a sum of $T$ contributions. That single fact is the source of everything in §3.

3Why training them is hard

3.1  Vanishing and exploding gradients

Trace one gradient path back through $T$ steps. By the chain rule, the contribution of $h_1$ to a loss at step $T$ is a product of $T-1$ Jacobians:

$$ \frac{\partial \mathcal{L}_T}{\partial h_1} = \frac{\partial \mathcal{L}_T}{\partial h_T} \prod_{t=2}^{T} \frac{\partial h_t}{\partial h_{t-1}}, \qquad \frac{\partial h_t}{\partial h_{t-1}} = \operatorname{diag}\!\big(1 - h_t^2\big)\, W_{hh} $$

The same matrix appears in every factor. A repeated product of one matrix is governed by its largest singular value $\sigma$: the norm behaves like $\sigma^{T}$, and there are only three regimes. Strip it to a scalar and the numbers are unignorable:

for w in (0.9, 1.0, 1.1):
    W = torch.tensor(w, requires_grad=True)
    h = torch.tensor(1.0)
    for _ in range(50):
        h = h * W                 # a 50-step linear "RNN"
    h.backward()
    print(f"w={w}: h_50={h.item():.6g}  dh/dW={W.grad.item():.6g}")

# w=0.9: h_50=0.00515377  dh/dW=0.28632
# w=1.0: h_50=1           dh/dW=50
# w=1.1: h_50=117.391     dh/dW=5335.95

A 10% change in one number moves the gradient by four orders of magnitude over 50 steps. $0.9^{50} \approx 0.005$; $1.1^{50} \approx 117$. Only $\sigma$ exactly $1$ is stable, and nothing keeps it there during training.

Now note the $\operatorname{diag}(1 - h_t^2)$ factor, which is $\tanh'$. Handout #6 §5 Q2 made the point in the abstract: $\tanh' \le 1$, with equality only at $h = 0$, so the nonlinearity multiplies the gradient by at most one at every step and typically by much less. The architecture is biased toward vanishing.

The two failures are not symmetric

Exploding gradients are loud and easy to fix: the loss becomes inf or nan, you notice within seconds, and rescaling any gradient whose norm exceeds a threshold — clip_grad_norm_ — solves it. The direction is preserved; only the step length is capped.

Vanishing gradients are silent. Nothing crashes. The model trains, the loss falls, and it simply never learns any dependency longer than ~10 steps, because the gradient signal from distance 30 arrives multiplied by $10^{-4}$ and is swamped by the short-range signal. You cannot fix it by rescaling, because the relative sizes of the long- and short-range contributions are what is wrong. This asymmetry is why the fix had to be architectural.

3.2  Truncation: BPTT is never actually run to $t=1$

Even setting gradients aside, full BPTT over a corpus is impossible: the unrolled graph would be as deep as the dataset is long, and §2.1's last table row says you would have to hold every activation. So implementations chop the stream into segments of some fixed length (in the code we will read, the flag is called bptt, typically 35), and backpropagate only within a segment.

The trick that makes this more than a crude approximation: carry the hidden state forward across segments, but not the gradient. The next segment starts from the state the previous one ended in, so the model's forward context is still unbounded; only the credit assignment is truncated at 35 steps. §7.3 shows the four lines that do this, and they are the ones students most often misread.

3.3  Gates: LSTM and GRU in one idea

The architectural fix for vanishing gradients is to give the state a path through time that is additive rather than multiplicative. An LSTM keeps a cell state $c_t$ updated as

$$ c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t $$

where $f_t, i_t \in (0,1)^{d}$ are learned, input-dependent gates. Compare $\partial c_t / \partial c_{t-1} = \operatorname{diag}(f_t)$ with the vanilla RNN's $\operatorname{diag}(1-h_t^2)W_{hh}$. If the forget gate stays near 1, the product of Jacobians is near the identity for as long as it does — the gradient can travel a long way without being repeatedly multiplied by a learned matrix. The gate can also close, which is the point: the network learns per-dimension, per-timestep how long to preserve information.

A GRU merges the input and forget gates into one and drops the separate cell state — fewer parameters, usually comparable quality. The details of which gate is which matter far less than the shared idea: an additive path through time, with learned control over it. That same idea is the residual connection you will meet in the Transformer, and it is not a coincidence.

Worth keeping in perspective LSTMs (1997) predate their own success by fifteen years, and were the state of the art in language modeling and translation until roughly 2017. Handout #1 §4's claim that the inputs changed more than the algorithms is visible here in miniature.

4Generating text

Scoring runs the whole sequence at once. Generation cannot: token $t+1$ depends on token $t$, which you just sampled. The loop is

  1. feed the current token, get $p(\cdot)$ and the new state;
  2. sample a token from $p$;
  3. make it the current token, and repeat.

The important structural point — and it is the reason RNNs look so good next to Transformers at inference time — is that step 1 costs the same at position 10,000 as at position 3. The entire past is in $h_t$, a fixed-size vector, so generation is $O(1)$ per token and $O(T)$ overall, with $O(1)$ memory. §7.4 puts this side by side with the Transformer branch of the very same file, which is $O(T^2)$.

Temperature. Sampling is usually done from a rescaled distribution $p_\tau(i) \propto \exp(z_i / \tau)$. As $\tau \to 0$ this concentrates on the argmax (greedy, repetitive); $\tau = 1$ is the model's actual distribution; $\tau > 1$ flattens it (more diverse, more incoherent). Because it is applied in the exponent, it is a one-parameter knob on the entropy of the next-token distribution and costs nothing.

5What RNNs cannot fix

Two costs are structural — no gate solves them — and together they are why the field moved.

1. No parallelism over time. $h_t$ requires $h_{t-1}$. On a training sequence of length 1000, an RNN must perform 1000 dependent steps, each too small to fill a GPU. A Transformer computes all positions simultaneously, because attention looks at the whole prefix at once instead of threading a state through it. This is not a constant-factor difference; it changes what is trainable at all at scale, which — per Handout #1 §4 — is the currency that matters.

2. A fixed-size bottleneck. Everything about the past must fit in $h_t$, a $d_h$-vector, regardless of how long the past is. Attention removes exactly this constraint by keeping every past position addressable.

Neither is a claim that RNNs do not work — they worked well enough to run production translation systems. Both are claims about how they scale, and scaling was the thing. The Transformer handout starts from exactly these two complaints.

6Reading real code: PyTorch's word-level LM

The example we will read is not a teaching toy — it is the official pytorch/examples word-level language model, which has been the reference implementation people copy for years. It is worth reading precisely because it is ordinary production-adjacent code: terse, slightly dated in places, with a couple of decisions that will look wrong until you work out why they are not.

Try it

Open these beside this page:

  • word_language_model/model.py — 144 lines. Read RNNModel (the first 64); the TransformerModel below it is a bonus for later.
  • word_language_model/generate.py — 96 lines, mostly argument parsing. The sampling loop is the last 20.
  • Optional but illuminating: main.py for batchify, get_batch and repackage_hidden — §7.3 is about those.

What to assess as you read. Whether you can follow the hidden state as it is threaded through time and across batches; whether you can name every tensor shape in forward; whether you can explain the weight tying; and whether you notice what the sampling loop does differently in its two branches.

7Four things to find in that code

7.1  forward, one line at a time

model.py · RNNModel.forward (verbatim)

def forward(self, input, hidden):
    emb = self.drop(self.encoder(input))
    output, hidden = self.rnn(emb, hidden)
    output = self.drop(output)
    decoded = self.decoder(output)
    decoded = decoded.view(-1, self.ntoken)
    return F.log_softmax(decoded, dim=1), hidden

Six lines, and all of §2 is in the second one. Note first what is not here: no loop over time. self.rnn is nn.LSTM (or nn.GRU, nn.RNN), and PyTorch runs the recurrence internally in optimized code. The $T$-step loop from §2.3 exists — it is simply not written in Python.

The shapes, which the file does not document and which you should verify yourself:

TensorShapeNote
input(T, B)time first, not batch first
emb(T, B, ninp)
output(T, B, nhid)the state at every step, not just the last
hidden(nlayers, B, nhid)a 2-tuple of these for LSTM (state + cell)
decoded(T*B, ntoken)after the view

(T, B) rather than (B, T) is the single most common source of confusion in this file. nn.RNN and friends default to batch_first=False, a historical choice that persists because changing it would break everyone. If your tensor is batch-first you must transpose or pass batch_first=True; getting this wrong does not crash, it silently treats your batch dimension as time.

The view(-1, ntoken) flattens $(T, B)$ into $T \cdot B$ rows in row-major order — all $B$ positions of timestep 0, then all of timestep 1, and so on. That ordering has to match the targets, which is §7.3's business.

The trap Handout #7 §9.1 warned about, live This forward returns log_softmax, not logits. So main.py pairs it with criterion = nn.NLLLoss() and not cross_entropy. Both routes give the identical number — F.cross_entropy is log_softmax followed by NLLLoss — but applying cross_entropy to this output would take a second log-softmax of something already normalized, and you would train a subtly wrong model with no error message:
a = nn.NLLLoss()(F.log_softmax(logits, dim=1), targets)
b = F.cross_entropy(logits, targets)
assert torch.allclose(a, b)     # 2.309767 == 2.309767
Whenever you adopt someone's model class, check which of the two it hands back.

7.2  Weight tying, and the constraint it imposes

model.py · RNNModel.__init__ (verbatim; a second paper citation elided)

        # Optionally tie weights as in:
        # "Using the Output Embedding to Improve Language Models" (Press & Wolf 2016)
        # https://arxiv.org/abs/1608.05859
        if tie_weights:
            if nhid != ninp:
                raise ValueError('When using the tied flag, nhid must be equal to emsize')
            self.decoder.weight = self.encoder.weight

One assignment, and it is worth pausing on how much it does. self.encoder is the $|\Sigma| \times d$ input embedding; self.decoder is the $d \to |\Sigma|$ output layer, whose weight is also $|\Sigma| \times d$. Making them the same tensor object means:

And the raise explains itself now: the shapes only match if $d_h = d_e$. A capability constrains an unrelated-looking hyperparameter — the kind of coupling you only see by reading the code.

7.3  The hidden state across batches — the subtle part

This is where most readers of this example go wrong, and the three functions live in main.py. Start with the data layout:

main.py · batchify (verbatim)

def batchify(data, bsz):
    # Work out how cleanly we can divide the dataset into bsz parts.
    nbatch = data.size(0) // bsz
    # Trim off any extra elements that wouldn't cleanly fit (remainders).
    data = data.narrow(0, 0, nbatch * bsz)
    # Evenly divide the data across the bsz batches.
    data = data.view(bsz, -1).t().contiguous()
    return data.to(device)

Run it on the integers 1–24 with bsz=4 and the layout becomes obvious:

batchify(torch.arange(1, 25), 4)
# tensor([[ 1,  7, 13, 19],
#         [ 2,  8, 14, 20],
#         [ 3,  9, 15, 21],
#         [ 4, 10, 16, 22],
#         [ 5, 11, 17, 23],
#         [ 6, 12, 18, 24]])

Each column is a contiguous stream: 1–6, then 7–12, and so on. The corpus was cut into $B$ long strips laid side by side. So reading down the rows walks forward in time in all $B$ streams at once — which is exactly the (T, B) convention from §7.1, and now it is clear that convention is not arbitrary.

Consequently consecutive batches are continuations of each other, not independent samples:

def get_batch(source, i):
    seq_len = min(args.bptt, len(source) - 1 - i)
    data = source[i:i+seq_len]
    target = source[i+1:i+1+seq_len].view(-1)
    return data, target

# with bptt=3, on the tensor above:
# get_batch(source, 0) -> data rows [1,7,13,19], [2,8,14,20], [3,9,15,21]
#                         target    [2,8,14,20, 3,9,15,21, 4,10,16,22]
# get_batch(source, 3) -> data rows [4,10,16,22], [5,11,17,23]   <- continues

Two things to see. First, target is the input shifted by one and flattened row-major, which is precisely the alignment Handout #7 §9.2 spent a subsection on — here it is a slice offset instead of a slice-off-the-ends, and it must match decoded.view(-1, ntoken)'s row-major flattening. Second, batch $i{+}1$ picks up where batch $i$ stopped, so throwing away the hidden state between batches would discard real context. It is kept. And that creates the problem:

main.py · repackage_hidden (verbatim)

def repackage_hidden(h):
    """Wraps hidden states in new Tensors, to detach them from their history."""

    if isinstance(h, torch.Tensor):
        return h.detach()
    else:
        return tuple(repackage_hidden(v) for v in h)

This function is §3.2's truncation, and it is four lines. The hidden state carried into the next batch is a tensor with a grad_fn pointing back through the previous batch's graph. If you call .backward() on the next batch's loss without detaching, autograd tries to walk back into a graph that was already freed:

RuntimeError: Trying to backward through the graph a second time (or directly access
saved tensors after they have already been freed)

— the exact error from Handout #6 §4.5 Q3, arising here for a structural reason rather than a mistake. .detach() returns the same numbers with the history cut, so the values flow forward forever while gradients stop at the segment boundary. The recursion into a tuple is there because an LSTM's state is (h, c), not a single tensor.

One more line from the same file, whose presence you can now predict:

# `clip_grad_norm` helps prevent the exploding gradient problem in RNNs / LSTMs.
torch.nn.utils.clip_grad_norm_(model.parameters(), args.clip)

§3.1's fix, in the training loop, with a comment that names the problem. Note where it sits: after loss.backward() and before optimizer.step() — it has to operate on gradients that exist and have not yet been applied. Feed-forward examples rarely include this line; this one does, because it must.

7.4  The sampling loop, and a free lesson about Transformers

generate.py · the generation loop (verbatim)

with torch.no_grad():  # no tracking history
    for i in range(args.words):
        if is_transformer_model:
            output = model(input, False)
            word_weights = output[-1].squeeze().div(args.temperature).exp().cpu()
            word_idx = torch.multinomial(word_weights, 1)[0]
            word_tensor = torch.Tensor([[word_idx]]).long().to(device)
            input = torch.cat([input, word_tensor], 0)
        else:
            output, hidden = model(input, hidden)
            word_weights = output.squeeze().div(args.temperature).exp().cpu()
            word_idx = torch.multinomial(word_weights, 1)[0]
            input.fill_(word_idx)

Four things, in order of how much they are worth.

1. The two branches have different complexity, and you can see it. The RNN branch ends with input.fill_(word_idx): the input stays a single (1,1) tensor and the entire past lives in hidden. The Transformer branch ends with input = torch.cat([input, word_tensor], 0): the input grows, and the next call re-runs the model over the whole prefix from scratch. Generating $T$ tokens is $O(T)$ work in the first branch and $O(T^2)$ (with quadratic attention inside, worse still) in the second. This snippet is the clearest statement of the RNN/Transformer trade-off in this handout — the architecture that won training lost inference, and the KV cache exists to buy this back.

2. .div(temperature).exp() on log-probabilities. §7.1 established that forward returns log_softmax. So .div(τ).exp() computes $\exp(\log p_i / \tau) = p_i^{1/\tau}$ — which is the temperature-rescaled distribution up to a constant. No renormalization is performed, and none is needed: torch.multinomial accepts unnormalized non-negative weights and normalizes internally. At $\tau = 0.5$ the weights above sum to 0.879, not 1, and the sampling is still correct.

3. torch.no_grad(). Handout #6 §3's warning, applied correctly. Without it, this loop would build a graph 1000 tokens deep and hold every activation, for gradients no one will ever use.

4. The safe_globals list at the top of the file. Skim it. It is there because torch.load of a pickled model is arbitrary code execution unless you restrict what may be unpickled, and PyTorch tightened this default. It has nothing to do with language modeling and everything to do with reading real code: production files carry scars from problems outside the subject matter, and recognizing that a block is not about your topic is part of reading fluently.

7.5  Now do something to it

Q1. Delete the repackage_hidden call from the training loop. Predict exactly what happens — and what happens instead if you keep the call but pass retain_graph=True to backward().

Answer
Without the call, the second batch's backward() raises "Trying to backward through the graph a second time" (verified above), because the carried state still references the freed graph. With retain_graph=True and no detach, it "works" — and is far worse than an error: the graph now grows without bound, so memory climbs every batch until the process dies, and each batch backpropagates through all preceding batches, making step time grow linearly too. That is untruncated BPTT over the entire corpus, and you would have arrived at it by silencing an error message. §3.2.

Q2. The model's forward returns log_softmax. You reuse the class in your own trainer and write loss = F.cross_entropy(output, targets). Does it crash? What do you observe?

Answer
No crash — shapes are fine and the number is finite, so nothing warns you. You are computing $-\log \operatorname{softmax}(\log p)$, a softmax over values that are all negative and already normalized. The result is a valid-looking loss that is wrong: it is much flatter than the true objective, so gradients are attenuated and the model trains slowly toward the wrong optimum. And your reported perplexity is meaningless. The diagnostic habit that catches it: for an untrained model over $|\Sigma|$ tokens, the loss should start near $\ln |\Sigma|$ (Handout #2 §4.2's baseline). A double-softmaxed loss starts far below that.

Q3. Add --tied and the run dies with "nhid must be equal to emsize". Why can't the library just insert a projection?

Answer
It could, and some implementations do — but then the tied matrix is no longer the output layer, it is an output layer composed with a learned $d_h \times d_e$ map, and you have reintroduced parameters and broken the modeling claim that the input and output representations of a word are the same vector (§7.2). The explicit raise is the better engineering choice: it refuses to silently give you something adjacent to what you asked for. Note this is a real design pattern worth copying — fail loudly on an incoherent configuration rather than papering over it.

Q4. In the sampling loop, replace multinomial with argmax. What does the output look like, and what does temperature do then?

Answer
Greedy decoding: fluent for a few words, then almost always a short repeating cycle, because the argmax is a deterministic function of the state and RNN states drift into attractors. Temperature becomes a no-op — dividing all logits by a positive constant is monotone, so it cannot change which is largest. That is a good sanity check on your understanding of what temperature is: a knob on the sampling distribution, not on the model.

Q5. batchify lays the corpus out in $B$ parallel streams rather than shuffling sentences into batches, as you would for classification. Why? And what is lost?

Answer
Because the hidden state is only meaningful if the next batch continues the same text (§7.3); shuffled independent sentences would force a state reset every batch and cap the effective context at bptt. What is lost is that the batches are no longer i.i.d. samples — the $B$ streams are fixed for the whole epoch, and the model sees the corpus in a fixed order (which is why the boundary positions of each segment get systematically less context). It also means bsz changes the data layout, so validation with a different batch size is scoring slightly different context boundaries. All tolerable, all worth knowing before you compare two numbers produced with different bsz.

8Check yourself

Q1. True or false: an RNN language model has more parameters when trained on longer sequences.

Answer
False — and the contrast with the fixed-window model is the point. Parameters are independent of $T$ (§2.1). What grows with $T$ is the memory needed for the backward pass, because every $h_t$ must be retained; that is a property of reverse-mode autodiff (Handout #6 §2), not of the model.

Q2. Gradient clipping is applied and training stabilizes, but the model still cannot resolve agreement across 40 tokens. What is happening?

Answer
Clipping fixed exploding gradients; the long-range failure is vanishing gradients, plus possibly truncation. Clipping rescales a gradient that is too large and can do nothing about a long-range contribution that arrived multiplied by $\sigma^{40}$ — rescaling the sum does not change the ratio between its terms. Also check bptt: if it is 35, no gradient path of length 40 exists at all. Fixes are architectural (gates, §3.3) or a longer truncation window. §3.1's callout.

Q3. Why is $\partial c_t / \partial c_{t-1} = \operatorname{diag}(f_t)$ a better situation than $\operatorname{diag}(1-h_t^2)W_{hh}$, given that both can shrink the gradient?

Answer
Both can shrink it; the difference is who decides. The vanilla RNN's factor is a fixed learned matrix applied identically at every step, so its spectral radius sets a single global decay rate for all dimensions and all inputs — and $\tanh'\le 1$ biases it downward. The LSTM's factor is an input-dependent gate the network computes per dimension per step, so it can hold $f \approx 1$ on the dimensions carrying long-range information while forgetting elsewhere. Same mathematical form, but one is a fixed decay and the other is a learned, selective one. §3.3.

Q4. An RNN generates token 10,000 as cheaply as token 3, while a Transformer's cost grows. Why did the Transformer win anyway?

Answer
Because the binding constraint was training, not inference. The RNN's sequential dependency means a length-1000 sequence is 1000 dependent steps that cannot be parallelized, leaving GPUs idle; a Transformer processes all positions at once and turns training into large matmuls. Given Handout #1 §4's account — data and compute as the driving forces — the architecture that converts compute into progress most efficiently wins, even at a worse asymptotic inference cost. The inference cost was then attacked separately (KV caching, and a current revival of recurrent-style models for exactly this reason). §5, §7.4.

Q5. You set bptt=35 and carry the hidden state across segments. Is the model's context 35 tokens, or unbounded?

Answer
Its forward context is unbounded — $h_t$ has seen every previous token in its stream. Its gradient context is 35: no parameter update can be attributed to anything older. So the model can use long context but can only learn to use dependencies within 35 steps. The honest answer to "what is the context length" is therefore "which one do you mean", and that distinction is the whole content of §3.2.

Q6. Handout #2 measured n-gram models with perplexity. Can you compare an RNN's perplexity to the trigram's from that handout?

Answer
Only if they share a vocabulary and a tokenization, and only if the n-gram's zeros were handled so its perplexity is finite. Both conditions are easy to violate: a word-level RNN with a truncated vocabulary and <UNK> is scoring a different set of events than a subword model on the same text. This is the tokenization handout's §4.1 in a different costume — perplexity is only comparable within a fixed event space.

9Optional further reading

Strictly optional — nothing here is assumed in class or on a quiz.