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

Handout #5 — Analytical Backpropagation

Session 5 (Tue, Sept 15)  ·  Slides: Feedforward Nets  ·  Course page

Handout #4 established losses, gradient descent, and the chain rule. Now we derive an efficient backward sweep for a layered network, then implement and check its parameter gradients.

Reading for Tuesday, Sept 15
Slides 90–109 of Feedforward Nets.
Time
About 70–90 minutes including exercises; the code lab can be completed after class.
By the end
Derive weight and bias gradients; explain caching; implement and check regression and classification backward passes.
Assumes
Handout #4: derivative shapes and the chain rule and Handout #3: MLP architecture and parameter shapes.
How to use it
Pause at each question before opening its answer. The code lab uses Python and PyTorch.

1Deriving analytical backpropagation

Slides 90–109 · Direct gradients, cached sensitivities, and parameter updates

1.1  Name the forward computations

Use the multilayer network from the slides, with biases made explicit. For one example, let $h_0=x\in\mathbb R^{d_0}$ and, for $i=0,\ldots,L-1$, define

$$z_{i+1}=W_i h_i+b_i,\qquad h_{i+1}=f_i(z_{i+1}),\qquad \hat y=u^\top h_L+c,\qquad \ell=\ell(\hat y,t).$$

Here $W_i\in\mathbb R^{d_{i+1}\times d_i}$, $b_i\in\mathbb R^{d_{i+1}}$, and $u\in\mathbb R^{d_L}$. The hidden activations $f_i$ act elementwise. Setting biases to zero recovers the slides' forward equations. Cache the activations and the values needed to evaluate activation derivatives during the forward pass.

We use column gradients: $g_i=\nabla_{h_i}\ell$. The slides' cached upstream sensitivities are written as rows; $g_i$ is their transpose. Below, $\delta_{i+1}$ means the column gradient with respect to the preactivation $z_{i+1}$, not $h_{i+1}$.

1.2  Start at the loss and output layer

First compute the scalar $q=\partial\ell/\partial\hat y$. For the convenient squared-error loss $\ell=\tfrac12(\hat y-t)^2$, we have $q=\hat y-t$. Since $\hat y=\sum_j u_j h_{L,j}+c$, the chain rule gives

$$\nabla_u\ell=q h_L,\qquad \frac{\partial\ell}{\partial c}=q,\qquad g_L=q u.$$

The first two are parameter gradients to save for the optimizer. The last is the upstream sensitivity to send into the last hidden layer. A different loss changes the starting derivative $q$; the hidden-layer rules below remain the same.

Derive before substituting

If the loss is $(\hat y-t)^2$ without the factor $1/2$, which gradients change?

Reveal the answer
$q$ doubles, so every parameter gradient doubles. The forward predictions are unchanged. For a fixed learning rate, the update doubles too.

1.3  Derive one hidden layer, then reuse it

Suppose $g_{i+1}$ has already arrived from the output side. The elementwise activation gives

$$\delta_{i+1}=\nabla_{z_{i+1}}\ell =g_{i+1}\odot f_i'(z_{i+1}).$$

This is a Jacobian-transpose product: the activation Jacobian is diagonal, so multiplication reduces to the elementwise product $\odot$. For sigmoid, $f'(z)=f(z)(1-f(z))$; for tanh, $f'(z)=1-f(z)^2$; for ReLU, it is 1 at positive inputs and 0 at negative inputs (we use 0 at zero).

Now expand the affine operation coordinate by coordinate:

$$z_{i+1,j}=\sum_k W_{i,jk}h_{i,k}+b_{i,j}.$$

Changing $W_{i,jk}$ directly changes only $z_{i+1,j}$, with local derivative $h_{i,k}$. Changing $h_{i,k}$ affects every output coordinate, so those paths must be summed:

$$\frac{\partial\ell}{\partial W_{i,jk}}=\delta_{i+1,j}h_{i,k},\qquad \frac{\partial\ell}{\partial b_{i,j}}=\delta_{i+1,j},\qquad \frac{\partial\ell}{\partial h_{i,k}}=\sum_j W_{i,jk}\delta_{i+1,j}.$$

Collecting these entries gives the three reusable rules:

$$\boxed{\nabla_{W_i}\ell=\delta_{i+1}h_i^\top,\qquad \nabla_{b_i}\ell=\delta_{i+1},\qquad g_i=W_i^\top\delta_{i+1}.}$$
ComputationShape check
Weight gradient: outer product$(d_{i+1}\times1)(1\times d_i)=d_{i+1}\times d_i$
Bias gradient$d_{i+1}$ entries, one per output unit
Gradient passed to input$(d_i\times d_{i+1})(d_{i+1}\times1)=d_i\times1$
Why an outer product?

A layer maps 4 inputs to 6 hidden units. Can $h_i^\top\delta_{i+1}$ be its weight gradient? What happens to row $j$ of the weight gradient if that ReLU unit has negative preactivation?

Reveal the answer
The proposed product has incompatible inner dimensions; the correct gradient is $\delta_{i+1}h_i^\top$, a $6\times4$ matrix. For an inactive ReLU, $\delta_{i+1,j}=0$, so row $j$ and bias gradient $j$ are zero for this example. Other examples in a batch may activate that unit.
From a derivative tensor to a usable weight gradient Adapted from HW3, sp2025, §2.7

Let $X\in\mathbb R^{n\times d}$, $W\in\mathbb R^{k\times d}$, and $Y=XW^\top$. What is the shape of the unflattened derivative of $Y$ with respect to $W$? Give $\partial Y_{ab}/\partial W_{cj}$. If $G=\nabla_Y\ell$ is $n\times k$, derive $\nabla_W\ell$ without constructing that tensor.

Work it out, then reveal the answer

The derivative tensor has shape $(n,k,k,d)$ and entries $\partial Y_{ab}/\partial W_{cj}=\mathbf1[b=c]X_{aj}$. Contracting against $G$ gives $[\nabla_W\ell]_{cj}=\sum_aG_{ac}X_{aj}$, hence $\nabla_W\ell=G^\top X$, of shape $k\times d$. This is the batch sum of the outer products derived above.

1.4  Why the backward sweep saves work

Let $A_i=J_{h_{i+1}}(h_i)$. Expanding the chain rule independently for every hidden layer would repeatedly compute the same suffix of the network:

$$g_i=A_i^\top A_{i+1}^\top\cdots A_{L-1}^\top g_L.$$

Instead, cache $g_{i+1}$ and compute $g_i=A_i^\top g_{i+1}$ once. In practice we evaluate this as $W_i^\top(g_{i+1}\odot f_i'(z_{i+1}))$, without constructing the full Jacobian. The saved upstream gradient also supplies that layer's weight and bias gradients.

With comparable layer widths, independently recomputing all suffixes takes $1+2+\cdots+L=O(L^2)$ layer operations. A backward sweep takes $O(L)$, comparable to the forward pass. For dense layers, a more informative cost is $O(\sum_i d_i d_{i+1})$ per example for either pass, plus elementwise work and the output layer. Cached activations cost memory.

  1. Run the forward pass and cache intermediate values.
  2. Differentiate the loss and scalar output to initialize $g_L$ and the output parameter gradients.
  3. For $i=L-1,\ldots,0$, compute $\delta_{i+1}$, save weight and bias gradients, and pass $g_i$ backward.
  4. After all gradients are computed, update each parameter using the chosen optimizer.
What exactly is cached?

Are we caching weight updates? Can we update $W_i$ before using it to compute $g_i$?

Reveal the answer
We reuse forward values and loss sensitivities at the current parameter values. Weight updates are a separate optimizer step. Using an already updated $W_i$ for $g_i$ mixes two parameter states and is not the gradient of the original forward pass. Finish the backward pass before updating weights.

1.5  A complete numerical backward pass

Take $x=(1,2)^\top$, target $t=1$, ReLU hidden units, and

$$W=\begin{bmatrix}1&0\\0&-1\end{bmatrix},\quad b=\begin{bmatrix}0\\1\end{bmatrix},\quad u=\begin{bmatrix}2\\3\end{bmatrix},\quad c=0.$$

The forward pass gives $z=(1,-1)^\top$, $h=(1,0)^\top$, $\hat y=2$, and $\ell=\tfrac12(2-1)^2=0.5$. Before opening the answer, compute every parameter gradient and the gradient with respect to $x$.

Reveal the backward pass
$$q=1,\quad \nabla_u\ell=(1,0)^\top,\quad \partial\ell/\partial c=1,\quad g_h=(2,3)^\top.$$ $$\delta=g_h\odot(1,0)^\top=(2,0)^\top,\quad \nabla_W\ell=\begin{bmatrix}2&4\\0&0\end{bmatrix},\quad \nabla_b\ell=(2,0)^\top,\quad \nabla_x\ell=W^\top\delta=(2,0)^\top.$$

With learning rate $0.1$, simultaneous updates give $W'=\left[\begin{smallmatrix}0.8&-0.4\\0&-1\end{smallmatrix}\right]$, $b'=(-0.2,1)^\top$, $u'=(1.9,3)^\top$, and $c'=-0.1$. The new hidden preactivations are $(-0.2,-1)^\top$, so the prediction is $-0.1$ and the loss is $0.605$. Even correct gradients can increase loss with a finite step: here the step crosses a ReLU boundary.

Trace a forward and backward passUse the worked example, then change one value and predict the result.

2Code lab: implement and check the derivation

2.1  Manual gradients for a minibatch

For row-batched inputs, $Z=XW^\top+b$, $H=\tanh Z$, and $\hat y=Hu+c$. Use $J=\tfrac1{2B}\sum_n(\hat y_n-t_n)^2$. Then $q=(\hat y-t)/B$ already includes averaging. With $D=(q u^\top)\odot(1-H^2)$, the weight gradient is $D^\top X$ and the bias gradient sums the rows of $D$. Do not divide by $B$ again.

This runnable example implements the backward formulas explicitly. Autograd is used only as an independent comparison, and finite differences check every parameter of this small network.

import torch

torch.manual_seed(7)
B, d, h = 3, 2, 4
X = torch.randn(B, d, dtype=torch.float64)
t = torch.randn(B, dtype=torch.float64)
W = (0.2 * torch.randn(h, d, dtype=torch.float64)).requires_grad_()
b = torch.zeros(h, dtype=torch.float64, requires_grad=True)
u = (0.2 * torch.randn(h, dtype=torch.float64)).requires_grad_()
c = torch.zeros((), dtype=torch.float64, requires_grad=True)
params = [W, b, u, c]

def objective():
    H = torch.tanh(X @ W.T + b)
    pred = H @ u + c
    return 0.5 * ((pred - t) ** 2).mean()

# Our backward pass: no autograd operations are used to find derivatives.
with torch.no_grad():
    H = torch.tanh(X @ W.T + b)
    pred = H @ u + c
    q = (pred - t) / B
    D = (q[:, None] * u[None, :]) * (1 - H ** 2)
    manual = [D.T @ X, D.sum(dim=0), H.T @ q, q.sum()]

reference = torch.autograd.grad(objective(), params)
for p, actual, expected in zip(params, manual, reference):
    assert actual.shape == p.shape
    torch.testing.assert_close(actual, expected, rtol=1e-10, atol=1e-12)

# Central differences: deliberately expensive, useful for a tiny check.
eps = 1e-6
with torch.no_grad():
    for p, expected in zip(params, manual):
        numeric = torch.empty_like(p)
        flat = p.view(-1)
        for k in range(p.numel()):
            old = flat[k].item()
            flat[k] = old + eps
            plus = objective().item()
            flat[k] = old - eps
            minus = objective().item()
            flat[k] = old
            numeric.view(-1)[k] = (plus - minus) / (2 * eps)
        torch.testing.assert_close(numeric, expected, rtol=1e-5, atol=1e-8)
    before = objective().item()
    for p, grad in zip(params, manual):
        p -= 0.01 * grad
    after = objective().item()
assert after < before  # checked for this example, not a general guarantee
print("All gradients agree; loss:", before, "->", after)
Change it, predict it, test it

Duplicate every example in the batch. Should the mean loss and parameter gradients change? Then remove the tanh derivative from $D$: which gradient checks should fail?

Reveal the answer
Duplicating the data doubles the summed contributions and the batch size, leaving the mean loss and gradients unchanged (up to roundoff). Omitting $1-H^2$ corrupts the gradients of $W$ and $b$. The output gradients of $u$ and $c$ still pass because they do not backpropagate through tanh.

2.2  Connecting to a classification output

The scalar regression head makes the derivation easy to follow. For classification, replace it with logits $s=Vh+a$, probabilities $p=\operatorname{softmax}(s)$, and a one-hot target $y$. For one example, cross-entropy can be rewritten as

$$\ell=-\sum_j y_j s_j+\log\sum_k e^{s_k},\qquad \frac{\partial\ell}{\partial s_j}=-y_j+\frac{e^{s_j}}{\sum_k e^{s_k}}=p_j-y_j.$$

Thus the output sensitivity is $r=p-y$, and the same affine-layer derivation gives $\nabla_V\ell=rh^\top$, $\nabla_a\ell=r$, and $g_h=V^\top r$. For a mean loss over a batch, divide each example's output sensitivity by the batch size before summing contributions. The hidden-layer recurrence is unchanged.

Softmax, cross-entropy, and the learning signalMake the model confidently wrong; then try shifting all logits equally.
Check the sign

If $p=(0.2,0.5,0.3)^\top$ and the first class is correct, what is $r$? What would a gradient step directly on the logits do?

Reveal the answer
$r=(-0.8,0.5,0.3)^\top$, whose entries sum to zero. Subtracting a positive multiple of $r$ increases the correct-class logit and decreases the others. In the network we update shared parameters, using the chain rule to translate this signal.
Derive the softmax Jacobian Adapted from HW3, sp2025, §3.5–6

For the column vector $p=\operatorname{softmax}(s)\in\mathbb R^K$, derive $\partial p_i/\partial s_j$ for $i=j$ and $i\ne j$. Assemble the $K\times K$ Jacobian. Why does multiplying it by the all-ones vector give zero?

Work it out, then reveal the answer

The quotient rule gives $p_i(1-p_i)$ on the diagonal and $-p_ip_j$ off the diagonal. Thus $J_p=\operatorname{diag}(p)-pp^\top$. Since $p^\top\mathbf1=1$, $J_p\mathbf1=p-p=0$: adding the same constant to every logit leaves softmax unchanged.

Correction to the sample solution: for a column $p$, the outer product is $pp^\top$. The printed solution switches to $p^\top p$, a scalar, which has the wrong shape.

2.3  Code lab: manual gradients for a classifier

Keep the tanh hidden layer, but replace the scalar output with $K$ class logits. For a batch of $B$ examples, let $X\in\mathbb R^{B\times d}$, $W\in\mathbb R^{h\times d}$, $V\in\mathbb R^{K\times h}$, and let $Y\in\mathbb R^{B\times K}$ contain one-hot targets:

$$H=\tanh(XW^\top+b),\qquad S=HV^\top+a,\qquad P_{n,:}=\operatorname{softmax}(S_{n,:}),\qquad J=-\frac1B\sum_{n=1}^B\log P_{n,t_n}.$$

Apply §2.2 to every row. The mean reduction enters once, at the logits:

$$R=\frac{P-Y}{B},\qquad D=(RV)\odot(1-H^2).$$
GradientBackward computationShape
$\nabla_V J$$R^\top H$$K\times h$
$\nabla_a J$$\sum_n R_{n,:}$$K$
$\nabla_W J$$D^\top X$$h\times d$
$\nabla_b J$$\sum_n D_{n,:}$$h$
$\nabla_X J$$DW$$B\times d$

Bias gradients sum over the rows that shared the bias. The input gradient is useful as a check; it does not mean we update the training inputs. Predict each shape before running this independent example. Manual derivatives use tensor arithmetic under no_grad(); autograd is only the reference calculation.

import torch
import torch.nn.functional as F

torch.manual_seed(11)
B, d, h, K = 4, 3, 5, 3
X = torch.randn(B, d, dtype=torch.float64, requires_grad=True)
target = torch.tensor([0, 2, 1, 2])
W = (0.2 * torch.randn(h, d, dtype=torch.float64)).requires_grad_()
b = torch.zeros(h, dtype=torch.float64, requires_grad=True)
V = (0.2 * torch.randn(K, h, dtype=torch.float64)).requires_grad_()
a = torch.zeros(K, dtype=torch.float64, requires_grad=True)
params = [W, b, V, a]

def objective():
    H = torch.tanh(X @ W.T + b)
    return F.cross_entropy(H @ V.T + a, target)

with torch.no_grad():
    H = torch.tanh(X @ W.T + b)
    logits = H @ V.T + a
    P = logits.softmax(dim=-1)
    Y = F.one_hot(target, num_classes=K).to(dtype=X.dtype)
    R = (P - Y) / B
    D = (R @ V) * (1 - H.square())
    manual = [D.T @ X, D.sum(0), R.T @ H, R.sum(0)]
    input_grad = D @ W
    torch.testing.assert_close(R.sum(-1), torch.zeros(B, dtype=X.dtype))

reference = torch.autograd.grad(objective(), params + [X])
for tensor, actual, expected in zip(params + [X], manual + [input_grad], reference):
    assert actual.shape == tensor.shape
    torch.testing.assert_close(actual, expected, rtol=1e-10, atol=1e-12)

# Also compare a directional derivative with two loss evaluations.
directions = [torch.ones_like(p) for p in params]
directions[0] = torch.arange(W.numel(), dtype=W.dtype).reshape_as(W) / W.numel()
analytic = sum((g * v).sum().item() for g, v in zip(manual, directions))
eps = 1e-6
with torch.no_grad():
    originals = [p.clone() for p in params]
    for p, original, v in zip(params, originals, directions):
        p.copy_(original + eps * v)
    plus = objective().item()
    for p, original, v in zip(params, originals, directions):
        p.copy_(original - eps * v)
    minus = objective().item()
    for p, original in zip(params, originals):
        p.copy_(original)
assert abs((plus - minus) / (2 * eps) - analytic) < 1e-7
print("Classifier: every parameter and input gradient agrees with autograd.")

The directional check compares $\sum_p\langle\nabla_pJ,v_p\rangle$ with $[J(\theta+\epsilon v)-J(\theta-\epsilon v)]/(2\epsilon)$. It is a cheap additional check of one direction, not a replacement for checking every component as in §2.1.

Predict which checks will fail

First remove / B from R. Next restore it and remove (1 - H.square()) from D. Which gradients should be wrong in each case? Finally, switch the objective to reduction="sum": what must change in the manual pass?

Predict, edit, run, then reveal

Without the division, all parameter gradients and the input gradient are $B$ times the reference. Without the tanh derivative, $W$, $b$, and $X$ generally fail, but $V$ and $a$ remain correct. For a summed loss, use $R=P-Y$; do not divide by $B$ anywhere else. Each row of $R$ still sums to zero, so that invariant alone cannot detect the wrong loss reduction.