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.
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 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
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:
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.
ls.
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 k | Input dimension 27k | First-layer weights (×100) | Can it see 50 back? |
|---|---|---|---|
| 1 | 27 | 2,700 | no |
| 5 | 135 | 13,500 | no |
| 20 | 540 | 54,000 | no |
| 50 | 1,350 | 135,000 | barely — and 51 breaks it |
| 500 | 13,500 | 1,350,000 | yes, 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.
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:
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.
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:
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.
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?"
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.
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).
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.
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.
l positions of hello?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".
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.
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:
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.
Vocabulary {a, b, c}, so d = 3. State size n = 2. Concrete weights, chosen to be readable rather than trained:
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].
Step 2 — read b, so Wxx2 = column 2 =
[−0.5, 0.8]. Now the recurrent term is live. Multiply row by row:
Step 3 — read c, so Wxx3 = column 3 =
[0.2, −0.3].
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.
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.
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.
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.
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 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:
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.
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:
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:
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:
and the gradient arriving at an earlier state is the gradient at the later one dragged back through the atoms:
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.
Set w = 0.8, u = 0.9, inputs x = [1, 1, 1], target y = 0.5, h0 = 0. Forward first:
The three squash derivatives, which we will reuse constantly:
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:
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):
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.
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):
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.
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.
Bars: |contribution to ∂L/∂w| per timestep, newest on the right. Shaded region = kept by truncation; faded bars = discarded.
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
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.
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:
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:
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.
Take norms. The norm of a product is at most the product of the norms, so:
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:
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.
"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 T | 0.45T (typical decay) | In words |
|---|---|---|
| 5 | 0.0185 | gradient is 1.8% of the local one — still learnable |
| 10 | 3.4 × 10−4 | 0.03% — marginal |
| 20 | 1.2 × 10−7 | ten-millionth — lost in float noise |
| 50 | 4.6 × 10−18 | below 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:
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.
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.
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.
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:
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.
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
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.
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.
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:
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):
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.
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.
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.
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:
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.
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.
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:
The update gate. First the recurrent contribution, row by row:
The reset gate. Same shape of calculation:
The gated state, then the candidate. Apply r elementwise before the Uh multiply — this is the step people skip:
The blend. Unit by unit, spelled out:
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.
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.
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.
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.
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.
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.
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:
Now multiply by the number of blocks. The GRU has three (z, r, candidate); the LSTM has four (f, i, o, candidate):
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.
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.
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:
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.
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.
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.
Traces are per-unit state over 24 timesteps. Bottom band: the live parameter comparison at the chosen widths.
| Situation | Pick | Because |
|---|---|---|
| Small or medium dataset, fast iteration | GRU | 25% fewer parameters, fewer things to overfit, converges in fewer epochs (Chung 2014) |
| Large-scale language modelling | LSTM | Jozefowicz 2015 found a persistent edge here, with forget-bias initialised to 1 |
| Memory- or latency-bound deployment (embedded, on-device) | GRU | One state vector to carry, 3/4 the MACs, 3/4 the weights to load |
| Very long dependencies with a value to latch and hide | LSTM | The output gate decouples "remember" from "reveal"; c can accumulate without a convexity cap |
| You need an unbounded accumulator (counting, running sums) | LSTM | f = i = 1 makes c grow; the GRU’s convex blend cannot exceed its inputs |
| Honest default when you have no strong prior | GRU | Cheaper to train and tune; if it plateaus, swapping in an LSTM is a two-line experiment |
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
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.
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:
Concretely, with a vocabulary of 5 and logits y = [2.0, 0.5, −1.0, 0.3, 1.2], target index 0. Exponentiate each:
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":
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.
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.
+=. Gradient into ht arrives from two places: this step’s output and the next step’s recurrence.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:
Take the same logits [2.0, 0.5, −1.0, 0.3, 1.2] and watch the top probability move:
| T | scaled logits | p(top) | behaviour |
|---|---|---|---|
| 0.5 | [4.0, 1.0, −2.0, 0.6, 2.4] | 0.7767 | sharpened — safe, repetitive, loops |
| 1.0 | [2.0, 0.5, −1.0, 0.3, 1.2] | 0.5250 | the model’s honest distribution |
| 2.0 | [1.0, 0.25, −0.5, 0.15, 0.6] | 0.3580 | flattened — 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.
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.
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()
= where
you meant +=. If it drops to 3.5 and stops, the learning rate is probably too high and the
model is bouncing.
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.
Cross-entropy per character (nats). Dashed line = chance, ln(V). Lower is better.
Gate activity over the last timesteps — rows are hidden units (the first 32), columns are time (newest at right).
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.
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 this | What you will see | Why |
|---|---|---|
| Learning rate 5, run 200 steps | Loss spikes above chance, sample becomes one repeated character | Steps overshoot the minimum; without clipping this would be NaN. The clip at ±5 is why it merely thrashes instead of dying. |
| Learning rate 0.001 | Loss barely moves in 500 steps | Adagrad’s accumulator shrinks the effective rate further over time; too small a base rate never escapes the initial plateau. |
| Hidden units 2 | Loss stalls around 2.3–2.6 and never approaches 1.5 | Two numbers cannot compress the corpus. This is capacity, not optimisation — more steps will not help. |
| Hidden units 64, run 1000 steps | Lowest loss, most word-like samples | More state, more memory, more parameters. Also the slowest per step — the cost is real. |
| Temperature 0.2 after training | Short repeating loops of the most common words | Near-argmax decoding collapses onto the highest-probability continuation and cycles. |
| Temperature 2.0 after training | Letter soup, but with the right letter frequencies | Flattening the distribution keeps the marginal statistics and destroys the conditional structure. |
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.
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.
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.
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.
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.
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 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.
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.
| Architecture | Kept from the RNN | Changed | Why |
|---|---|---|---|
| LSTM (1997) | Recurrent fixed state | Split state into c and h; three gates | Additive cell path gives a near-identity gradient highway |
| GRU (2014) | Gating, additive path | Dropped the output gate; merged c and h; coupled forget and write | 25% fewer parameters at comparable quality |
| Seq2seq + attention (2015) | The RNN cell itself | Decoder reads all encoder states, not just the last | Removes the fixed-vector bottleneck |
| Transformer (2017) | Nothing recurrent | Recurrence deleted; attention over the whole sequence | Fully parallel training; cost is a KV cache that grows with length |
| RWKV (2023) | Fixed state, constant-memory decode | Gates replaced by a time-decay linear-attention recurrence | Has both a parallel training form and a recurrent inference form |
| Mamba / S6 (2023) | Fixed state, linear-time scan | Learned gates replaced by an input-dependent state-space recurrence, run as a parallel scan | Selectivity gives content-based forgetting; the scan gives GPU parallelism |
| xLSTM (2024) | LSTM gating philosophy | Exponential gating with a stabiliser; matrix memory; a parallelisable variant | Fixes the LSTM’s two flaws: cannot revise, cannot parallelise |
| Griffin / Hawk (2024) | A real gated linear recurrence | Interleaves the recurrence with local attention blocks | Recurrence 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.
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.
Vertical: fixed-size state (up) vs growing cache (down). Horizontal: sequential training (left) vs parallel over time (right).
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.
Everything in one place, with every symbol spelled out in words. Nothing new is introduced here — this is the page to come back to.
| Equation | Read 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. |
| h̃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. |
| Vanilla RNN | GRU | LSTM | Transformer | SSM / Mamba | |
|---|---|---|---|---|---|
| Blocks per cell | 1 | 3 | 4 | — | — |
| State at decode | fixed n | fixed n | fixed 2n | grows with length | fixed |
| Train cost in length | O(T), sequential | O(T), sequential | O(T), sequential | O(T2), parallel | O(T log T) scan, parallel |
| Long dependencies | fails past ~20 | good | good | excellent | excellent |
| Parallel over time | no | no | no | yes | yes |
| Pick it when | teaching, or truly short sequences | small data, tight memory, on-device, streaming | you need an unbounded accumulator or to hide state | quality at scale and you can afford the cache | very long context with constant-memory decode |
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.
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.
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.
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:
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.
| Symptom | Cause | Fix |
|---|---|---|
Loss goes to NaN after a spike | Exploding 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 loop | Every shared parameter accumulates across timesteps |
| Model never learns dependencies past ~20 steps | Vanishing gradient in a vanilla cell (Ch 3) | Use a gated cell — clipping cannot help here |
| You biased bz positive "to remember" and it forgot faster | Sign convention: GRU z is write, not keep (Ch 4) | Bias bz negative to bias toward keeping |
| Generated text drifts into nonsense after 50 tokens | Exposure bias from teacher forcing (Ch 6) | Lower the temperature; or train with scheduled sampling |
If you want more foundations on recurrence:
If you want to follow the lineage forward:
If you want to build with it: