Backpropagation through time
29 min read
The previous lesson, on the vanilla recurrent neural network (RNN), ended with a network that reads text of any length using two matrices and a loop, but that nobody has trained yet: every weight in it still holds the value it was given when the network was built. Training it means asking for the gradient of the loss with respect to those weights, and that question raises something the previous block never had to deal with. An RNN doesn't spread its work across one matrix per layer: is a single matrix that the loop uses at every position, so it's tangled up in the whole sequence.
How do you differentiate with respect to a weight that takes part in all steps at once? The previous block already has both halves of the answer. The chain rule says what happens when a weight reaches the loss along several paths; backpropagation gets every derivative in a network in a single pass from the end to the start, without probing one weight at a time. What's missing is putting the two together inside a loop, and the result has a name of its own: backpropagation through time (BPTT). This lesson doesn't stop at stating it: it derives it in full and then checks, number by number, that its gradients match what a numerical probe measures.
Unrolling the loop lays the network out as a row of steps, and you can walk the backward pass along that row by hand. The loss sits at the end, hanging off the last state; to reach a weight, its influence has to retrace the route the forward pass took, from the last state to the one before, and from that one to the one before it, crossing the same at every hop. There's no other route, because there was no other route forward.
The error that travels back, one step at a time
Fix one sequence, a single document, and do the derivation for that alone. A document is one whole example (one , one label), so its loss is , the loss of a single example, as it has been throughout the previous block since the lesson on loss functions. The tokens the loop runs through aren't examples: they're positions inside this one document, the axis that plays the role of the layers here, and none of them is the batch. The batch is the other axis, the familiar one ( documents instead of one), and its mean, , averages these per-document gradients one level further up, without touching anything that follows. The answer is read off the last state, as in the previous lesson:
so the recurrence's weights reach the loss in exactly one place: through . The rest of the backward pass is a matter of reconstructing, step by step, how each state affects that last one.
First, give a name to something the previous lesson left unnamed. The pre-activation of step , the argument of the , is
and call the gradient of the loss with respect to that pre-activation the error of step , . It's exactly the from the lesson on backpropagation (a layer's error, taken at its pre-activation), except that the index now counts steps in time rather than layers.
That error runs from the end back to the start, in two moves per step. The acts coordinate by coordinate, so getting from the gradient at to the gradient at means multiplying by its slope. Using the identity from the lesson on activation functions together with , that slope is , which you can read straight off the state the forward pass already stored, without keeping around. That leaves the other factor, , which splits into two cases depending on where the loss reaches from: at the last step, only from the output layer; at every other step, only from the next one:
The starting point, , is the backward pass through the previous lesson's output layer: the sigmoid with cross-entropy delivers , and distributes it across the coordinates of the state. The recurrence handles everything else, and it's the two moves from the lesson on backpropagation with nothing changed: it transports the error with and masks it with the slope of the . The only difference is that the transport always uses the same matrix. Hold on to that, because it's half of a later lesson, the one on the vanishing gradient.
See the transport index by index
Here it is with no matrices in the way, for an interior step , where reaches the loss only through the next step. Coordinate of is the derivative of with respect to , and feeds into every one of the coordinates of the pre-activation : it's the sum over paths from the lesson on the chain rule, with paths.
where the first factor is by the definition of the error, and the second comes from the fact that depends on only through the term , whose derivative is . That sum runs down column of , which is row of its transpose, in other words coordinate of . So , and multiplying by the slope of the gives . At the last step, the same argument starts from the output layer instead of the next step, and that's where the other equation comes from.
The gradient of the shared weight is a sum
With one error per step in hand, the weight gradients follow without any further walking. This is where the sum the previous lesson promised turns up. Look at where takes part: in the pre-activation of every step, , with running from to . It's a single weight reached along paths, one per position, and the chain rule from the previous block has already settled what to do about that: across paths, you add. Each path contributes what the lesson on backpropagation gives for a pre-activation (the error times the transposed input), and the total is the sum of all contributions:
Put them next to the previous block's formulas and you'll find they're the same. There, a layer's gradient was , a single outer product; here it's that same outer product summed over the steps, because there each matrix appeared once, and here appears times. That sum is the only difference between backpropagation and backpropagation through time, and it's the exact price of sharing: one matrix, jobs, contributions to collect.
Two checks settle the account. The first is about shapes, the habit from the lesson on backpropagation: each term of is , and so is the sum, so the gradient has the shape of the matrix it corrects however long the sequence is. decides how many terms get added, not the size of the result. The second looks at the first term: in , the term for is , and since , it's the zero matrix. That fits with the previous lesson, where had no term: the first step never used that matrix, so it owes it no gradient. In , on the other hand, the term does count, because is a real token.
That leaves the output weights, and , and they bring nothing new. They appear only once (the network reads out its answer once, at the end), so their gradient is an outer product with no sum, : the previous block's output layer, exactly as it was. The sum over time belongs to the three shared weights, and to nothing else.
BPTT in NumPy, against the numerical probe
This is the same check as in the lesson on backpropagation: differentiate by hand, then compare every number against a probe that nudges each weight slightly and measures how the loss changes. The setup is a toy RNN with a fixed seed, a state of and a sequence of . Run it and look at two things: each step's contribution to (the last one, for , comes out as zero) and the final line.
rng = np.random.default_rng(0)
d_model, d_h, T = 4, 3, 5
Wxh = rng.normal(size=(d_h, d_model)) * 0.5
Whh = rng.normal(size=(d_h, d_h)) * 0.5
bh = np.zeros(d_h)
Why = rng.normal(size=(1, d_h)) * 0.5 # scalar output: 1 x d_h
by = np.zeros(1)
X = rng.normal(size=(T, d_model)) * 0.5 # one row per position
y = 1.0 # the document's label
def forward():
hs = [np.zeros(d_h)] # h_0 = 0
for x in X:
hs.append(np.tanh(Whh @ hs[-1] + Wxh @ x + bh))
p = 1.0 / (1.0 + np.exp(-(Why @ hs[-1] + by)[0]))
return hs, p, -(y * np.log(p) + (1 - y) * np.log(1 - p))
hs, p, loss = forward()
print("ŷ = %.4f ℓ = %.4f" % (p, loss))
# Backwards: one δ per position, from the end to the start.
gWhh, gWxh, gbh = np.zeros_like(Whh), np.zeros_like(Wxh), np.zeros_like(bh)
dh = Why[0] * (p - y) # ∂ℓ/∂h_T, from the output layer
contributions = []
for t in range(T, 0, -1):
delta = dh * (1 - hs[t] ** 2) # δ_t = (∂ℓ/∂h_t) ⊙ (1 - h_t²)
gWhh += np.outer(delta, hs[t - 1]) # δ_t h_{t-1}ᵀ : what step t contributes
gWxh += np.outer(delta, X[t - 1])
gbh += delta
contributions.append(np.linalg.norm(np.outer(delta, hs[t - 1])))
dh = Whh.T @ delta # ∂ℓ/∂h_{t-1} : transport with Whhᵀ
gWhy, gby = np.outer([p - y], hs[T]), np.array([p - y])
print("each step's contribution to ∇Whh, from t=%d to t=1:" % T)
print(" ", np.round(contributions, 4), " <- the last one, t=1, is 0 because h_0 = 0")
# The probe: move each weight by hand and see how much the loss changes.
worst = 0.0
for P, G in [(Whh, gWhh), (Wxh, gWxh), (bh, gbh), (Why, gWhy), (by, gby)]:
for k in np.ndindex(P.shape):
v = P[k]
P[k] = v + 1e-6; plus = forward()[2]
P[k] = v - 1e-6; minus = forward()[2]
P[k] = v
worst = max(worst, abs((plus - minus) / 2e-6 - G[k]))
print("largest difference against the numerical probe: %.1e" % worst)
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
Check the output against the derivation. The list of contributions has five numbers, one per step, and the last is exactly zero: step multiplies by and asks nothing of , exactly as the algebra predicted. And the last line is the whole lesson: the gradients of all five groups of weights agree with the probe to within , which is the probe's own error, not the algorithm's. The sum over time isn't an approximation or a workaround; it's the exact gradient, and the probe confirms it number by number.
Test your intuition
Four questions: the shape of the shared weight's gradient, where its sum comes from, what the backward recurrence looks like, and what the backward pass forces you to keep.
An RNN with a state of coordinates reads a document of tokens. How many numbers does hold?
A margin of ±0 is accepted.
In the previous block's networks, the gradient of each weight matrix was a single outer product. In the RNN, is a sum of of them. Where does that sum come from?
Tick everything that is true of the backward recurrence .
Select every correct option. This is graded all-or-nothing: there is no partial credit.
The backward pass runs through the steps from down to , using and at each one. What does that force you to do during the forward pass?
And then the whole backward pass, written by you: the piece the previous lesson's RNN was still missing before it could learn.
Write the recurrence's backward pass (the gradients of the shared weights), starting from what the forward pass left in storage.
bptt(hs, X, Whh, dhT)takes the list of stateshswith ; the inputXof shape , whose row is ; the recurrent matrixWhh; anddhT, the gradient the output layer passes to the last state.- It returns the triple
(gWhh, gWxh, gbh), each one summed over the steps.
The function must not write into the list or the arrays it receives.
The first run downloads the Python interpreter (~15 MB); after that it stays in the browser cache. This challenge is much easier to solve on a physical keyboard: on a phone, read it and come back later.
The gradient is done, exact and verified, but it carries a warning inside it that we haven't read yet. The backward recurrence multiplies by at every step (the same matrix, once per position), and it also masks with the slopes , which the lesson on activation functions already showed switching off when the pre-activation strays far from zero. An error that leaves position and has to reach position passes through that product dozens of times, and multiplying by one and the same matrix that many times does one of two things: it shrinks towards nothing, or it blows up.
Either outcome breaks exactly what the RNN was brought in to fix. If the gradient linking the first position to the loss has vanished by the time it gets there, the network can't learn that something at the start of the document mattered for the end: the long-range dependency, which was the whole point of having memory. How much it shrinks or grows, and why that comes down to a single property of , is the subject of the next lesson, on the vanishing gradient: we derive it carefully and then watch it plummet on a plot as the distance grows.
Further reading1 source · 1 book
Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.
- Deep Learning, ch. 10: Sequence Modeling: Recurrent and Recursive Nets
Its §10.2.2 is this same derivation; equation 10.21 shows the transport through the recurrent matrix and the 1 − h² mask. It's terser than yours, with no numerical check, and calls your W_hh, W_xh and W_hy W, U and V.