AI Architectures

RNN & GRU
Recurrent Architectures

One vector of memory, rewritten at every timestep. Build the recurrence from scratch, watch its gradients die, then fix it with two gates — and train a real character model in your browser.

Prerequisites: a neuron computes a weighted sum then a nonlinearity + backpropagation is the chain rule applied backwards. That’s it.
10
Chapters
11
Simulations
0
Assumed Knowledge

Chapter 0: The Memory Problem

Here is a task so small you can hold all of it in your head. I show you one letter. You tell me which letter comes next. The word is hello, and I feed it to you one character at a time: h, then e, then l, then l, then o. Four training pairs fall out of that word: h→e, e→l, l→l, l→o.

Now build the obvious model. A plain feed-forward network — a multilayer perceptron, or MLP: a stack of weighted sums and nonlinearities that maps one fixed-size input vector to one fixed-size output vector. Feed it a one-hot vector for the current character — all zeros except a single 1 at that character’s index — and have it emit a probability for each possible next character. Train it on those four pairs.

It cannot succeed. Not "it needs more layers" or "it needs more data" — it is structurally incapable, and we can prove it with arithmetic in the next thirty seconds.

The proof, in four numbers

The letter l appears twice in hello. The first time, the next character is l. The second time, the next character is o. But the MLP sees exactly the same input vector both times — the one-hot code for l. A function returns the same output for the same input. That is what "function" means. So the network must emit one single distribution p over next-characters, and that one distribution has to serve both examples.

Write pl for the probability it assigns to l and po for the probability it assigns to o. The cross-entropy loss — the negative log of the probability the model gave to the character that actually appeared — summed over the two l examples is

L = −ln(pl) − ln(po)

subject to pl + po ≤ 1. Push everything into those two characters (pl + po = 1) and the sum is smallest when they are equal, at 0.5 each:

L = −ln(0.5) − ln(0.5) = 0.6931 + 0.6931 = 1.3863

That is 0.6931 nats per example — a nat is the natural-logarithm unit of information, so divide by ln 2 = 0.6931 to read it in bits — and it is a floor, not a starting point. No amount of training gets under it. Try lopsided instead — say pl = 0.9, po = 0.1 — and you get −ln(0.9) − ln(0.1) = 0.1054 + 2.3026 = 2.4080, which is worse. The model is stuck paying 0.69 nats forever on a task where a human scores zero, because a human remembers what came before the l.

The diagnosis. The MLP does not lack capacity. It lacks identity for its inputs. Two occurrences of the same character are different situations, and nothing in the input tells them apart. The fix cannot be a bigger network. It has to be a network that sees something different at the two ls.

Patch attempt one: show it a window

The reflex fix is to widen the input. Instead of one character, feed the network the last k characters concatenated. With k = 2 the two ls become el and ll — different inputs, problem solved. This is the fixed-window model, and it is exactly what a neural n-gram language model does.

It works right up until the dependency is longer than the window. "The cat that the dog that the boy owned chased was hungry" needs agreement between words nine tokens apart. Set k = 5 and the model literally cannot see the word it has to agree with. So make k bigger. Count the cost.

With a vocabulary of 27 symbols (26 letters plus space) and a hidden layer of 100 units, the first weight matrix has shape 100 × (27k):

Window kInput dimension 27kFirst-layer weights (×100)Can it see 50 back?
1272,700no
513513,500no
2054054,000no
501,350135,000barely — and 51 breaks it
50013,5001,350,000yes, at 500× the cost of k = 1

Two things are wrong here and only one of them is the money. The parameter count grows linearly in the window, so buying context is expensive. Worse, the model learns a separate set of weights for "the character 3 slots back" and "the character 4 slots back." Nothing it learns about position 3 transfers to position 4. A pattern it has seen a thousand times at one offset is brand new at another. That is not a memory; that is a filing cabinet with no index.

Common misconception. “A wide-enough window is basically memory, so windows and recurrence are the same idea with different bookkeeping.” They are not. A window has a hard horizon — at k+1 steps back the information is not attenuated, it is absent, and no amount of training recovers it. Recurrence has a soft horizon: old information fades but is never architecturally forbidden. That difference is why Chapter 3 is about gradients decaying rather than about gradients being zero. Two very different failure modes need two very different fixes.

The actual fix: carry a state

Stop trying to widen the input. Instead, give the network a small notebook it writes to at every step and reads back at the next one. Call the notebook the hidden state h — a fixed-size vector of numbers that summarises everything seen so far. The update rule is one line:

ht = f(ht−1, xt)

Read it as: the new notebook is a function of the old notebook and the character you just read. That is the whole idea of a recurrent neural network (RNN) — a network with a cycle, where the output of a step feeds back in as an input to the next step. The same f, with the same weights, runs at every position. Parameter count is independent of sequence length. Whatever f learns about "how to update memory" applies at step 3 and at step 3,000.

Watch it separate the two ls

Let us make f concrete and absurdly small: a single hidden number, updated with ht = tanh(a(xt) + u·ht−1), where a(·) assigns each character a fixed number and u is a decay of 0.9. Take a(h) = 1.0, a(e) = 0.5, a(l) = −0.4, a(o) = 0.2, and start from h0 = 0. Every step by hand:

h1 = tanh(1.0 + 0.9×0) = tanh(1.0000) = 0.7616
h2 = tanh(0.5 + 0.9×0.7616) = tanh(0.5 + 0.6854) = tanh(1.1854) = 0.8292
h3 = tanh(−0.4 + 0.9×0.8292) = tanh(−0.4 + 0.7463) = tanh(0.3463) = 0.3330
h4 = tanh(−0.4 + 0.9×0.3330) = tanh(−0.4 + 0.2997) = tanh(−0.1003) = −0.0999

Look at h3 and h4. Both are the state after reading an l. One is +0.33, the other is −0.10. Same input character, different state, because the state remembers that the first l followed an e and the second followed an l. A readout layer sitting on top of h can now emit two different distributions, and the 0.6931-nat floor evaporates. We did not add capacity. We added history.

What just happened, precisely. The one-hot input for l is identical at t = 3 and t = 4. The pair (xt, ht−1) is not. Recurrence turns an ambiguous input into an unambiguous one by concatenating it with a compressed summary of the past. Every architecture in this lesson — and every one in the lineage that follows it — is a different answer to "what should that summary be, and how should it be updated?"

See it: memory on and memory off

The simulation below runs both models on the string hello hello. Press Step to advance one character. The memoryless model has one bar chart per character, fixed forever; the recurrent model carries the state you see in the strip at the bottom and its prediction changes even when the input character repeats. Watch the two l positions in particular. Then drag the decay slider: at u = 0 the recurrent model forgets instantly and collapses back into the memoryless one, which is the cleanest possible demonstration that memory is the only difference.

Memoryless vs Recurrent — the same string, two models

Step through hello hello. Top strip: the string with a cursor. Middle: the model’s predicted distribution over the next character. Bottom: the recurrent hidden state (absent for the MLP).

Decay u 0.90

The same thing in code

Six lines of NumPy is the entire idea. Note that nothing about the loop depends on how long the sequence is — that is the point.

python — recurrence from scratch
import numpy as np

# a(char) values from the hand calculation above
A = {'h': 1.0, 'e': 0.5, 'l': -0.4, 'o': 0.2}
u = 0.9

h = 0.0                          # the notebook starts blank
for ch in "hello":
    h = np.tanh(A[ch] + u * h)   # write the new page, reading the old one
    print(ch, round(float(h), 4))

# h 0.7616   e 0.8292   l 0.3330   l -0.0999   o 0.1096
# the two 'l' rows differ — that is the whole lesson in one line of output

And the library version, for orientation. PyTorch’s nn.RNN is this loop with matrices instead of scalars and a C++ kernel instead of a Python for. Same recurrence, same state, same idea:

python — the library one-liner
import torch, torch.nn as nn

rnn = nn.RNN(input_size=4, hidden_size=1, nonlinearity='tanh', batch_first=True)
x   = torch.eye(4)[[0,1,2,2,3]].unsqueeze(0)   # (1, 5, 4) — 'hello' one-hot
out, h_final = rnn(x)                             # out (1,5,1) = every h_t; h_final (1,1,1)

The shapes are worth pinning down now because they never change for the rest of the lesson. x is (batch, time, d) — here (1, 5, 4): one sequence, five timesteps, a 4-dimensional one-hot per step. out is (batch, time, n) — the hidden state at every timestep, which is what you feed a readout layer. h_final is (layers, batch, n) — just the last state, which is what you feed a downstream classifier when you only care about the whole sequence.

What this lesson is for

From here the story has a shape. Chapter 1 replaces our one scalar with a real vector cell and does the matrix arithmetic by hand. Chapter 2 unrolls the loop and derives its gradient. Chapter 3 shows that gradient dying — the failure that killed plain RNNs in practice. Chapter 4 fixes it with two gates, which is the GRU, the centre of gravity of everything here. Chapters 5 and 6 put the GRU next to the LSTM and then train one. Chapter 7 lets you train one live, in this page, and break it. Chapters 8 and 9 follow the lineage forward to xLSTM, RWKV, Mamba and the Transformer.

Why can a memoryless MLP not drive the loss below 0.6931 nats per example on the two l positions of hello?

Chapter 1: The Vanilla RNN Cell

Chapter 0 ended with a scalar notebook. One number is not much of a memory — it can encode "how excited am I" and nothing else. Promote it to a vector and the cell becomes the real thing: the Elman RNN, named for Jeffrey Elman’s 1990 paper Finding Structure in Time, and still the cell everyone means by "vanilla RNN".

ht = tanh( Wx xt + Wh ht−1 + b )

Every symbol, in words, before we touch it:

Two design decisions are already baked in and both matter. First, the same Wx, Wh, b are used at every timestep — this is weight sharing across time, and it is why a 3-token sequence and a 3,000-token sequence cost the same number of parameters. Second, ht is the only channel from the past to the future. Everything the model will ever know about characters 1 through t must survive inside those n numbers. That is a brutal bottleneck, and later chapters are largely about widening it or defending it.

Why the state is a bottleneck and not a bug. A fixed-size state is the reason an RNN generates in constant memory: predicting token 10,000 costs exactly what predicting token 10 cost. A Transformer keeps every past token in a growing KV cache — the stored keys and values for every token seen so far, which it needs in order to attend backwards — and pays for it in memory that grows with the sequence. The whole 2023–2025 revival of recurrence — Mamba, RWKV, xLSTM — is people deciding that this constraint is worth defending, and engineering around its costs rather than abandoning it.

The output head: state is not prediction

ht is a summary, not an answer. To get a prediction you add a readout layer — one more matrix, mapping the state to one score per vocabulary item, then a softmax to turn scores into probabilities:

yt = Wy ht + by
pt = softmax(yt)

Wy has shape d × n (vocabulary rows, state columns). The vector yt is called the logits — unnormalised scores, one per possible next character, free to be any real number. Softmax exponentiates them and divides by the sum, producing a proper distribution. Keeping the readout separate from the recurrence is a real engineering choice: it lets you swap vocabularies without retraining the cell, and it lets you tie Wy to the input embedding to save parameters, a trick standard in language models.

Do it by hand: three steps, every number

Vocabulary {a, b, c}, so d = 3. State size n = 2. Concrete weights, chosen to be readable rather than trained:

Wx = [ [1.0, −0.5, 0.2],  [0.0, 0.8, −0.3] ]  (2×3)
Wh = [ [0.5, −0.2],  [0.1, 0.6] ]  (2×2)
b = [0.1, −0.1],   h0 = [0, 0]

Input sequence: a, b, c. Because each x is one-hot, Wxxt is simply a column of Wx — column 1 for a, column 2 for b, column 3 for c. That is worth internalising: for one-hot inputs the input matrix is a lookup table, which is exactly why real implementations use an embedding lookup instead of a matrix multiply.

Step 1 — read a, so Wxx1 = column 1 = [1.0, 0.0]. The state is still zero, so Whh0 = [0, 0].

pre1 = [1.0 + 0 + 0.1,  0.0 + 0 − 0.1] = [1.1, −0.1]
h1 = [tanh(1.1), tanh(−0.1)] = [0.8005, −0.0997]

Step 2 — read b, so Wxx2 = column 2 = [−0.5, 0.8]. Now the recurrent term is live. Multiply row by row:

(Whh1)1 = 0.5×0.80050 + (−0.2)×(−0.09967) = 0.40025 + 0.01993 = 0.42018
(Whh1)2 = 0.1×0.80050 + 0.6×(−0.09967) = 0.08005 − 0.05980 = 0.02025
pre2 = [−0.5 + 0.42018 + 0.1,  0.8 + 0.02025 − 0.1] = [0.02018, 0.72025]
h2 = [tanh(0.02018), tanh(0.72025)] = [0.0202, 0.6171]

Step 3 — read c, so Wxx3 = column 3 = [0.2, −0.3].

(Whh2)1 = 0.5×0.02018 + (−0.2)×0.61706 = 0.01009 − 0.12341 = −0.11332
(Whh2)2 = 0.1×0.02018 + 0.6×0.61706 = 0.00202 + 0.37024 = 0.37226
pre3 = [0.2 − 0.11332 + 0.1,  −0.3 + 0.37226 − 0.1] = [0.18668, −0.02774]
h3 = [tanh(0.18668), tanh(−0.02774)] = [0.1845, −0.0277]

Five decimal places through the intermediates is not fussiness: round each step to four and the third state drifts by one in the last digit, so the printed library output would no longer match. Rounding error compounds along a recurrence exactly the way gradients do — a small preview of Chapter 3.

Three observations from those nine multiplications. Unit 1 went 0.80 → 0.02 → 0.18: it is being driven mostly by the input, since Wx row 1 has the large entries. Unit 2 went −0.10 → 0.62 → −0.03: it holds on longer, because Wh[2][2] = 0.6 is the biggest recurrent weight and feeds unit 2 back into itself. And every pre-activation landed inside roughly (−1.2, 1.2), where tanh is steep and informative. Chapter 3 is about what happens when they do not.

Common misconception. “ht stores the last few inputs, like a shift register.” It does not. ht is a lossy, learned compression of the entire prefix, and nothing forces any component to correspond to a particular past timestep. Trained character RNNs are known to develop single units that track quote-open/quote-closed or bracket depth — features spanning hundreds of characters — while no unit tracks "the character two steps ago". Expecting a shift register makes the vanishing-gradient result in Chapter 3 look like a bug; understanding it as compression makes it look like a budget.

See it: step the cell, watch the state

The widget runs exactly the arithmetic above. Press Step to advance one character; the diagram shows the input column, the recurrent contribution, the pre-activation, and the resulting state as signed bars. The recurrent-weight slider scales all of Wh at once: turn it to 0 and the cell becomes memoryless (each state depends only on the current character); turn it up past 1.5 and watch the pre-activations saturate against the flat tails of tanh, which is a preview of Chapter 3’s disaster.

The Elman cell, stepped by hand

Left bars are coloured by term — blue = Wxx, purple = Whh, grey = bias — laid end to end, with the orange line marking their sum. Right: the resulting hidden state, teal when positive and warm when negative.

Scale Wh 1.00

From scratch, then the one-liner

Here is the cell in NumPy with the exact weights from the hand calculation, so you can check the printout against the numbers above. This is the whole forward pass — there is nothing hidden.

python — Elman RNN forward, from scratch
import numpy as np

Wx = np.array([[ 1.0, -0.5,  0.2],
               [ 0.0,  0.8, -0.3]])      # (n=2, d=3)
Wh = np.array([[ 0.5, -0.2],
               [ 0.1,  0.6]])              # (n=2, n=2)
b  = np.array([ 0.1, -0.1])                # (n=2,)

def rnn_step(x, h):
    # x: (d,) one-hot   h: (n,) previous state  ->  (n,) new state
    return np.tanh(Wx @ x + Wh @ h + b)

def rnn_forward(idxs, d=3, n=2):
    h  = np.zeros(n)
    hs = []
    for i in idxs:
        x = np.zeros(d); x[i] = 1.0       # one-hot for this character
        h = rnn_step(x, h)
        hs.append(h.copy())                # keep EVERY state — backprop needs them
    return np.array(hs)                    # (T, n)

print(np.round(rnn_forward([0, 1, 2]), 4))
# [[ 0.8005 -0.0997]
#  [ 0.0202  0.6171]
#  [ 0.1845 -0.0277]]   <-- matches the hand calculation exactly

That hs.append(h.copy()) is not bookkeeping for the demo — it is mandatory. The backward pass in Chapter 2 needs ht at every t to compute 1 − ht2, so the forward pass must retain the whole state trajectory. Memory during training therefore scales as O(T × n), which is precisely why truncated backpropagation exists.

And the library equivalent. PyTorch calls the two matrices weight_ih_l0 and weight_hh_l0, and carries two biases instead of one (mathematically redundant, kept for cuDNN compatibility):

python — the same cell via torch.nn.RNN
import torch, torch.nn as nn

rnn = nn.RNN(input_size=3, hidden_size=2, batch_first=True)
with torch.no_grad():                        # paste our weights in
    rnn.weight_ih_l0.copy_(torch.tensor(Wx))
    rnn.weight_hh_l0.copy_(torch.tensor(Wh))
    rnn.bias_ih_l0.copy_(torch.tensor(b))
    rnn.bias_hh_l0.zero_()                    # fold both biases into one

x = torch.eye(3)[[0,1,2]].unsqueeze(0).double()   # (1, 3, 3)
out, hT = rnn.double()(x)
print(out.squeeze(0).round(decimals=4))
# tensor([[ 0.8005, -0.0997], [ 0.0202,  0.6171], [ 0.1845, -0.0277]])

Same three rows. The library is the loop, compiled. Everything you will meet later — gates, clipping, stacking — is a modification of the six lines above, not a replacement for them.

For a vocabulary of size d = 3 and state size n = 2, the input term Wxxt for a one-hot xt is exactly what?

Chapter 2: Unrolling and BPTT

We have a cell that runs forward. To train it we need gradients, and gradients need a graph. The problem is that the recurrence has a cycle: h feeds into itself. Backpropagation is defined on directed acyclic graphs. So the first move is a bookkeeping trick that makes the cycle disappear.

Unrolling: a loop is a deep network in disguise

Unrolling means writing out one copy of the cell per timestep, laid left to right, with ht−1 wired into the copy at t. A 3-step sequence becomes a 3-layer feed-forward network. A 100-step sequence becomes a 100-layer one. The copies are not independent — they share Wx, Wh, b, the same physical numbers used three times or a hundred times.

Once unrolled, it is an ordinary DAG and ordinary backpropagation applies. Running backprop over the unrolled graph has a name: backpropagation through time, or BPTT. It is not a new algorithm. It is the chain rule on a graph that happens to be very deep and to reuse the same weights at every depth.

That weight sharing has one consequence you must never forget, because it is the single most common implementation bug in hand-written RNNs. Each parameter receives a gradient contribution from every timestep it participated in, and those contributions add up:

∂L/∂Wh = ∑t (∂L/∂Wh) from step t
Accumulate, never assign. In from-scratch code this is the difference between dWh += ... and dWh = ... inside the backward loop. Writing = gives you the gradient of the last timestep only. The network still trains — slowly, badly, and with no error message — which is why the bug survives so long. If your hand-written RNN learns but plateaus far above a library baseline, check this line first.

The chain rule, derived step by step

Take the smallest interesting case: a scalar RNN, ht = tanh(w·xt + u·ht−1), three timesteps, and a loss on the final state only, L = ½(h3 − y)2. Scalar so every quantity is a number you can check.

Start at the output and walk backwards. The derivative of the loss with respect to the last state is immediate:

∂L/∂h3 = h3 − y

Next, how does h3 depend on h2? Write a3 for the pre-activation w·x3 + u·h2. Then h3 = tanh(a3), and the derivative of tanh is 1 − tanh2, so:

∂h3/∂h2 = (1 − h32) · u

That factor — squash-derivative times recurrent weight — is the atom of the whole subject. Every step backwards multiplies by one of them. Chapter 3 is nothing but the study of what happens when you multiply many of them together.

Now the parameter u. It enters the graph three times, once per step, so its gradient is a sum of three terms. The term from step t is "the gradient arriving at ht, times the local derivative of ht with respect to u holding ht−1 fixed", and that local derivative is (1 − ht2)·ht−1:

∂L/∂u = ∑t=1..3 (∂L/∂ht) · (1 − ht2) · ht−1

and the gradient arriving at an earlier state is the gradient at the later one dragged back through the atoms:

∂L/∂ht = (∂L/∂ht+1) · (1 − ht+12) · u

Those two lines are the entire BPTT algorithm for a scalar cell. In the vector case the products become matrix products and u becomes WhT, but the structure is identical.

Now do it with actual numbers

Set w = 0.8, u = 0.9, inputs x = [1, 1, 1], target y = 0.5, h0 = 0. Forward first:

h1 = tanh(0.8 + 0.9×0) = tanh(0.8000) = 0.6640
h2 = tanh(0.8 + 0.9×0.6640) = tanh(1.3976) = 0.8848
h3 = tanh(0.8 + 0.9×0.8848) = tanh(1.5964) = 0.9211
L = ½(0.9211 − 0.5)2 = ½(0.4211)2 = ½(0.1773) = 0.0887

The three squash derivatives, which we will reuse constantly:

1 − h32 = 1 − 0.8485 = 0.1515
1 − h22 = 1 − 0.7829 = 0.2171
1 − h12 = 1 − 0.4409 = 0.5591

Notice they are already shrinking as we move forward in time: 0.559, 0.217, 0.151. The state is drifting toward the flat part of tanh, and the flat part of tanh has a small derivative. Backwards:

∂L/∂h3 = 0.9211 − 0.5 = 0.4211
∂h3/∂h2 = 0.1515 × 0.9 = 0.1364
∂h2/∂h1 = 0.2171 × 0.9 = 0.1954

Now assemble ∂L/∂w, which collects one term per timestep. Since xt = 1 at every step, the local factor for w at step t is just (1 − ht2):

term from t=3: 0.1515 = 0.1515
term from t=2: 0.1364 × 0.2171 = 0.0296
term from t=1: 0.1364 × 0.1954 × 0.5591 = 0.02665 × 0.5591 = 0.0149
sum = 0.15154 + 0.02960 + 0.01490 = 0.19604
∂L/∂w = 0.4211 × 0.19604 = 0.0826

And for u, where the local factor at step t is (1 − ht2)·ht−1. The t = 1 term vanishes because h0 = 0 — a small but real fact: the very first step teaches the recurrent weight nothing, because there was no previous state to multiply.

term from t=3: 0.15154 × 0.88483 = 0.13409
term from t=2: 0.13639 × 0.21707 × 0.66404 = 0.02960 × 0.66404 = 0.01966
term from t=1: 0.13639 × 0.19536 × 0.55905 × 0 = 0.00000
sum = 0.15375,   ∂L/∂u = 0.4211 × 0.15375 = 0.0647
Read the three w-terms again: 0.1515, 0.0296, 0.0149. The step nearest the loss contributes ten times more than the step two back. Over three steps that is a curiosity. Over fifty steps it is the reason plain RNNs cannot learn long dependencies — and it is the entire content of Chapter 3. The disease is already visible in a three-step hand calculation.

Truncated BPTT: cutting the tail off honestly

A 10,000-character document unrolls into a 10,000-layer network. Storing every state costs O(T·n) memory, and the backward pass costs O(T) sequential steps you cannot parallelise. Nobody does this. Instead, truncated BPTT processes the sequence in chunks of k steps (k = 25 and k = 35 are the classic choices), backpropagates only within a chunk, and carries the value of h across the chunk boundary while cutting the gradient there.

That distinction is the whole trick, and it is worth stating twice. The forward state is continuous — the model’s memory really does span the whole document. Only the credit assignment is truncated: the optimiser never asks "how should I change W to have made the state 300 steps ago better?" In PyTorch this is one line, h = h.detach(), at the chunk boundary.

What does truncation cost? Use the numbers we just computed. Full BPTT over three steps gave a w-gradient sum of 0.19604. Truncate to k = 2 (drop the t = 1 term):

sumk=2 = 0.15154 + 0.02960 = 0.18114
∂L/∂w |k=2 = 0.4211 × 0.18114 = 0.0763  vs  0.0826 exact
relative error = (0.0826 − 0.0763) / 0.0826 = 7.6%

A 7.6% error for a 33% saving. And it gets better, not worse, as k grows: the terms you are dropping are the ones that were already tiny. That is the honest justification for truncation — not that long-range gradients do not matter, but that in a plain RNN they are already negligible, so discarding them costs almost nothing. In a gated cell the same terms are much larger, which is exactly why gated cells benefit from bigger k.

Common misconception. “Truncating BPTT to 25 steps means the model can only remember 25 steps.” No — those are two different horizons. The forward state carries across chunk boundaries unchanged, so the model can and does condition on information from thousands of steps back at inference. What truncation limits is learning: a dependency spanning 200 steps cannot be directly credited by a 25-step window, and the model must discover it indirectly, by learning to write a durable summary into h within one chunk that is still useful many chunks later. Truncation caps the teacher’s reach, not the student’s memory.

See it: drag the truncation window

Each bar below is the magnitude of one timestep’s contribution to the weight gradient, computed by actually running the recurrence over twelve steps. (Magnitudes, because with an alternating input the signed terms partly cancel and would hide the decay we are here to see.) Drag the window slider to keep only the last k steps and watch how much of the total survives — the readout gives you the exact percentage. Then raise the recurrent weight and watch the far bars grow: the truncation you were getting away with at u = 0.3 starts throwing away real gradient by u = 0.9.

One deliberate difference from the hand calculation: this widget drives the cell with a small alternating input rather than a constant one. That keeps the state near zero, where the tanh derivative is close to 1 and the per-step factor really is about u. With a large constant drive the state saturates near ±0.95, the tanh derivative collapses to 0.1, and the per-step factor stays far below 1 no matter how large you make u — which is itself worth knowing, and is exactly why Chapter 3’s slider has a separate control for the average tanh derivative.

Truncated BPTT — which steps actually pay

Bars: |contribution to ∂L/∂w| per timestep, newest on the right. Shaded region = kept by truncation; faded bars = discarded.

Window k 3
Recurrent u 0.90

BPTT from scratch, then autograd

The from-scratch version below computes the same 0.0826 and 0.0647. Read the loop backwards: it starts with the gradient at the final state and drags it back, accumulating into dw and du at every step.

python — BPTT by hand, scalar cell
import numpy as np

w, u, y = 0.8, 0.9, 0.5
xs = [1.0, 1.0, 1.0]

# ---- forward, keeping every state ----
hs = [0.0]
for x in xs:
    hs.append(np.tanh(w*x + u*hs[-1]))
loss = 0.5 * (hs[-1] - y)**2

# ---- backward through time ----
dw = du = 0.0
dh = hs[-1] - y                      # dL/dh_T
for t in range(len(xs), 0, -1):        # t = 3, 2, 1
    da  = dh * (1 - hs[t]**2)          # through the tanh
    dw += da * xs[t-1]                 # += NOT = : shared weight
    du += da * hs[t-1]                  # += NOT = : shared weight
    dh  = da * u                          # hand the gradient one step further back

print(round(loss,4), round(dw,4), round(du,4))
# 0.0887 0.0826 0.0647   <-- exactly the hand calculation

Autograd does the same thing, and confirming that the two agree is the standard way to trust a hand-written backward pass. If you ever write your own cell, run this comparison before anything else:

python — the same gradients from torch autograd
import torch

w = torch.tensor(0.8, requires_grad=True)
u = torch.tensor(0.9, requires_grad=True)

h = torch.tensor(0.0)
for _ in range(3):
    h = torch.tanh(w*1.0 + u*h)          # the unrolled graph is built here
(0.5*(h - 0.5)**2).backward()          # BPTT = backward() on that graph
print(w.grad.item(), u.grad.item())   # 0.0826  0.0647

# truncation in real code is exactly one line, at the chunk boundary:
#     h = h.detach()   # keep the VALUE, cut the gradient path
In truncated BPTT with chunk size k, what exactly is cut at a chunk boundary?

Chapter 3: Vanishing and Exploding

Chapter 2 left a clue in plain sight: the gradient contribution from three steps back was ten times smaller than the one from the last step. Now we find out why, and discover that the answer forbids plain RNNs from doing the one thing we built them for.

The product of Jacobians

To push a gradient from time T back to time 0, backpropagation multiplies together the step-to-step derivatives. For a vector cell, each of those is a matrix — a Jacobian, the matrix of all partial derivatives of one vector with respect to another. Differentiate ht = tanh(Wxxt + Whht−1 + b) with respect to ht−1:

∂ht / ∂ht−1 = Dt Wh,   where Dt = diag(1 − ht2)

Dt is a diagonal matrix holding the tanh derivative for each unit, and Wh is the same recurrent matrix at every step. Chain them over T steps and the gradient that reaches the beginning of the sequence is:

∂hT / ∂h0 = DTWh · DT−1Wh · … · D1Wh

There it is: the same matrix, raised to (roughly) the T-th power. Powers of a matrix do exactly two things — they blow up or they collapse — and which one depends on a single number.

Deriving the bound

Take norms. The norm of a product is at most the product of the norms, so:

‖ ∂hT/∂h0 ‖ ≤ ∏t=1..T ‖Dt‖ · ‖Wh

Bound each factor separately. For the diagonal: tanh has derivative 1 − tanh2, which is 1 at the origin and shrinks toward 0 in the tails, so ‖Dt‖ ≤ γ = 1 always, and in practice sits well below 1 — a unit sitting at h = 0.9 contributes only 1 − 0.81 = 0.19. For the weight matrix, the relevant norm is the largest singular value, written σmax — intuitively, the largest factor by which Wh can stretch any vector in a single step. It is often loosely called the spectral radius, though strictly the spectral radius is the largest absolute eigenvalue, and that is the one that governs repeated multiplication; the measurement script at the end of this chapter makes the difference concrete. Put them together:

‖ ∂hT/∂h0 ‖ ≤ ( γ · σmax )T

The exponent is the sequence length. That is the whole result, and it is Pascanu, Mikolov and Bengio’s 2013 analysis in one line: if γ·σmax < 1 the gradient is guaranteed to vanish geometrically; if it exceeds 1 the gradient is free to explode geometrically. Since γ ≤ 1, the sufficient condition for vanishing is σmax < 1 — which is where random initialisations usually land, and where weight decay actively pushes you.

What geometric decay actually looks like

"Geometric" hides how violent this is, so put numbers on it. Take a well-behaved network: σmax = 0.9, and states typically around h ≈ 0.7 so the average tanh derivative is 1 − 0.49 ≈ 0.5. The per-step factor is 0.9 × 0.5 = 0.45.

Steps back T0.45T (typical decay)In words
50.0185gradient is 1.8% of the local one — still learnable
103.4 × 10−40.03% — marginal
201.2 × 10−7ten-millionth — lost in float noise
504.6 × 10−18below float32 resolution entirely

Check the T = 20 row by hand so it is not magic: ln(0.45) = −0.7985, times 20 is −15.97, and e−15.97 = 1.16 × 10−7. A float32 has about seven significant decimal digits, so at T = 20 the long-range gradient is already competing with rounding error, and at T = 50 it is numerically zero. The model does not learn the dependency slowly. It does not learn it at all.

Now the other direction. Set σmax = 1.5 and suppose the states sit near zero where tanh’ ≈ 1, so the per-step factor is 1.5:

1.520 = e20×0.4055 = e8.11 = 3,325
1.550 = e50×0.4055 = e20.27 = 6.4 × 108

A gradient scaled by 640 million, hit with a learning rate of 0.01, moves every weight by millions. One optimiser step and the weights are inf; the step after that the loss is NaN and the run is dead. This is the classic RNN training experience: loss falling nicely, then a single vertical spike, then nothing.

Two failures, one cause, opposite symptoms. Explosion is loud — the loss spikes, you see it in the first minute, and the fix is three lines of code. Vanishing is silent — training proceeds, the loss goes down, everything looks fine, and the model has simply learned nothing beyond a 20-step horizon. The dangerous one is the quiet one, and it is the one gates were invented to fix.

See it: turn the dial through the phase change

The plot below is a log-scale view of the bound (γ·σmax)T as T grows, evaluated at whatever per-step factor your two sliders produce. Slide the spectral radius: below the critical line the curve dives, above it the curve climbs, and right where the product hits 1 it walks flat. Then lower the average tanh derivative — a proxy for "the states are saturated" — and notice that the safe region shrinks. Saturation makes vanishing worse, which is why weight initialisation for RNNs is unusually fussy.

Gradient magnitude vs distance — the phase change at 1

Vertical axis is log10 of the gradient norm. The dashed line marks float32’s practical noise floor near 10−7 — anything below it is not a small gradient, it is no gradient.

σmax 0.90
avg tanh’ 0.50

The cheap fix: gradient clipping

Explosion has a blunt, effective, universally used cure, called gradient clipping: a rescaling applied to the gradient after it is computed but before the optimiser uses it. Compute the global gradient norm across every parameter; if it exceeds a threshold c, rescale the entire gradient to have norm exactly c:

if ‖g‖ > c:   g ← c · g / ‖g‖

Worked example. Suppose a batch produces a gradient with global norm ‖g‖ = 47.3 and you clip at c = 5.0. The scale factor is 5.0 / 47.3 = 0.1057, and every component is multiplied by it: a component that was 12.0 becomes 12.0 × 0.1057 = 1.268; one that was −0.4 becomes −0.0423. Check the result: the new norm is 47.3 × 0.1057 = 5.0, as required.

Crucially this preserves the direction of the gradient and only shortens it. You still step downhill; you just refuse to take a mile-long stride off a cliff. Typical thresholds are c = 1 to 5 for language models. The cost is essentially zero and it is standard practice in every RNN codebase.

Common misconception. “Gradient clipping fixes the vanishing gradient too.” It fixes exactly half the problem — the explosive half. Clipping can only make a gradient smaller. If the long-range signal has already decayed to 10−7, no rescaling of the global norm recovers it, because the information is not merely small, it is gone relative to the short-range terms that dominate the same norm. Vanishing needs an architectural fix: a path along which the gradient is multiplied by something close to 1 instead of by DtWh. That path is what a gate builds, and it is Chapter 4.

Measure it yourself

Do not take the plot on faith. This script builds a random recurrent matrix with a chosen spectral radius and measures the actual product of Jacobians at every distance. Run it, watch the numbers fall off a cliff — and then look carefully at the ρ = 1.5 row, which does something the bound alone would not have told you.

python — measure the decay directly
import numpy as np

def make_Wh(n, rho, seed=0):
    # Scale so the SPECTRAL RADIUS (largest |eigenvalue|) is rho. Repeated
    # products are governed by the spectral radius; sigma_max bounds only ONE step.
    rng = np.random.default_rng(seed)
    W = rng.standard_normal((n, n))
    ev = np.max(np.abs(np.linalg.eigvals(W)))
    return W * (rho / ev)

def log10_grad_norm(rho, T=60, n=64):
    Wh = make_Wh(n, rho)
    rng = np.random.default_rng(1)
    h = np.zeros(n); J = np.eye(n); acc = 0.0; out = []
    for t in range(T):
        x = rng.standard_normal(n) * 0.5              # stand-in for Wx @ x_t
        h = np.tanh(Wh @ h + x)
        J = (1 - h**2)[:, None] * (Wh @ J)          # diag(1-h^2) @ Wh @ J
        s = np.linalg.norm(J, 2)
        acc += np.log10(s); J /= s                  # renormalise — never under/overflows
        out.append(acc)
    return out                                      # log10 of the accumulated norm

for rho in (0.5, 0.9, 1.5, 2.5):
    g = log10_grad_norm(rho)
    print(rho, [f"1e{g[t]:+.1f}" for t in (4, 9, 19, 49)])

# 0.5 ['1e-1.4', '1e-3.2', '1e-7.1', '1e-18.8']   <- dead well before step 20
# 0.9 ['1e-0.2', '1e-0.9', '1e-2.4', '1e-7.5']   <- at the noise floor by step 50
# 1.5 ['1e+0.8', '1e+0.8', '1e+0.6', '1e-0.0']   <- flat: saturation cancels the growth
# 2.5 ['1e+1.6', '1e+2.0', '1e+2.8', '1e+5.3']   <- exploding
Two things in that output are worth more than the plot. First, the scaling: the code sets the spectral radius (largest absolute eigenvalue), not σmax. That is deliberate. σmax bounds a single step, but the growth rate of a matrix multiplied by itself many times is its spectral radius, and for a random matrix the two differ a lot — the ρ = 1.5 matrix here has σmax ≈ 2.95. Second, that ρ = 1.5 row barely moves across fifty steps instead of exploding, because as the recurrence grows it drives the states into tanh’s flat tails, 1 − h2 collapses, and the diagonal cancels the growth. The bound says explosion is possible above 1; saturation is what usually prevents it, and it takes ρ = 2.5 before the product really runs away. Real explosions in training tend to come from a few outlier inputs, not from a uniformly hot recurrence — which is exactly why clipping (a per-batch guard) works so well in practice.

And the two-line library form of clipping, which belongs in every RNN training loop you ever write. Note that it goes after backward() and before step() — it operates on gradients that exist but have not yet been applied:

python — clipping in the training loop
loss.backward()
total = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
optimizer.zero_grad()

# clip_grad_norm_ RETURNS the pre-clip norm — log it. A histogram of that
# number is the single most useful diagnostic for an RNN that is about to die.
Your RNN trains smoothly, the loss decreases, but it never learns a dependency spanning 40 timesteps. Which fix addresses the actual cause?

Chapter 4: Gates — the GRU Cell

Chapter 3 handed us a precise diagnosis. Every backward step multiplies by DtWh, and any matrix repeatedly multiplied by itself either dies or explodes. So do not ask for a better Wh. Ask for a different multiplier — one the network can set to 1 when it wants a memory to survive.

Derive the fix from the requirement

Write down what we need and let the architecture fall out. We want an update where the network can choose, per unit and per timestep, between two behaviours:

A hard switch between the two is not differentiable, and we need gradients. So take the standard trick: replace the discrete choice with a continuous blend, and let a learned number control the blend. Call that number z:

ht = (1 − zt) ⊙ ht−1 + zt ⊙ h̃t

where ⊙ is elementwise multiplication. At z = 0 this is exactly "keep". At z = 1 it is exactly "replace". In between it interpolates. This is a convex combination: the two coefficients are non-negative and sum to 1, so ht is always a weighted average of the old state and the new candidate and can never blow up in magnitude. That property is free, and it is worth a lot.

z has to come from somewhere, and it has to be in [0, 1]. Compute it the way we compute everything else — a linear map of the input and the previous state — and squash it with the logistic sigmoid σ(a) = 1/(1 + e−a), whose range is exactly (0, 1):

zt = σ( Wz xt + Uz ht−1 + bz )

This is the update gate, and it is a vector of length n, not a scalar. Unit 7 can be keeping while unit 8 replaces, on the very same timestep. That per-unit independence is what lets one GRU hold a long-range fact in a few units while the rest of the state churns with local detail.

Why this rescues the gradient. Differentiate the blend, treating h̃ as roughly independent of ht−1 for a moment: ∂ht/∂ht−1 ≈ diag(1 − zt). When the gate says keep (z → 0) that Jacobian is the identity, and the product over T steps is still the identity — no decay at all, at any distance. The network does not have to fight the vanishing gradient; it can switch it off for whichever units are carrying something worth remembering. This is the same structural idea as a residual connection in a deep feed-forward network: build an additive highway so the default path multiplies by 1.

The highway, in numbers

That claim deserves arithmetic rather than assertion, so put the same 20-step distance from Chapter 3 next to three different gate settings. The GRU’s per-step backward multiplier is (1 − zt); the vanilla cell’s was γ·σmax ≈ 0.45.

z = 0.1 (mostly keeping):  0.920 = 0.1216
z = 0.5 (half and half):  0.520 = 9.5 × 10−7
z = 0.9 (mostly overwriting):  0.120 = 1.0 × 10−20
vanilla RNN, no gate:  0.4520 = 1.2 × 10−7

Three things fall out of those four numbers. At z = 0.1 the gradient arrives at 12% strength after twenty steps — six orders of magnitude better than the vanilla cell, and comfortably above the float32 noise floor. At z = 0.5 the GRU is no better than the vanilla cell; a gate stuck at one half buys you nothing. And at z = 0.9 it is dramatically worse. The gate does not make gradients survive. It makes survival a choice the network can make per unit, and it will only choose it where the unit is carrying something the loss actually rewards remembering.

This is also why a trained GRU develops the striped structure you will see in Chapter 7: a handful of units sit near z = 0 for long stretches, and those are the ones with a live gradient path back hundreds of steps. The rest churn at z near 1 and remember nothing beyond a few characters, which is correct — most of what a character model needs is local.

The second gate: permission to ignore

One gate is not quite enough. The candidate h̃t is the "fresh content", and the obvious way to compute it is exactly the vanilla RNN cell: tanh(Whxt + Uhht−1 + bh). But that forces the candidate to be a function of the whole previous state, always. Consider reaching a sentence boundary in a language model: the right move is to propose content that ignores the previous clause entirely. With only an update gate, the cell can overwrite the state, but the replacement it computes is still contaminated by what it is replacing.

So add a second gate, applied to ht−1 inside the candidate:

rt = σ( Wr xt + Ur ht−1 + br )
t = tanh( Wh xt + Uh ( rt ⊙ ht−1 ) + bh )

This is the reset gate. At r = 1 the candidate reads the full previous state, exactly like a vanilla cell. At r = 0 the candidate is computed from the input alone — the cell proposes a completely fresh start. Cho and colleagues, introducing the GRU in 2014, described exactly this behaviour: units with frequently-active reset gates learn short-lived local features, while units that keep r high learn long-range ones.

Why r sits inside the candidate and not on ht. This is the question that separates people who have read the equations from people who understand them. If you applied r to the output — ht = r ⊙ (blend) — then resetting would destroy the memory: the information would be gone from the state forever. Placed inside the candidate, r only controls what the proposal is allowed to look at. The old state is still sitting there in the (1 − z)⊙ht−1 term, fully intact. So the cell can say "compute a fresh proposal that ignores history" and separately say "but keep most of the old state anyway." Two independent decisions, deliberately decoupled. Reading is gated; remembering is gated; and they are not the same gate.

The complete cell, four lines

zt = σ( Wzxt + Uzht−1 + bz )  — how much to write
rt = σ( Wrxt + Urht−1 + br )  — how much to read
t = tanh( Whxt + Uh(rt ⊙ ht−1) + bh )  — what to write
ht = (1 − zt) ⊙ ht−1 + zt ⊙ h̃t  — the blend

Shapes, because they are the thing people get wrong: every W is n × d, every U is n × n, every b is length n, and z, r, h̃, h are all length n. Three of each, one set per gate plus one for the candidate. Data flow for one timestep, concretely, with d = 128 and n = 256: a (128,) input and a (256,) previous state go in; three (256,) pre-activations are computed; two are squashed by sigmoid into gates and one by tanh into a candidate; they combine into a (256,) new state. Nothing else crosses the timestep boundary.

One step, by hand, every number

n = 2, d = 3 with vocabulary {a, b, c}. Previous state ht−1 = [0.5, −0.2], input character b (so every W·x picks column 2). Weights:

Wz col b = [0.6, −0.4],  Uz = [[0.3, 0.1], [−0.2, 0.5]],  bz = [0.0, 0.1]
Wr col b = [−0.3, 0.7],  Ur = [[0.2, −0.4], [0.1, 0.3]],  br = [0.2, 0.0]
Wh col b = [0.5, 0.4],  Uh = [[0.7, 0.2], [−0.1, 0.9]],  bh = [0.0, 0.0]

The update gate. First the recurrent contribution, row by row:

(Uzh)1 = 0.3×0.5 + 0.1×(−0.2) = 0.15 − 0.02 = 0.13
(Uzh)2 = −0.2×0.5 + 0.5×(−0.2) = −0.10 − 0.10 = −0.20
az = [0.6 + 0.13 + 0.0,  −0.4 − 0.20 + 0.1] = [0.73, −0.50]
z = [ 1/(1+e−0.73), 1/(1+e0.50) ] = [ 1/1.4819, 1/2.6487 ] = [0.6748, 0.3775]

The reset gate. Same shape of calculation:

(Urh)1 = 0.2×0.5 + (−0.4)×(−0.2) = 0.10 + 0.08 = 0.18
(Urh)2 = 0.1×0.5 + 0.3×(−0.2) = 0.05 − 0.06 = −0.01
ar = [−0.3 + 0.18 + 0.2,  0.7 − 0.01 + 0.0] = [0.08, 0.69]
r = [ 1/(1+e−0.08), 1/(1+e−0.69) ] = [0.5200, 0.6660]

The gated state, then the candidate. Apply r elementwise before the Uh multiply — this is the step people skip:

r ⊙ ht−1 = [0.5200×0.5,  0.6660×(−0.2)] = [0.2600, −0.1332]
(Uh(r⊙h))1 = 0.7×0.2600 + 0.2×(−0.1332) = 0.1820 − 0.0266 = 0.1554
(Uh(r⊙h))2 = −0.1×0.2600 + 0.9×(−0.1332) = −0.0260 − 0.1199 = −0.1459
ah = [0.5 + 0.1554,  0.4 − 0.1459] = [0.6554, 0.2541]
h̃ = [tanh(0.6554), tanh(0.2541)] = [0.5753, 0.2488]

The blend. Unit by unit, spelled out:

ht,1 = (1 − 0.6748)×0.5 + 0.6748×0.5753 = 0.3252×0.5 + 0.3882 = 0.1626 + 0.3882 = 0.5508
ht,2 = (1 − 0.3775)×(−0.2) + 0.3775×0.2488 = −0.1245 + 0.0939 = −0.0306

Read the story in those numbers. Unit 1 had z = 0.67, so it took two thirds of its new value from the candidate — it is writing. Unit 2 had z = 0.38, so it kept 62% of its old value; the candidate pulled it upward from −0.2 toward +0.25, but only partway, landing at −0.03 — it is mostly remembering. And unit 1’s reset gate was 0.52, so its candidate was computed from a half-attenuated view of the past, while unit 2 at r = 0.67 let two thirds of the past through. Four independent decisions in one timestep, all learned, all differentiable.

See it: drive the gates yourself

Set the gates by hand and watch the arithmetic redo itself. There are four sliders, not two, because z and r are vectors: each hidden unit gets its own pair. The widget opens on exactly the values computed above — z = [0.6748, 0.3775], r = [0.5200, 0.6660] — so the readout should match the hand calculation digit for digit, and “Back to the hand example” returns you there.

Each number line shows ht−1 and h̃t as anchors with ht sliding between them — the convex combination made visual. Pin a unit’s z to 0 and that unit freezes no matter what its candidate says; pin it to 1 and its history is erased in one step; sweep r and the candidate itself moves, because a different amount of past feeds into it. Try u1 writes, u2 holds: one unit overwriting while the other keeps, on the same timestep, is the per-unit independence the prose above claims — and the two gradient meters at the bottom show what it costs, with (1 − z) and its twentieth power for each unit side by side.

The GRU blend — two gates, one step

Two number lines, one per hidden unit. Warm dot = previous state, blue dot = candidate, teal ring = the new state landing between them. Each unit gets its own pair of gates, exactly as in the real cell.

z u1 0.6748
z u2 0.3775
r u1 0.5200
r u2 0.6660

Common misconception. “z is the forget gate, so z near 1 means remember hard.” It is the other way round in the GRU’s convention, and the sign error is a genuinely common bug when people port LSTM intuition across. In the GRU, z is how much of the NEW candidate to write. z = 1 means overwrite completely — forget everything. z = 0 means keep the old state exactly. If you initialise bz positive "to encourage memory" you have done the opposite: a positive bias pushes σ toward 1, which makes the cell forget faster. To bias a GRU toward remembering, push bz negative.

From scratch, then nn.GRUCell

python — GRU forward, from scratch, matching the hand numbers
import numpy as np
sigmoid = lambda a: 1 / (1 + np.exp(-a))

Wz = np.array([[0.0, 0.6, 0.0], [0.0, -0.4, 0.0]])   # only column 'b' matters here
Uz = np.array([[0.3, 0.1], [-0.2, 0.5]]);  bz = np.array([0.0, 0.1])
Wr = np.array([[0.0, -0.3, 0.0], [0.0, 0.7, 0.0]])
Ur = np.array([[0.2, -0.4], [0.1, 0.3]]);  br = np.array([0.2, 0.0])
Wh = np.array([[0.0, 0.5, 0.0], [0.0, 0.4, 0.0]])
Uh = np.array([[0.7, 0.2], [-0.1, 0.9]]);  bh = np.array([0.0, 0.0])

def gru_step(x, h):
    z     = sigmoid(Wz @ x + Uz @ h + bz)          # how much to WRITE   (n,)
    r     = sigmoid(Wr @ x + Ur @ h + br)          # how much to READ    (n,)
    h_bar = np.tanh(Wh @ x + Uh @ (r * h) + bh)    # the candidate       (n,)
    return (1 - z) * h + z * h_bar, z, r, h_bar   # convex blend

x = np.array([0., 1., 0.])                          # one-hot for 'b'
h, z, r, hb = gru_step(x, np.array([0.5, -0.2]))
print(np.round(z,4), np.round(r,4), np.round(hb,4), np.round(h,4))
# [0.6748 0.3775] [0.52 0.666] [0.5753 0.2488] [ 0.5508 -0.0306]
# every number matches the hand calculation above

PyTorch packs the three gates into one fused matrix for speed — weight_ih is (3n, d) and weight_hh is (3n, n), stacked in the order r, z, n (PyTorch calls the candidate n). One matmul instead of three is a real kernel-level win, and it is why library GRUs are several times faster than a loop of three separate multiplies:

python — the fused library cell
import torch, torch.nn as nn

cell = nn.GRUCell(input_size=3, hidden_size=2)
print(cell.weight_ih.shape, cell.weight_hh.shape)   # (6, 3) (6, 2)  == (3n, d), (3n, n)

# whole sequences at once, cuDNN-fused:
gru = nn.GRU(input_size=128, hidden_size=256, num_layers=1, batch_first=True)
x = torch.randn(8, 50, 128)          # (batch, time, d)
out, hT = gru(x)
print(out.shape, hT.shape)         # (8, 50, 256)  (1, 8, 256)
print(sum(p.numel() for p in gru.parameters()))   # 296,448 — see Chapter 5

One caution when you compare your implementation against PyTorch: their candidate applies the reset gate after the hidden matmul, as tanh(Wnx + r ⊙ (Unh + bhn)), rather than before it. It is a different but equally valid variant — cuDNN-friendly, because the hidden matmul does not depend on r and can be issued earlier. Expect small numerical differences, not a bug.

In a GRU, why is the reset gate applied to ht−1 inside the candidate rather than to the final ht?

Chapter 5: GRU vs LSTM

The GRU was not the first gated cell. Hochreiter and Schmidhuber solved the vanishing-gradient problem in 1997 with the LSTM — Long Short-Term Memory — seventeen years before Cho and colleagues proposed the GRU. The GRU is best understood as an answer to the question "how much of the LSTM is load-bearing?" So: put them side by side, count what each costs, and see what the literature actually found.

The LSTM, in the same notation

ft = σ( Wfxt + Ufht−1 + bf )  — forget gate
it = σ( Wixt + Uiht−1 + bi )  — input gate
ot = σ( Woxt + Uoht−1 + bo )  — output gate
gt = tanh( Wgxt + Ught−1 + bg )  — candidate
ct = ft ⊙ ct−1 + it ⊙ gt  — the memory
ht = ot ⊙ tanh(ct)  — the exposed output

Two structural differences jump out, and everything else follows from them.

First: the LSTM has two state vectors, the GRU has one. The LSTM keeps an internal cell state c — the actual long-term memory, never shown to the outside — and a separate hidden state h, which is a gated view of c produced by the output gate. The GRU has no such separation: its single h is both the memory and the output. So an LSTM can hold a fact in c while refusing to expose it (o ≈ 0), which is genuinely useful for latching a value that should influence a decision only much later. A GRU that wants to hide something must encode it in a direction the readout ignores — possible, but it has to learn that rather than getting it architecturally.

Second: the LSTM’s keep and write decisions are independent; the GRU’s are tied. In the GRU the coefficients are (1 − z) and z, so writing more necessarily means keeping less. In the LSTM, f and i are separate sigmoids. Set f = 1 and i = 1 and the cell state accumulates — the LSTM can add to memory without erasing any of it, which the GRU structurally cannot. That extra freedom is also the LSTM’s liability: c has no bound, can drift to large magnitudes, and needs that final tanh(c) to keep h in a sane range.

The GRU is a bet, and you can name it. Cho’s bet was that the output gate is optional and that coupling forget-and-write costs little. Empirically that bet mostly pays: Chung and colleagues (2014) found GRU and LSTM comparable on music and speech modelling, with the GRU converging faster; Jozefowicz and colleagues (2015) searched over ten thousand architectures and found the GRU beat the LSTM on most tasks except language modelling, where the LSTM held an edge once its forget-gate bias was initialised to 1. Greff and colleagues (2017) ablated eight LSTM variants and reported that the forget gate and the output activation were the components you cannot remove — and that the GRU-style couplings cost little. So: comparable quality, fewer parameters, one fewer state to manage.

Count the parameters by hand

Take d = 128 (input width) and n = 256 (state width). Every gate and every candidate has the same three pieces: an input matrix W of shape n × d, a recurrent matrix U of shape n × n, and a bias b of length n. Count one block:

one block = n·d + n2 + n = 256×128 + 256×256 + 256
= 32,768 + 65,536 + 256 = 98,560

Now multiply by the number of blocks. The GRU has three (z, r, candidate); the LSTM has four (f, i, o, candidate):

GRU = 3 × 98,560 = 295,680
LSTM = 4 × 98,560 = 394,240
difference = 98,560, ratio = 3/4 = 0.75

So a GRU is exactly 25% smaller than an LSTM at the same width, and the general formulas are GRU = 3(nd + n2 + n) and LSTM = 4(nd + n2 + n). If you want to spend the saving on width instead, solve 3(nd + n2) = 394,240 for n at d = 128: n2 + 128n − 131,413 = 0, giving n = (−128 + √(16,384 + 525,652))/2 = (−128 + 736.2)/2 ≈ 304. A 304-wide GRU costs about what a 256-wide LSTM costs — a genuinely useful trade when memory, not depth, is the binding constraint.

Reconciling with PyTorch’s number. Chapter 4’s code printed 296,448 for a d = 128, n = 256 nn.GRU, not 295,680. The gap is exactly 768 = 3 × 256. PyTorch carries two bias vectors per block (bias_ih and bias_hh) instead of one, which is mathematically redundant — their sum is the only thing that matters — but preserved for cuDNN kernel compatibility. Always reconcile a hand count against the framework before concluding you misunderstand the architecture; the discrepancy is usually a bias convention, not a mistake.

The gradient path, compared with numbers

Both cells beat the vanilla RNN for the same structural reason — an additive path whose backward multiplier the network controls — but the two multipliers are different quantities and it is worth seeing them side by side.

In the GRU, the state update is a convex blend, so the per-step Jacobian is approximately diag(1 − zt). In the LSTM, the cell update ct = f⊙ct−1 + i⊙g has per-step Jacobian approximately diag(ft). Same shape of result, different gate:

GRU across 30 steps at z = 0.1:  (1 − 0.1)30 = 0.930 = 0.0424
LSTM across 30 steps at f = 0.9:  0.930 = 0.0424
vanilla RNN across 30 steps at 0.45:  0.4530 = 3.9 × 10−11

Identical for the two gated cells, and nine orders of magnitude better than the plain cell. Push the gate further: at z = 0.01 (or f = 0.99) the 30-step factor is 0.9930 = 0.740 — the gradient arrives essentially intact. That is the whole benefit, and it is available to both architectures, which is why the literature keeps finding them within noise of each other.

The practical difference is where the default sits. An untuned LSTM initialises bf at zero, so f starts at σ(0) = 0.5, and 0.530 = 9.3 × 10−10 — a cell that forgets fast before it has learned anything. Setting bf = 1 moves the start to σ(1) = 0.731, and 0.73130 = 8.3 × 10−5, which is nearly five orders of magnitude more gradient at initialisation. That single line of initialisation is most of the reported LSTM-versus-GRU gap in the papers that tuned it.

The mirror-image trick for the GRU. The same reasoning says bias bz negative. At bz = −1, z starts at σ(−1) = 0.269, so the keep coefficient starts at 0.731 — exactly the LSTM’s tuned default, arrived at from the opposite direction because the GRU’s gate means write where the LSTM’s means keep. This is why the sign convention in Chapter 4 is worth memorising rather than re-deriving under pressure.

Compute cost, not just parameter cost

Parameters are memory; multiply-accumulates (MACs — one multiply plus one add, the unit of arithmetic work) are time. Per timestep, each block does one n×d matrix-vector product and one n×n product, so the GRU costs 3(nd + n2) MACs and the LSTM 4(nd + n2). At our sizes that is 295,000 versus 393,000 MACs per token — again the 3:4 ratio. For a 1,000-token sequence, roughly 0.3 versus 0.4 GMAC per sequence per layer.

But the number that actually decides wall-clock time on a GPU is neither of those. Both cells are sequential: step t cannot begin until step t − 1 finishes. A 1,000-token sequence means 1,000 dependent kernel launches, each doing a tiny matrix-vector product that leaves an A100 almost entirely idle. That is why the fused cuDNN kernel exists, why batch size matters far more for RNNs than for Transformers, and ultimately why the field moved on. Chapter 8 returns to this.

See it: the same sequence, both cells

Both cells are run below on an identical input sequence with identically-scaled random weights. Toggle between them and watch the state trajectories: the LSTM panel shows both c and h, so you can see the output gate hiding part of the memory; the GRU shows its single state. Slide the input drive to push the cells harder — the LSTM’s cell state grows past ±1 (it is unbounded) while the GRU’s state cannot leave [−1, 1], because a convex combination of things in [−1, 1] stays in [−1, 1]. The parameter panel recomputes both counts live as you change d and n.

GRU vs LSTM — states, gates, and parameter budget

Traces are per-unit state over 24 timesteps. Bottom band: the live parameter comparison at the chosen widths.

input d 128
state n 256
input drive 1.00

When each one actually wins

SituationPickBecause
Small or medium dataset, fast iterationGRU25% fewer parameters, fewer things to overfit, converges in fewer epochs (Chung 2014)
Large-scale language modellingLSTMJozefowicz 2015 found a persistent edge here, with forget-bias initialised to 1
Memory- or latency-bound deployment (embedded, on-device)GRUOne state vector to carry, 3/4 the MACs, 3/4 the weights to load
Very long dependencies with a value to latch and hideLSTMThe output gate decouples "remember" from "reveal"; c can accumulate without a convexity cap
You need an unbounded accumulator (counting, running sums)LSTMf = i = 1 makes c grow; the GRU’s convex blend cannot exceed its inputs
Honest default when you have no strong priorGRUCheaper to train and tune; if it plateaus, swapping in an LSTM is a two-line experiment
Common misconception. “The LSTM is strictly more expressive, so it is strictly better given enough data.” Extra gates buy expressiveness and extra ways to be badly conditioned. The LSTM’s famous default — initialise the forget-gate bias to 1 — exists precisely because the untuned LSTM starts out forgetting too fast and trains worse than a GRU that needed no such trick. Greff’s 2017 ablation is the cleanest statement of the situation: across eight variants, none significantly beat the vanilla LSTM, and the GRU-style simplifications did not significantly hurt. When architectures are within noise of each other, the cheaper one wins on every axis you can actually measure.

Both cells, from scratch, side by side

python — the two cells, same interface
import numpy as np
sig = lambda a: 1/(1+np.exp(-a))

def gru_step(x, h, P):
    z = sig(P['Wz']@x + P['Uz']@h + P['bz'])
    r = sig(P['Wr']@x + P['Ur']@h + P['br'])
    c = np.tanh(P['Wh']@x + P['Uh']@(r*h) + P['bh'])
    return (1-z)*h + z*c                    # ONE state out

def lstm_step(x, h, c, P):
    f = sig(P['Wf']@x + P['Uf']@h + P['bf'])   # keep how much of c?
    i = sig(P['Wi']@x + P['Ui']@h + P['bi'])   # write how much of g? (INDEPENDENT of f)
    o = sig(P['Wo']@x + P['Uo']@h + P['bo'])   # reveal how much of c?
    g = np.tanh(P['Wg']@x + P['Ug']@h + P['bg'])
    c = f*c + i*g                            # unbounded accumulator
    return o*np.tanh(c), c                   # TWO states out

def n_params(d, n, gates):
    return gates * (n*d + n*n + n)
print(n_params(128,256,3), n_params(128,256,4))   # 295680 394240
python — the library swap is one word
import torch.nn as nn
gru  = nn.GRU (128, 256, batch_first=True)   # out, h    = gru(x)
lstm = nn.LSTM(128, 256, batch_first=True)   # out,(h,c) = lstm(x)   <- note the tuple

# the LSTM trick everyone forgets: bias the forget gate toward remembering.
# PyTorch packs biases as [i, f, g, o], so the forget slice is n : 2n.
for name, p in lstm.named_parameters():
    if 'bias' in name:
        n = p.shape[0] // 4
        p.data[n:2*n].fill_(1.0)      # f-gate bias = 1 -> sigmoid(1) = 0.73 keep by default
At the same input and state widths, a GRU has exactly three quarters of an LSTM’s parameters. What is the structural reason?

Chapter 6: Training a Character-Level GRU

We have a cell that remembers and a gradient that survives. Now assemble an actual trainable model: read a stream of characters, predict the next one at every position, and learn from the mistakes. This is the character-level language model, the standard proving ground for recurrent architectures, and it is exactly what Chapter 7 will run live in this page.

The loss: cross-entropy, computed by hand

At each timestep the readout produces d logits — one score per vocabulary item — and softmax turns them into probabilities. The cross-entropy loss is the negative log of the probability assigned to the character that actually came next:

pt = softmax(yt),   Lt = −ln pt[targett]

Concretely, with a vocabulary of 5 and logits y = [2.0, 0.5, −1.0, 0.3, 1.2], target index 0. Exponentiate each:

e2.0=7.3891,  e0.5=1.6487,  e−1.0=0.3679,  e0.3=1.3499,  e1.2=3.3201
sum = 7.3891 + 1.6487 + 0.3679 + 1.3499 + 3.3201 = 14.0756
p = [0.5250, 0.1171, 0.0261, 0.0959, 0.2359]  (each exp divided by 14.0756)
L = −ln(0.5250) = 0.6444 nats

Sanity-check that against the baselines. A uniform guesser scores −ln(1/5) = ln 5 = 1.6094, so this model is already well ahead of chance. A perfect model scores 0. The unit is nats; divide by ln 2 = 0.6931 to get bits per character, which is the number people quote for character models — 0.6444 / 0.6931 = 0.93 bits per character here.

The gradient of cross-entropy with respect to the logits is famously clean. All the softmax algebra cancels and what remains is "predicted minus actual":

∂L/∂y = p − onehot(target) = [0.5250 − 1, 0.1171, 0.0261, 0.0959, 0.2359]
= [−0.4750, 0.1171, 0.0261, 0.0959, 0.2359]

Read the sign structure: the correct class gets a negative gradient (push its logit up), every wrong class gets a positive one (push theirs down), and the magnitudes sum to zero so the total "probability mass" is conserved. This single vector is where every gradient in the network originates.

Teacher forcing: what goes in at step t

There are two things you could feed the model at step t during training: the character the model predicted at step t − 1, or the character that actually appeared. Feeding the truth is called teacher forcing, and it is what essentially every sequence model is trained with.

The reason is conditioning, not laziness. Feed back the model’s own early-training predictions and they are noise, so the state is garbage, so the loss carries no useful signal about the cell’s parameters — the model has to get step 1 right before step 2 can teach it anything. Teacher forcing decouples the steps: every position gets a clean, correct history and learns its own conditional independently. Training is faster and vastly more stable.

Common misconception. “Teacher forcing is just an implementation detail.” It creates a real train/test mismatch called exposure bias: at training time the model has only ever seen ground-truth prefixes, but at generation time it consumes its own output, so one bad sample puts it in a state it was never trained on and errors compound. This is why sampled text drifts into nonsense after a while, and why the temperature knob below matters so much — lower temperature keeps the model nearer the distribution it was actually trained on. Scheduled sampling and related fixes exist; in practice most systems just live with it.

The training loop, assembled

1 · Slice a chunk
Take k = 25 consecutive characters. Inputs are chars 0..24, targets are chars 1..25 — the same array, shifted by one.
2 · Forward, caching everything
Run the GRU over the chunk starting from the carried state. Store z, r, candidate, h and p at every t — the backward pass needs all of them.
3 · Sum the loss
Add up −ln pt[targett] across the 25 positions. One chunk gives 25 supervised examples, not one.
4 · Backward through time
Walk t from 25 down to 1, accumulating into every gradient with +=. Gradient into ht arrives from two places: this step’s output and the next step’s recurrence.
5 · Clip
Clamp every gradient to [−5, 5] (or rescale by global norm). Cheap insurance against the Chapter 3 explosion.
6 · Update, then carry the state
One optimiser step. Then keep h, drop its gradient — the next chunk continues the same memory but starts a fresh graph.
↻ next chunk

Sampling: temperature, with numbers

To generate, feed a seed character, take the output distribution, draw a character from it, feed that back, repeat. The only knob is temperature T, which divides the logits before softmax:

p = softmax( y / T )

Take the same logits [2.0, 0.5, −1.0, 0.3, 1.2] and watch the top probability move:

Tscaled logitsp(top)behaviour
0.5[4.0, 1.0, −2.0, 0.6, 2.4]0.7767sharpened — safe, repetitive, loops
1.0[2.0, 0.5, −1.0, 0.3, 1.2]0.5250the model’s honest distribution
2.0[1.0, 0.25, −0.5, 0.15, 0.6]0.3580flattened — creative, then incoherent

Check the T = 0.5 row by hand: exponentiate [4.0, 1.0, −2.0, 0.6, 2.4] to get [54.598, 2.718, 0.135, 1.822, 11.023], sum 70.297, and 54.598/70.297 = 0.7767. Halving the temperature took the top character from 52% to 78%. In the limit T → 0 sampling becomes argmax; as T → ∞ it becomes uniform. T is not a property of the model — it is a decoding choice you make after training, and you can change it per generation.

Temperature — reshaping a distribution you already have

The bars are softmax(y/T) for the logits above. Press Sample to draw from the current distribution — the tally shows what 200 draws actually look like at this temperature.

temperature 1.00

The complete GRU, forward and backward, from scratch

This is the entire model — no autograd, no library cell, every derivative written out. It is the same algorithm the Chapter 7 lab runs in JavaScript. Read the backward pass slowly: each block undoes exactly one line of the forward pass, in reverse order.

python — char-level GRU, forward + backward, complete
import numpy as np
sig = lambda a: 1/(1+np.exp(-a))

def init(V, H, seed=0):
    g = np.random.default_rng(seed);  s = 0.08
    P = {}
    for k in ('z','r','h'):                 # three blocks: update, reset, candidate
        P['W'+k] = g.standard_normal((H,V))*s   # (H,V) input->hidden
        P['U'+k] = g.standard_normal((H,H))*s   # (H,H) hidden->hidden
        P['b'+k] = np.zeros(H)
    P['Wy'] = g.standard_normal((V,H))*s        # (V,H) readout
    P['by'] = np.zeros(V)
    return P

def loss_and_grads(P, inputs, targets, h0, V, H):
    """inputs/targets: lists of int char ids, len T. h0: (H,) carried state."""
    T = len(inputs)
    hs = {-1: h0.copy()}; zs, rs, cs, ps = {}, {}, {}, {}
    loss = 0.0

    # ---------- FORWARD ----------
    for t in range(T):
        i = inputs[t]                                  # one-hot => COLUMN LOOKUP
        hp = hs[t-1]
        zs[t] = sig(P['Wz'][:,i] + P['Uz']@hp + P['bz'])
        rs[t] = sig(P['Wr'][:,i] + P['Ur']@hp + P['br'])
        cs[t] = np.tanh(P['Wh'][:,i] + P['Uh']@(rs[t]*hp) + P['bh'])
        hs[t] = (1-zs[t])*hp + zs[t]*cs[t]
        y     = P['Wy']@hs[t] + P['by']
        y    -= y.max()                                # stability: shift before exp
        e     = np.exp(y); ps[t] = e/e.sum()
        loss += -np.log(ps[t][targets[t]] + 1e-12)

    # ---------- BACKWARD ----------
    G  = {k: np.zeros_like(v) for k, v in P.items()}
    dh_next = np.zeros(H)
    for t in reversed(range(T)):
        i, hp = inputs[t], hs[t-1]
        dy = ps[t].copy(); dy[targets[t]] -= 1          # p - onehot
        G['Wy'] += np.outer(dy, hs[t]); G['by'] += dy
        dh = P['Wy'].T @ dy + dh_next               # output path + recurrent path

        # h = (1-z)*hp + z*c
        dz  = dh * (cs[t] - hp)
        dc  = dh * zs[t]
        dhp = dh * (1 - zs[t])                        # <-- the identity highway

        # c = tanh(Wh[:,i] + Uh@(r*hp) + bh)
        da  = dc * (1 - cs[t]**2)
        G['Wh'][:,i] += da; G['bh'] += da
        G['Uh'] += np.outer(da, rs[t]*hp)
        drh  = P['Uh'].T @ da                        # grad wrt (r*hp)
        dr   = drh * hp
        dhp += drh * rs[t]

        # z = sigmoid(...)  and  r = sigmoid(...)
        daz = dz * zs[t] * (1 - zs[t])
        G['Wz'][:,i] += daz; G['bz'] += daz
        G['Uz'] += np.outer(daz, hp); dhp += P['Uz'].T @ daz

        dar = dr * rs[t] * (1 - rs[t])
        G['Wr'][:,i] += dar; G['br'] += dar
        G['Ur'] += np.outer(dar, hp); dhp += P['Ur'].T @ dar

        dh_next = dhp                                  # hand it one step further back

    for k in G: np.clip(G[k], -5, 5, out=G[k])       # explosion insurance
    return loss/T, G, hs[T-1]

Four details in there are the difference between working and not. dh = Wy.T@dy + dh_next — gradient arrives at ht from two paths and both must be summed. Every parameter gradient uses += because of weight sharing. G['Wh'][:,i] += da touches only one column, because a one-hot input activates one column. And dhp = dh*(1-z) is the identity highway from Chapter 4, visible in the code: when z is near 0, the gradient passes back multiplied by ~1.

Wrap it in Adagrad — per-parameter learning rates that shrink for parameters receiving large gradients — and you have a complete trainer:

python — the training loop, truncated BPTT + Adagrad + sampling
text = open('input.txt').read()
chars = sorted(set(text)); V = len(chars); H, K, LR = 128, 25, 0.1
stoi = {c:i for i,c in enumerate(chars)}
data = [stoi[c] for c in text]

P  = init(V, H)
M  = {k: np.zeros_like(v) for k,v in P.items()}       # Adagrad accumulators
h  = np.zeros(H); pos = 0; smooth = -np.log(1.0/V)      # start at chance

for step in range(20000):
    if pos + K + 1 >= len(data):
        pos, h = 0, np.zeros(H)                        # wrap: only here do we reset memory
    xs, ys = data[pos:pos+K], data[pos+1:pos+K+1]      # targets = inputs shifted by one
    L, G, h = loss_and_grads(P, xs, ys, h, V, H)        # h carries; its GRAD does not
    for k in P:
        M[k] += G[k]*G[k]
        P[k] -= LR * G[k] / (np.sqrt(M[k]) + 1e-8)
    smooth = 0.999*smooth + 0.001*L
    pos += K

def sample(P, h, seed_id, n, temp=1.0):
    out, i = [], seed_id
    for _ in range(n):
        z = sig(P['Wz'][:,i] + P['Uz']@h + P['bz'])
        r = sig(P['Wr'][:,i] + P['Ur']@h + P['br'])
        c = np.tanh(P['Wh'][:,i] + P['Uh']@(r*h) + P['bh'])
        h = (1-z)*h + z*c
        y = (P['Wy']@h + P['by']) / temp        # temperature acts on LOGITS
        e = np.exp(y - y.max()); p = e/e.sum()
        i = np.random.choice(len(p), p=p)          # feed the SAMPLE back — exposure bias lives here
        out.append(i)
    return out

And the same model in PyTorch, for the version you would actually ship. Note how detach() is the one line implementing truncation, and view(-1, V) flattens time into the batch dimension so a single cross-entropy call covers all 25 positions:

python — the same model, library edition
import torch, torch.nn as nn

class CharGRU(nn.Module):
    def __init__(self, V, H):
        super().__init__()
        self.emb = nn.Embedding(V, H)      # the one-hot column lookup, made explicit
        self.gru = nn.GRU(H, H, batch_first=True)
        self.out = nn.Linear(H, V)
    def forward(self, x, h=None):
        e, h = self.emb(x), h
        o, h = self.gru(e, h)              # o: (B, T, H)
        return self.out(o), h             # logits: (B, T, V)

model = CharGRU(V, 128); opt = torch.optim.Adam(model.parameters(), lr=2e-3)
h = None
for xb, yb in loader:                      # xb, yb: (B, 25) int64
    logits, h = model(xb, h)
    h = h.detach()                        # TRUNCATION — value kept, graph cut
    loss = nn.functional.cross_entropy(logits.view(-1, V), yb.view(-1))
    opt.zero_grad(); loss.backward()
    nn.utils.clip_grad_norm_(model.parameters(), 5.0)
    opt.step()
What good looks like. On a small English corpus with V ≈ 60 and H = 128, the loss starts at ln(60) = 4.09 nats and should be under 2.5 within a few hundred steps and near 1.5 after a few thousand. Text quality follows a reliable script: first the model learns the space distribution, then word-like letter clusters, then real short words, then quotes and brackets that close. If your loss sits at 4.09 forever, the gradient is not reaching the parameters — check for = where you meant +=. If it drops to 3.5 and stops, the learning rate is probably too high and the model is bouncing.
Sampling at temperature 0.5 instead of 1.0 does what to the model?

Chapter 7: The Live Lab

Everything from Chapters 1 through 6 is now running in this page. Below is a real character-level GRU — the exact cell from Chapter 4, the exact backward pass from Chapter 6, translated to JavaScript and training in your browser on a small corpus, with no server and no library. Press Train and watch the loss fall.

This is not an animation of training. Every number you see is computed: the loss curve is the running cross-entropy, the sampled text comes from the current weights, and the gate strips are the actual z and r values from the most recent forward pass. If you set the learning rate to 5 and the model diverges, it diverged.

What each panel is telling you

Live GRU — trains in your browser, no library

Cross-entropy per character (nats). Dashed line = chance, ln(V). Lower is better.

learning rate 0.100
hidden units 32
temperature 0.80

GENERATED SAMPLE

Gate activity over the last timesteps — rows are hidden units (the first 32), columns are time (newest at right).

Reading the loss curve, in numbers

The vertical axis is nats per character, and nats are only meaningful against a baseline. The corpus here has V = 24 distinct characters, so a model that has learned nothing scores ln(24) = 3.178 nats, which is the dashed line. Divide by ln 2 = 0.693 to convert: 3.178 / 0.693 = 4.59 bits per character. That is the price of naming one of 24 symbols with no knowledge at all.

Now watch what each stage of learning buys. The first thing any character model finds is the unigram distribution — how often each character appears, ignoring context. In this corpus space and e and t are far more common than k or v, and exploiting that alone typically drops the loss to around 2.7–2.9 nats within the first few dozen steps. No memory is involved; that is what the readout bias by learns, on its own, almost immediately.

The next drop is the bigram structure: after t comes h, after a space comes a word-initial letter, after q comes u. That needs the input matrix Wh but barely any state, and it takes the loss into the low twos. Everything below that is genuinely recurrent — it requires the state to be carrying information from more than one character back, and it is the part the gates make possible.

So read the curve in three regimes: still-high and slowly falling means letter frequencies; the middle band means pairs and triples; below that is real context. At the default H = 32 the model blows through all three in roughly a hundred steps — it is under 0.25 by step 200 — because 270 characters is small enough to compress almost perfectly. To watch the regimes separately, slow it down: at learning rate 0.001 the curve parks at 2.63 and stays there, which is the “frequencies and nothing else” plateau made visible. A curve that flattens there is telling you about capacity or step size, not about the data.

One caveat on the number itself: the loss displayed is an exponential moving average with a decay of 0.97, so it lags the instantaneous loss by roughly 1/(1 − 0.97) ≈ 33 steps. That smoothing is why the curve looks clean, and also why a divergence you cause with a huge learning rate takes a moment to show up on the plot even though the sampled text degrades instantly.

Experiments to run right now

The point of a lab is to break things on purpose. Each of these produces a distinct, diagnosable failure — run them and match the symptom to the cause you already know.

Do thisWhat you will seeWhy
Learning rate 5, run 200 stepsLoss spikes above chance, sample becomes one repeated characterSteps overshoot the minimum; without clipping this would be NaN. The clip at ±5 is why it merely thrashes instead of dying.
Learning rate 0.001Loss barely moves in 500 stepsAdagrad’s accumulator shrinks the effective rate further over time; too small a base rate never escapes the initial plateau.
Hidden units 2Loss stalls around 2.3–2.6 and never approaches 1.5Two numbers cannot compress the corpus. This is capacity, not optimisation — more steps will not help.
Hidden units 64, run 1000 stepsLowest loss, most word-like samplesMore state, more memory, more parameters. Also the slowest per step — the cost is real.
Temperature 0.2 after trainingShort repeating loops of the most common wordsNear-argmax decoding collapses onto the highest-probability continuation and cycles.
Temperature 2.0 after trainingLetter soup, but with the right letter frequenciesFlattening the distribution keeps the marginal statistics and destroys the conditional structure.
Watch the gate strips as the loss falls. At initialisation both gates hover near 0.5 everywhere — the sigmoids see near-zero pre-activations, so the cell is doing something bland and averaged. As training proceeds the strips develop structure: certain rows go persistently dark in z (units that have learned to hold), and the r strip starts showing vertical dark bands aligned with spaces and punctuation (the cell learning that a word boundary is a good place to stop reading the past). Nobody programmed that. It is what "learning to remember" looks like from the inside.
Common misconception. “This corpus is tiny, so the model is just memorising it, and that proves nothing.” It is partly memorising — with a few hundred characters and 32 hidden units that is expected and fine. But memorisation through a 32-number bottleneck is exactly the interesting part: the model cannot store the corpus, so it must store a compressed procedure for regenerating it, and that procedure is what a language model is. Scale changes the numbers and not the mechanism. The loss you watch fall here falls for the same reason it falls on a billion tokens.

Why the browser can do this at all

Count the work. With H = 32 hidden units and V = 24 vocabulary items, one timestep costs three blocks × (H·V + H2) = 3 × (768 + 1024) = 5,376 multiply-accumulates forward, and roughly twice that backward. A 25-step chunk is therefore around 25 × 5,376 × 3 ≈ 400,000 operations per training step — a few milliseconds of plain JavaScript. Eight steps per animation frame is comfortable, even on a phone.

Push H to 64 and the H2 term quadruples while the H·V term merely doubles, so cost grows faster than width — the same n2 scaling that made Chapter 5’s parameter count dominated by the recurrent matrices. You can feel the frame rate change. That is the recurrence tax, measured on your own hardware.

The lab is the real test: if you can predict what each slider will do before you move it, and explain the result afterwards using z, r and the loss curve, you understand the GRU. One question to check that, then we follow the lineage forward.

You set hidden units to 2 and train for a thousand steps. The loss falls from 3.3 to about 2.4 and then flatly refuses to improve. What is the diagnosis?

Chapter 8: Beyond the Cell

A single GRU layer reading left to right is the atom. Real systems compose it three ways — stack it, run it in both directions, and split it into an encoder and a decoder — and the third of those runs into a wall that changed the whole field. Then we follow the lineage forward to the architectures that inherited from it.

Stacking: depth in the feature direction

A stacked (or deep) RNN feeds the output sequence of layer 1 into layer 2 as its input sequence. Layer 1 sees characters and produces a sequence of 256-dim states; layer 2 sees that sequence and produces its own. Each layer keeps its own hidden state and its own weights.

Count the cost, because it is not what people guess. With d = 128 and n = 256, layer 1 costs 3(256×128 + 2562 + 256) = 295,680. Layer 2’s input is now 256-wide, not 128, so it costs 3(256×256 + 2562 + 256) = 3 × 131,328 = 393,984. Two layers total 689,664 — not double the first layer but 2.33×, because the second layer’s input matrix grew. Every layer after the first costs the same 393,984.

Depth here buys abstraction, not reach. Layer 1 learns character-level regularities; layer 2 learns patterns over layer 1’s features. The temporal horizon does not improve just by stacking — each layer still faces the same recurrence. Two to four layers is the practical range; past that, gradients through depth and time become their own problem and you need residual connections between layers.

Bidirectional: reading the future

A bidirectional RNN runs two independent cells — one left-to-right, one right-to-left — and concatenates their states at each position. Output width doubles to 2n; parameters double to 591,360 for our example layer.

This is a strict upgrade for tagging tasks — named-entity recognition, part-of-speech, acoustic frame classification — because the label at position t genuinely depends on words after t. "Bank" is disambiguated by what follows it as much as by what precedes it.

And it is completely unusable for generation. To run the backward pass the model needs the whole sequence up front, but a language model generating token t by definition has not produced tokens t + 1 onward. The information is not merely unavailable, it does not exist yet. This is the same constraint that forces causal masking in decoder-only Transformers, and it is worth recognising as one constraint wearing two costumes.

Common misconception. “Bidirectional means the model attends both ways at each step.” It does not. The two directions never interact during the recurrence — they are two separate passes over the same sequence, and they only meet in the concatenation at the end. The forward cell never sees a single number computed by the backward cell. If you want genuine two-way interaction at every layer, you need attention, not bidirectionality.

Encoder–decoder: the bottleneck that created attention

For translation you need a variable-length input to become a variable-length output. The 2014 sequence-to-sequence answer: an encoder RNN reads the source sentence and its final hidden state becomes the context vector c; a decoder RNN is initialised with c and generates the target one token at a time.

Encoder GRU
Reads the source sentence token by token. Its final state hT is the only thing that survives.
↓ c = hT, one fixed 256-dim vector
Decoder GRU
Initialised from c. Generates the target one token at a time, conditioning on c and its own output so far.
The problem
c has the same size whether the source was 5 words or 50. Information per source token falls as 1/T.

Put a number on the squeeze. A 256-dim state is 256 floats regardless of input length. Encoding a 5-token sentence gives roughly 51 dimensions per token; a 50-token sentence gives 5.1; a 100-token paragraph gives 2.6. The budget is constant while the demand grows linearly. Cho and colleagues measured exactly this in 2014: encoder–decoder BLEU — an n-gram overlap score for translation quality, running from 0 to 100 — held up to about 20 source words and then fell off sharply, and the fall tracked sentence length rather than sentence difficulty.

Bahdanau, Cho and Bengio’s fix in 2015 was not a better encoder. It was to stop insisting on one vector: keep every encoder state h1…hT, and let the decoder compute a fresh weighted average of them at every output step, with weights it chooses based on what it is generating right now. That weighted read is attention. The length curve flattened out immediately.

The line from here to the Transformer is short and worth saying out loud. Attention was invented as a patch on the RNN bottleneck — a way to read the encoder’s whole history instead of its summary. It worked so well that in 2017 Vaswani and colleagues asked what happens if you keep the patch and delete the patient. Attention Is All You Need is the answer: no recurrence, all attention, fully parallel training. The Transformer is not a rejection of the RNN — it is the RNN’s workaround, promoted to the whole architecture. Chapter 9 links you to that lesson.

Put a number on the squeeze

The information argument is worth making precisely, because it is the reason attention exists and people usually wave at it. Suppose a source vocabulary of 30,000 word types. Naming one word takes at most log2(30,000) = 14.87 bits. A 50-word sentence therefore carries at most 50 × 14.87 = 743 bits of identity information; a 500-word document carries 7,436 bits.

Against that, a 256-dimensional float32 context vector is nominally 256 × 32 = 8,192 bits — but that is a wild overestimate of what it can actually convey, because the values are continuous, noisy, and the decoder has to read them through a learned map that is far from a lossless decoder. A realistic effective capacity is a few bits per dimension at best, so call it several hundred bits. The 50-word sentence sits comfortably inside that. The 500-word document does not, and no training run changes the arithmetic.

This is why the failure is gradual and length-dependent rather than abrupt. Cho and colleagues plotted BLEU against source length in 2014 and saw exactly that shape: flat to about 20 words, then a steady decline. The model was not getting worse at translation; it was running out of channel.

Depth does not fix it, and here is why. A natural reflex is to stack more encoder layers. It does not help, because the bottleneck is the width of the single vector crossing the encoder–decoder boundary, not the depth of the computation producing it. Widening c from 256 to 1,024 helps a little and costs 16× in the recurrent matrices (n2 scaling). Attention helps enormously and costs O(T) memory for the encoder states you were computing anyway. Once you see the constraint as a channel-capacity problem, the fix is obvious and the alternatives are visibly wrong.

The lineage: what each successor kept and changed

The recurrent idea did not die; it went quiet for six years and came back once people wanted constant-memory inference again. Every architecture below keeps the fixed-size state that Chapter 1 introduced. What they change is how the state is updated — and almost all of them change it for the same reason: to make training parallel over time.

ArchitectureKept from the RNNChangedWhy
LSTM (1997)Recurrent fixed stateSplit state into c and h; three gatesAdditive cell path gives a near-identity gradient highway
GRU (2014)Gating, additive pathDropped the output gate; merged c and h; coupled forget and write25% fewer parameters at comparable quality
Seq2seq + attention (2015)The RNN cell itselfDecoder reads all encoder states, not just the lastRemoves the fixed-vector bottleneck
Transformer (2017)Nothing recurrentRecurrence deleted; attention over the whole sequenceFully parallel training; cost is a KV cache that grows with length
RWKV (2023)Fixed state, constant-memory decodeGates replaced by a time-decay linear-attention recurrenceHas both a parallel training form and a recurrent inference form
Mamba / S6 (2023)Fixed state, linear-time scanLearned gates replaced by an input-dependent state-space recurrence, run as a parallel scanSelectivity gives content-based forgetting; the scan gives GPU parallelism
xLSTM (2024)LSTM gating philosophyExponential gating with a stabiliser; matrix memory; a parallelisable variantFixes the LSTM’s two flaws: cannot revise, cannot parallelise
Griffin / Hawk (2024)A real gated linear recurrenceInterleaves the recurrence with local attention blocksRecurrence for the long tail, attention for the local detail

Notice the pattern in the "Changed" column. Between 2014 and 2017 the field traded the fixed state away for parallel training. From 2023 onward it has been trying to buy the fixed state back without giving the parallelism up — by finding recurrences whose update is linear enough in the state to be computed with an associative scan. The GRU’s nonlinear tanh candidate is precisely what blocks that, which is why none of the modern cells keep it.

See it: the family tree

Click any node to read what it kept, what it changed, and how it sits on the two axes that matter: how much of the state is fixed-size (vertical) and whether training parallelises over time (horizontal). The GRU sits in the top-left — fully fixed state, fully sequential training — and every arrow out of it is somebody trying to move right without falling down.

The recurrent lineage — click a node

Vertical: fixed-size state (up) vs growing cache (down). Horizontal: sequential training (left) vs parallel over time (right).

All three compositions, in code

python — stacked, bidirectional, and encoder-decoder
import torch, torch.nn as nn

# 1) STACKED — depth in features, not in time
deep = nn.GRU(128, 256, num_layers=2, dropout=0.2, batch_first=True)
out, h = deep(torch.randn(4, 50, 128))
print(out.shape, h.shape)     # (4,50,256)  (2,4,256)  <- one state PER LAYER
print(sum(p.numel() for p in deep.parameters()))   # 691,200 (with torch's 2 biases)

# 2) BIDIRECTIONAL — tagging only, never generation
bi = nn.GRU(128, 256, bidirectional=True, batch_first=True)
out, h = bi(torch.randn(4, 50, 128))
print(out.shape, h.shape)     # (4,50,512)  (2,4,256)  <- width DOUBLES

# 3) ENCODER-DECODER — and the bottleneck, made visible
class Seq2Seq(nn.Module):
    def __init__(self, V, H):
        super().__init__()
        self.enc = nn.GRU(H, H, batch_first=True)
        self.dec = nn.GRU(H, H, batch_first=True)
        self.emb = nn.Embedding(V, H); self.out = nn.Linear(H, V)
    def forward(self, src, tgt_in):
        _, c = self.enc(self.emb(src))       # c: (1,B,H) — EVERYTHING the source became
        o, _ = self.dec(self.emb(tgt_in), c) # the decoder only ever sees c
        return self.out(o)
# src of length 5 and src of length 500 produce the SAME size c.
# That single fact is what attention was invented to remove.
Why can a bidirectional GRU not be used as a language model that generates text?

Chapter 9: Cheat Sheet & Connections

Everything in one place, with every symbol spelled out in words. Nothing new is introduced here — this is the page to come back to.

Every equation, every symbol

EquationRead it as
ht = tanh(Wxxt + Whht−1 + b) The vanilla RNN cell. xt is the input at time t (length d). h is the hidden state (length n) — the entire memory. Wx (n×d) says what a new symbol contributes. Wh (n×n) says how the old memory is rewritten. b is a per-unit resting level. tanh squashes each component into (−1, 1).
∂ht/∂ht−1 = DtWh The per-step backward multiplier. Dt = diag(1 − ht2), the tanh derivative for each unit. Chain T of these and you get the gradient across T steps.
‖∂hT/∂h0‖ ≤ (γ·σmax)T The vanish/explode bound. γ is the largest tanh derivative (at most 1). σmax is the largest singular value of Wh — the biggest stretch it applies. Below 1: guaranteed geometric decay. Above 1: free to explode.
if ‖g‖ > c: g ← c·g/‖g‖ Gradient clipping. g is the concatenated gradient of every parameter, c the threshold (typically 1–5). Direction preserved, length capped. Cures explosion only.
zt = σ(Wzxt + Uzht−1 + bz) The GRU update gate: how much of the new candidate to write, per unit, in [0,1]. z near 0 = keep the old state. z near 1 = overwrite it. σ is the logistic sigmoid.
rt = σ(Wrxt + Urht−1 + br) The GRU reset gate: how much of the old state the candidate is allowed to read. r near 0 = propose content from the input alone.
t = tanh(Whxt + Uh(rt⊙ht−1) + bh) The candidate — what the cell would like the state to become. ⊙ is elementwise multiplication. The reset gate is applied before the recurrent matrix, so it filters the read without destroying the memory.
ht = (1−zt)⊙ht−1 + zt⊙h̃t The convex blend. Coefficients are non-negative and sum to 1, so the state can never grow past its inputs. When z → 0 the backward multiplier becomes the identity — the gradient highway.
ct = ft⊙ct−1 + it⊙gt,  ht = ot⊙tanh(ct) The LSTM. c is the cell state (private long-term memory), h the exposed output. f = forget (keep how much of c), i = input (write how much of the candidate g), o = output (reveal how much of c). f and i are independent, so c can accumulate.
Lt = −ln pt[targett],  ∂L/∂y = p − onehot Cross-entropy and its gradient. p = softmax of the logits y. The gradient is "predicted minus actual" — negative on the true class, positive on the rest, summing to zero.
p = softmax(y / T) Temperature sampling. T < 1 sharpens (safe, repetitive), T > 1 flattens (creative, incoherent), T → 0 is argmax. A decode-time choice, not a model property.
params = G·(nd + n2 + n) Parameter count. G = number of blocks: 3 for a GRU (z, r, candidate), 4 for an LSTM (f, i, o, candidate), 1 for a vanilla RNN. PyTorch adds one extra bias vector per block.

Which architecture, and why

Vanilla RNNGRULSTMTransformerSSM / Mamba
Blocks per cell134
State at decodefixed nfixed nfixed 2ngrows with lengthfixed
Train cost in lengthO(T), sequentialO(T), sequentialO(T), sequentialO(T2), parallelO(T log T) scan, parallel
Long dependenciesfails past ~20goodgoodexcellentexcellent
Parallel over timenononoyesyes
Pick it whenteaching, or truly short sequencessmall data, tight memory, on-device, streamingyou need an unbounded accumulator or to hide statequality at scale and you can afford the cachevery long context with constant-memory decode
The decision, made interactive

Set your three constraints and the chart scores each architecture against them. There is no single winner — the point is to see which constraint is doing the deciding.

seq length ~500
memory medium
train scale medium

Self-test: one GRU step, start to finish

Before the failure table, one worked example to check yourself against. Same cell as Chapter 4 — n = 2, previous state ht−1 = [0.5, −0.2], the same U matrices and biases — but now the input character is a, so the W columns are Wz = [−0.2, 0.5], Wr = [0.4, −0.1], Wh = [0.3, −0.6]. Cover the answers and compute it; the recurrent contributions Uzh = [0.13, −0.20] and Urh = [0.18, −0.01] are unchanged from Chapter 4, because ht−1 is the same.

az = [−0.2 + 0.13 + 0.0,  0.5 − 0.20 + 0.1] = [−0.07, 0.40]
z = [σ(−0.07), σ(0.40)] = [1/(1+e0.07), 1/(1+e−0.40)] = [0.4825, 0.5987]
ar = [0.4 + 0.18 + 0.2,  −0.1 − 0.01 + 0.0] = [0.78, −0.11]
r = [σ(0.78), σ(−0.11)] = [0.6857, 0.4725]
r ⊙ ht−1 = [0.6857×0.5,  0.4725×(−0.2)] = [0.34284, −0.09451]
(Uh(r⊙h))1 = 0.7×0.34284 + 0.2×(−0.09451) = 0.23999 − 0.01890 = 0.2211
(Uh(r⊙h))2 = −0.1×0.34284 + 0.9×(−0.09451) = −0.03428 − 0.08506 = −0.1193
ah = [0.3 + 0.2211,  −0.6 − 0.1193] = [0.5211, −0.7193]
h̃ = [tanh(0.5211), tanh(−0.7193)] = [0.4785, −0.6165]
ht,1 = 0.5175×0.5 + 0.4825×0.4785 = 0.25875 + 0.23090 = 0.4896
ht,2 = 0.4013×(−0.2) + 0.5987×(−0.6165) = −0.08026 − 0.36909 = −0.4494

Compare with Chapter 4, where the same state met the character b and landed at [0.5508, −0.0306]. One character of difference moved unit 2 from −0.03 to −0.45, because a drives its candidate strongly negative and opens its update gate wider (z2 = 0.60 here versus 0.38 there). That is the cell distinguishing two inputs from the same state — exactly the capability Chapter 0 said an MLP could not have.

Sizing a real one: the deployment budget

The other calculation worth being able to do from memory is the cost of a model you might actually ship. Take a two-layer GRU language model, input width d = 256, state width n = 512, vocabulary 50,000. One block costs n·d + n2 + n:

layer 1 block = 512×256 + 512×512 + 512 = 131,072 + 262,144 + 512 = 393,728  (×3 = 1,181,184)
layer 2 block = 512×512 + 512×512 + 512 = 262,144 + 262,144 + 512 = 524,800  (×3 = 1,574,400)
recurrent total = 1,181,184 + 1,574,400 = 2,755,584
readout = 50,000×512 + 50,000 = 25,650,000

Look at those last two lines together. The readout is nine times larger than the entire recurrent stack. That is the ordinary situation for character- and word-level language models at modest state widths, and it is why weight tying (sharing the readout matrix with the input embedding) is such a common trick: it removes more parameters than any change you could make to the cell.

Compute per token is 3(nd + n2) for layer 1 plus 3(2n2) for layer 2 = 1,179,648 + 1,572,864 = 2.75 million multiply-accumulates, which any phone does in well under a millisecond. The binding constraint is not arithmetic; it is that those steps are sequential.

And the payoff line, the one that keeps recurrence alive. Decode-time state is 2 layers × 512 floats × 4 bytes = 4,096 bytes, constant forever. A Transformer of the same width caches a key and a value per layer per token: 2 × 2 × 512 × 4 = 8,192 bytes per token, so at 10,000 generated tokens it is holding 82 MB and still growing. Four kilobytes versus eighty-two megabytes is the whole argument of Chapter 8’s bottom half, in two numbers.

The five things that actually break

SymptomCauseFix
Loss goes to NaN after a spikeExploding gradient (Ch 3)clip_grad_norm_(params, 5.0) between backward() and step()
Loss falls then plateaus far above a library baseline= where you needed += in the backward loopEvery shared parameter accumulates across timesteps
Model never learns dependencies past ~20 stepsVanishing gradient in a vanilla cell (Ch 3)Use a gated cell — clipping cannot help here
You biased bz positive "to remember" and it forgot fasterSign convention: GRU z is write, not keep (Ch 4)Bias bz negative to bias toward keeping
Generated text drifts into nonsense after 50 tokensExposure bias from teacher forcing (Ch 6)Lower the temperature; or train with scheduled sampling
Common misconception, one last time. “RNNs are obsolete, so this is history.” The vanilla cell is history. The recurrent idea — a fixed-size state carried across time, giving constant-memory decoding — is the most actively researched alternative to attention right now. Every architecture in Chapter 8’s bottom half is an RNN that solved the parallel-training problem. Understanding z and r is how you read those papers: when Mamba talks about selectivity it means input-dependent gating, and when xLSTM talks about revision it means a gate that can decisively overwrite. Same vocabulary, better engineering.

Where to go next

If you want more foundations on recurrence:

If you want to follow the lineage forward:

If you want to build with it:

The one-paragraph summary. A recurrent network carries a fixed-size hidden state and rewrites it at every step, which gives it unbounded context at constant memory. Training it means unrolling the loop and running the chain rule backwards, and that chain multiplies the same matrix T times, so gradients die or explode. Clipping fixes explosion. Gating fixes vanishing, by making the state update a learned convex blend whose backward multiplier can be the identity. The GRU does this with two gates and three weight blocks: z decides how much to write, r decides how much the candidate may read. That is the whole architecture, and everything after it is an attempt to keep the fixed state while recovering parallel training.
“What I cannot create, I do not understand.”
— Richard Feynman. You wrote the backward pass by hand in Chapter 6 and watched it train in Chapter 7. That is the test.
A colleague proposes replacing a GRU with a Transformer for on-device streaming speech recognition on a phone. What is the strongest technical objection?