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 across the two occurrences of l — 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." This flattened-input MLP has no built-in requirement to reuse the same detector at different offsets. It can still learn reusable features, but that reuse is not enforced by the architecture.
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 an easy implementation bug to make 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 =
keeps only the last contribution computed by the backward loop. The loss may still decrease
without an error message, even though the gradient is wrong. 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 sequence unrolls into 10,000 cell applications. Keeping the states costs O(T·n) memory, in addition to other saved activations. The ordinary nonlinear recurrence introduces a sequential dependency across time, although operations within a step and across examples can run in parallel. Full BPTT may be feasible, but truncated BPTT reduces memory and compute by processing chunks of k steps. Values such as 25 or 35 are illustrative choices, not universal defaults. Carry the value of h across each chunk boundary while cutting its connection to the earlier gradient graph.
That distinction is the whole trick, and it is worth stating twice. The forward state is continuous
— it can retain information from earlier chunks, if the model has learned to preserve it. 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):
Here, dropping one of three timestep contributions changes this gradient by about 7.6%; that is not a measurement of a 33% end-to-end runtime saving. Truncation is a biased approximation. When the omitted terms are small, it can save work with little gradient error. When distant credit matters, it discards useful information. Signed terms can also cancel, so the error need not improve monotonically with every added step. Choose k by measuring the task, memory budget, and learning behavior; gated cells can also be limited by too short a 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.
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: a product of time-varying Jacobians. The shared recurrent matrix appears in every factor, while each timestep contributes its own diagonal tanh slope. A repeated matrix power is a useful approximation, but carefully chosen orthogonal or unit-gain cases can preserve a signal longer than the generic attenuating or amplifying cases.
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 — strongly attenuated |
| 50 | 4.6 × 10−18 | tiny but representable in float32 |
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. Float32 can represent both this value and 4.6 × 10−18. Its precision is relative, not an absolute cutoff at 10−7. A small gradient contribution can be lost when added to much larger terms or become too small to change a parameter. That depends on the surrounding scales and optimizer; attenuation is not the same as underflow.
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 illustrative gradient multiplier. The dashed line at 10−7 is a scale reference, not a float32 underflow threshold. Whether a small contribution affects learning depends on the magnitudes of other gradients and parameter updates.
A common safeguard against unusually large gradients is gradient norm 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.
For a finite nonzero gradient, this preserves its direction and caps its norm. With plain SGD, the learning rate then sets the update norm; adaptive optimizers also transform the gradient. Clipping alone does not guarantee a loss decrease or repair values that already overflowed. Thresholds such as 1 to 5 are common starting points, but the useful scale depends on the setup.
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'] <- strongly attenuated 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 you can use when large gradients are a problem.
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 — about six orders of magnitude larger than this vanilla-cell example. At z = 0.5 the direct path is about eight times larger, but both still attenuate strongly. At z = 0.9 this direct path is smaller. These products isolate the keep path; they are not the full GRU Jacobian. 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. The original analysis says that a reset gate near zero lets a unit behave as if it were reading the first symbol of a sequence: the candidate ignores the previously computed state, while the final blend may still retain it. The broader lesson is that the gates let different units adapt to different time scales. Nobody assigns those time scales. They emerge.
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
can reduce kernel-launch overhead. The actual speedup depends on hardware, batch size, and the implementation:
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 + bin + r ⊙ (Unh + bhn)), rather than before it. PyTorch also uses h = z⊙hold + (1−z)⊙n, so its update gate is the complement of this lesson’s write gate. Matrix multiplication and elementwise reset do not generally commute; copying the same numbers into both implementations does not guarantee matching or even small numerical differences. Compare the complete formulas and parameter conventions first.
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 has exactly 25% fewer matrix-and-bias parameters 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, three recurrent matrix terms instead of four, and fewer weights to load; measured speedup depends on kernels and hardware |
| 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.
For autoregressive sequence generation, training commonly feeds the ground-truth token from step t − 1 while predicting the token at t. This is teacher forcing. Feeding model samples instead is another possible training policy; teacher forcing is common, but it is not used by every kind of sequence model.
Ground-truth prefixes give a supervised next-token loss at every position even when the model is initially wrong. They do not decouple the recurrence: ht still depends on earlier states and shared parameters, and BPTT still couples their gradients.
+=. 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. One decoding control 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 | sharper; lower-entropy samples |
| 1.0 | [2.0, 0.5, −1.0, 0.3, 1.2] | 0.5250 | the model distribution before decoding filters |
| 2.0 | [1.0, 0.25, −0.5, 0.15, 0.6] | 0.3580 | flatter; higher-entropy samples |
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. Greedy or beam search, top-k, nucleus sampling, and repetition controls are other choices; temperature alone does not guarantee coherence or creativity.
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: # contiguous streams, stable batch ordering logits, h = model(xb, h) # otherwise reset h = None per batch 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()
+= accumulation, then compare controlled
learning-rate and width changes rather than diagnosing from one loss value.
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. The corpus has V = 24 distinct characters, so an exactly uniform predictor scores ln(24) = 3.178 nats, the dashed line. Divide by ln 2 = 0.693 to get 4.59 bits per character. Random initial logits are often near uniform, but an arbitrary nonuniform predictor can score above or below this reference.
Before attributing a loss change to memory, compute context-free baselines. For this 273-character
corpus, the empirical unigram distribution has entropy 2.653 nats: for example, e
appears 51 times and space 50 times, while k appears 3 times and v once. A
frequency-only predictor evaluated on that same distribution attains this value, but the training curve
does not say which parameters learn frequency information or when.
Context-conditioned predictions can exploit repeated phrases such as the gate and
the state. The input weights, recurrent state, gates, and readout work jointly, so a loss
drop does not isolate bigrams or persistent memory. Test those hypotheses with controlled ablations or
interventions.
Loss values do not identify which statistics the network uses: unigram, short-context, and longer-state solutions can overlap in average cross-entropy. This tiny repeated corpus can eventually be fitted very closely, but exact loss-at-step values are properties of this implementation and seed. A plateau says only that the measured objective has stopped improving; test optimisation, width, truncation, data, and implementation hypotheses with controlled runs.
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 | Look for unstable or oscillating loss | Large Adagrad steps can overshoot; elementwise clipping limits each gradient component but does not guarantee stability. |
| Learning rate 0.001 | Compare progress over the same step budget | A smaller base rate usually changes the optimisation timescale; a short run cannot establish a final plateau. |
| Hidden units 2 | Compare its best loss with wider runs | A narrow state is a capacity hypothesis, but optimisation and truncation still need controls. |
| Hidden units 64, run 1000 steps | Compare loss, sample, and runtime | More state adds parameters and compute; it does not guarantee a better sample at every fixed budget. |
| Temperature 0.2 after training | Lower-entropy samples | The highest-probability continuations become more likely, which can increase repetition. |
| Temperature 2.0 after training | Higher-entropy samples | Flattening each conditional distribution makes lower-probability characters more likely. |
Count the recurrent affine work. With H = 32 and V = 24, one forward timestep uses three blocks times (H·V + H2) = 3 × (768 + 1024) = 5,376 multiply-accumulates. Treating backward as roughly twice that gives about 403,200 recurrent multiply-accumulates for a 25-step training chunk. This estimate excludes the readout, nonlinearities, sampling, and browser overhead; wall time must be measured on the target device.
At H = 64, the same recurrent estimate is 16,896 multiply-accumulates per forward timestep, about 3.14 times the H = 32 value, because the H2 term grows faster than H·V. The lab can show local timing differences, but browser scheduling and hardware determine whether they affect frame rate.
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.
Stacking lets higher recurrent layers transform the sequence representations produced below them. It does not by itself guarantee a longer usable temporal horizon or a particular abstraction in each layer. Deeper stacks add optimisation and compute costs; useful depth and choices such as residual connections depend on the task and implementation.
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 often an advantage 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.
For strictly causal generation it is unavailable. 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.
A 256-dimensional state has the same shape at every source length, but dividing dimensions by tokens is not an information measure. Cho and colleagues reported that their 2014 systems performed worse on longer sentences and on sentences with more unknown words. The often-cited change near 20 words is a feature of that experiment, not a universal encoder limit.
Bahdanau, Cho and Bengio called the fixed-length vector a conjectured bottleneck and proposed an attention-based alternative: retain encoder states and compute a weighted source summary for each decoder step. Their model learned soft alignments and improved translation results; the paper does not establish a universal length threshold or instant removal of every length effect.
A fixed-width vector must support a harder learned compression task as source length and complexity grow. There is no justified conversion from float dimensions to a fixed number of recoverable token bits: the representation is task-dependent, lossy, distributed, and learned jointly with the decoder. The evidence here is empirical translation quality, not a dimensionality proof.
Recurrent work continued while attention and convolution drew more attention, then gated and memory-efficient variants brought the fixed-state trade-off back into focus. The architectures below make different choices about how the state is updated and how much parallel training they can recover.
| Architecture | Kept from the RNN | Changed | Why |
|---|---|---|---|
| Current LSTM (original 1997; forget gate added later) | Recurrent state | Separates cell state c from exposed state h; the standard cell has input, output, and forget gates | An additive cell path can provide a near-identity gradient route |
| GRU (2014) | Gating and an additive state update | Uses one state, no output gate, and complementary keep/write coefficients | About 25% fewer cell parameters at equal input and state widths |
| Seq2seq + attention (2015) | Recurrent encoder and decoder | Decoder reads weighted combinations of encoder states | Relaxes reliance on one fixed source vector |
| Transformer (2017) | No recurrent sequence-mixing cell | Uses attention and position-wise feed-forward layers | Parallel processing over training positions; standard autoregressive decoding commonly stores a growing KV cache |
| RWKV (2023) | Recurrent state and receptance gates | Uses a time-decay linear-attention formulation | Has parallel and recurrent forms, with constant-memory recurrent inference |
| Mamba / S6 (2023) | Fixed-size state-space recurrence | Makes selected SSM parameters input-dependent | A hardware-aware parallel scan trains the selective recurrence efficiently |
| xLSTM (2024) | LSTM gating philosophy | Adds exponential gating; sLSTM and mLSTM use different memory structures | mLSTM is parallelizable, while the family retains gated memory |
| Griffin / Hawk (2024) | Gated linear recurrence | Hawk is recurrent; Griffin interleaves recurrence with local attention | Explores efficient recurrent and hybrid language models |
The Transformer replaced recurrent sequence mixing with attention, enabling parallel processing across training positions. Recent recurrent and state-space architectures explore fixed or compressed decode state together with scan-friendly or otherwise parallel training forms. A standard GRU update is not an associative linear recurrence, so these systems make different compromises rather than forming one uniform family or a single line of descent.
Click any node to read a compact comparison on two useful axes: how much decode state is fixed-size (vertical) and whether training parallelises over time (horizontal). Positions and connecting lines are conceptual, not benchmark scores or claims of direct genealogy.
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 — non-causal context, not a direct left-to-right generator 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), one learned source summary o, _ = self.dec(self.emb(tgt_in), c) # this simple decoder receives the source through c return self.out(o) # Source lengths 5 and 500 produce the same-shaped c. # Attention instead lets each decoder step read across encoder states.
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 forward state Jacobian; its transpose acts on a column gradient in backpropagation. 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 | A uniform upper bound on the state-Jacobian product. γ bounds the tanh derivatives across the steps (at most 1), and σmax is the largest singular value of Wh. If their product is below 1, this bound decays geometrically. If it exceeds 1, the bound permits growth but does not prove that a particular gradient explodes. |
| if ‖g‖ > c: g ← c·g/‖g‖ | Gradient clipping. g is the concatenated parameter gradient and c is a chosen threshold. The finite gradient direction is preserved and its norm is capped; vanished contributions are not restored. |
| 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 the coordinatewise range of its old-state and candidate inputs. The direct keep-path contribution is diag(1−z), approaching the identity as z approaches 0; the complete cell Jacobian also includes derivatives through the gates and candidate. |
| 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 and T > 1 flattens the distribution; T → 0 approaches argmax. Output quality depends on the model and other decoding choices. |
| 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 retained prefix for standard full attention | fixed for recurrent selective SSM |
| Train cost in length | O(T), sequential | O(T), sequential | O(T), sequential | O(T2) full attention, parallel | O(T) scan work, O(log T) parallel depth |
| Long dependencies | often difficult | task-dependent | task-dependent | direct access within retained context | task- and state-size-dependent |
| Parallel over time | no | no | no | yes in training | yes via selective scan |
| Pick it when | teaching or a simple short-sequence baseline | fixed-state streaming is useful | independent keep/write and an output gate are useful | full-context attention justifies its compute and cache | long-sequence linear scaling merits evaluation |
Set three constraints to explore an illustrative heuristic. The scores are editorial teaching aids, not benchmark measurements; real choices require task-specific quality and hardware tests.
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:
The 50,000-way readout has about 9.3 times as many parameters as the recurrent stack in this example. Direct input/output weight tying requires matching embedding and hidden widths; with d = 256 and n = 512, it needs an added projection or a different width choice.
The recurrent stack costs about 2.75 million multiply-accumulates per token, but the dense 50,000-way readout adds about 25.6 million. Actual latency depends on hardware and kernels. The recurrence also makes token steps sequential.
The two-layer GRU carries 2 × 512 float32 values, or 4,096 bytes, independent of prefix length. For a two-layer standard full-attention decoder whose key and value widths are each 512, an uncompressed float32 KV cache adds 2 × 2 × 512 × 4 = 8,192 bytes per token, or about 82 MB at 10,000 tokens. Grouped-query attention, lower-precision caches, sliding windows, and compression change this comparison, so deployment requires the actual architecture and cache policy.
| Symptom | Hypothesis | Check or response |
|---|---|---|
Loss becomes NaN after a spike | Non-finite activations, gradients, or an oversized update | Locate the first non-finite tensor and inspect gradient norms; clip finite gradients before the optimiser step when appropriate |
| Loss plateaus far above a trusted library baseline | Failure to accumulate shared-parameter gradients is one possible bug | Compare gradients with autograd or finite differences; every shared parameter uses += across timesteps |
| Vanilla model struggles with tested long dependencies | Vanishing gradients are one hypothesis (Ch 3) | Measure gradient flow; gated cells are one architectural response |
| You biased bz positive "to remember" and it forgot faster | Sign convention: this lesson’s z is write, not keep (Ch 4) | Check the library convention before choosing a bias |
| Generated text degrades over a long sample | Model error, decoding, and prefix shift are possible causes | Compare temperatures and truncation sampling; evaluate exposure-bias interventions separately |
If you want more foundations on recurrence:
If you want to follow the lineage forward:
If you want to build with it: