Course contents 20 / 27

20 / 27 · Neural Networks

Backpropagation

How the gradient of the loss with respect to every weight of a network is computed in one backward sweep: the computational graph, the chain rule in matrix form with shapes, the full forward and backward pass of a two-layer network with cross-entropy, why reverse mode costs about as much as a forward pass, what autograd in PyTorch computes, and checking gradients by finite differences.

On this page 13

The previous note built networks with thousands of parameters; the small digit classifier there already had 17,226. Gradient descent needs the gradient of the loss with respect to all of them at every step. For logistic regression the gradient had a closed form. For a network the loss is a composition of many functions, and writing out each partial derivative by hand is hopeless. Backpropagation computes all of them at once by applying the chain rule from the loss backwards, reusing every intermediate result. Its cost is a small constant times the cost of evaluating the loss, whatever the number of parameters. It was popularized for neural networks by Rumelhart, Hinton and Williams (1986), and it is the special case for networks of reverse-mode automatic differentiation, which goes back to Linnainmaa (1976) (Baydin et al., 2018, section 3.3).

Notation. As in the previous note, zl=Wlhl−1+blz_l = W_l h_{l-1} + b_l and hl=φ(zl)h_l = \varphi(z_l), with a scalar loss LL. For any quantity uu (a scalar, a vector or a matrix), uˉ=∂L/∂u\bar u = \partial L / \partial u is the gradient of the loss with respect to it, called its adjoint; it has the same shape as uu. For a map v=g(u)v = g(u) from Rn\mathbb R^n to Rm\mathbb R^m, the Jacobian J=∂v/∂uJ = \partial v / \partial u is the m×nm \times n matrix with entries Jij=∂vi/∂ujJ_{ij} = \partial v_i / \partial u_j. ⊙\odot is the elementwise product and eye_y the one-hot vector of class yy.

Why not differentiate numerically?#

The baseline is the definition of the derivative. Perturb one parameter, rerun the network, and divide:

∂L∂θi≈L(θ+εei)−L(θ−εei)2ε.\frac{\partial L}{\partial \theta_i} \approx \frac{L(\theta + \varepsilon e_i) - L(\theta - \varepsilon e_i)}{2\varepsilon} .

This costs two forward passes per parameter: 34,452 passes per step for the digit classifier, and impossibly many for a network with millions of weights. The result is also only approximate, as the last section shows. Symbolic differentiation, writing out each ∂L/∂θi\partial L / \partial \theta_i as a formula, avoids the approximation but repeats the same subexpressions in every formula.

The computational graph#

Any computation of LL is a sequence of elementary operations. Drawn as a directed acyclic graph, the nodes are the intermediate values and an edge from uu to vv means that vv is computed from uu. The forward pass evaluates the nodes in order. The backward pass visits them in reverse order and applies the chain rule: if uu is used by the nodes v1,…,vkv_1, \dots, v_k, its influence on LL goes through all of them, and the multivariate chain rule adds the contributions,

uˉ=∑c=1kvˉc ∂vc∂u.\bar u = \sum_{c=1}^{k} \bar v_c\, \frac{\partial v_c}{\partial u} .

Worked example: one unit. A sigmoid unit with squared error, z=wx+bz = wx + b, a=σ(z)a = \sigma(z), L=(a−y)2L = (a - y)^2, at x=1.5x = 1.5, w=0.8w = 0.8, b=−0.2b = -0.2, y=1y = 1:

forwardbackward
z=wx+bz = wx + b1.01.0aˉ=2(a−y)\bar a = 2(a - y)−0.538-0.538
a=σ(z)a = \sigma(z)0.7310.731zˉ=aˉ σ(z)(1−σ(z))=−0.538⋅0.197\bar z = \bar a\, \sigma(z)(1 - \sigma(z)) = -0.538 \cdot 0.197−0.106-0.106
L=(a−y)2L = (a - y)^20.07230.0723wˉ=zˉ x\bar w = \bar z\, x,  bˉ=zˉ\ \bar b = \bar z−0.159-0.159,  −0.106\ -0.106

The backward column starts from Lˉ=1\bar L = 1 and multiplies by one local derivative per node. The local derivative σ′(z)=a(1−a)\sigma'(z) = a(1 - a) uses the value aa stored during the forward pass, which is why the forward values are kept.

The chain rule in matrix form#

For vectors the same rule reads: if v=g(u)v = g(u) with Jacobian JJ, then

uˉ=J⊤vˉ(n entries=(n×m) (m entries)).\bar u = J^\top \bar v \qquad (n \text{ entries} = (n \times m)\,(m \text{ entries})).

The backward pass multiplies by transposed Jacobians, and it never needs to form them as matrices. Three operations cover an MLP.

A linear layer z=Wh+bz = W h + b, with W∈Rm×nW \in \mathbb R^{m \times n}. Since zi=∑jWijhj+biz_i = \sum_j W_{ij} h_j + b_i, the derivative of LL with respect to one weight collects only the output it feeds: ∂L/∂Wij=zˉihj\partial L / \partial W_{ij} = \bar z_i h_j. In matrix form,

Wˉ=zˉ h⊤ (m×n),bˉ=zˉ (m),hˉ=W⊤zˉ (n).\bar W = \bar z\, h^\top \ (m \times n), \qquad \bar b = \bar z \ (m), \qquad \bar h = W^\top \bar z \ (n) .

The weight gradient is an outer product of the adjoint arriving from above and the input stored from the forward pass.

An elementwise activation h=φ(z)h = \varphi(z). Its Jacobian is diagonal, diag⁡(φ′(z))\operatorname{diag}(\varphi'(z)), so

zˉ=φ′(z)⊙hˉ.\bar z = \varphi'(z) \odot \bar h .

For a ReLU, φ′(z)\varphi'(z) is the mask 1[z>0]\mathbf 1[z > 0]: the gradient passes through active units unchanged and is blocked at inactive ones.

A mini-batch. With the examples as the rows of HH, Z=HW⊤+1b⊤Z = H W^\top + \mathbf 1 b^\top and the loss averaged over the batch,

Hˉ=Zˉ W,Wˉ=Zˉ⊤H,bˉ=Zˉ⊤1.\bar H = \bar Z\, W, \qquad \bar W = \bar Z^\top H, \qquad \bar b = \bar Z^\top \mathbf 1 .

Zˉ⊤H\bar Z^\top H sums the per-example outer products, which is how the gradients of the examples of a batch add up. A useful check: the adjoint of a quantity has its shape, and for each formula there is only one way to multiply the factors so that the shapes match.

A two-layer network, forward and backward#

Take x∈Rdx \in \mathbb R^d, a hidden layer of HH ReLU units and KK classes:

z1=W1x+b1∈RH,h=ReLU⁡(z1),z2=W2h+b2∈RK,p=softmax⁡(z2),L=−log⁡py.\begin{aligned} z_1 &= W_1 x + b_1 \in \mathbb R^{H}, & h &= \operatorname{ReLU}(z_1), \\ z_2 &= W_2 h + b_2 \in \mathbb R^{K}, & p &= \operatorname{softmax}(z_2), \qquad L = -\log p_y . \end{aligned}

Through softmax and cross-entropy. Write L=−z2,y+log⁡∑jez2,jL = -z_{2,y} + \log \sum_j e^{z_{2,j}}. Differentiating with respect to z2,kz_{2,k}, the first term gives −1[k=y]-\mathbf 1[k = y] and the second gives ez2,k/∑jez2,j=pke^{z_{2,k}} / \sum_j e^{z_{2,j}} = p_k. Hence

zˉ2=p−ey:\bar z_2 = p - e_y :

predicted probabilities minus the one-hot target, exactly the gradient of logistic regression with respect to its logits. Then the three rules above, in reverse order of the forward pass:

Wˉ2=zˉ2 h⊤,bˉ2=zˉ2,hˉ=W2⊤zˉ2,zˉ1=hˉ⊙1[z1>0],Wˉ1=zˉ1 x⊤,bˉ1=zˉ1.\begin{aligned} \bar W_2 &= \bar z_2\, h^\top, & \bar b_2 &= \bar z_2, & \bar h &= W_2^\top \bar z_2, \\ \bar z_1 &= \bar h \odot \mathbf 1[z_1 > 0], & \bar W_1 &= \bar z_1\, x^\top, & \bar b_1 &= \bar z_1 . \end{aligned}
quantityshapeadjointshape of the product
z2z_2KKp−eyp - e_yKK
W2W_2K×HK \times Hzˉ2h⊤\bar z_2 h^\top(K×1)(1×H)(K \times 1)(1 \times H)
hhHHW2⊤zˉ2W_2^\top \bar z_2(H×K)(K)(H \times K)(K)
z1z_1HHhˉ⊙1[z1>0]\bar h \odot \mathbf 1[z_1 > 0]HH
W1W_1H×dH \times dzˉ1x⊤\bar z_1 x^\top(H×1)(1×d)(H \times 1)(1 \times d)

For a batch of nn examples with the loss averaged, Zˉ2=(P−Y)/n\bar Z_2 = (P - Y)/n with the one-hot targets as the rows of YY, and the batch formulas of the previous section take over.

Worked example. Take d=2d = 2, H=3H = 3, K=2K = 2, zero biases, true class y=1y = 1 (the first), and

x=(12),W1=(10−110.5−1),W2=(1−120.51−1).x = \begin{pmatrix} 1 \\ 2 \end{pmatrix}, \qquad W_1 = \begin{pmatrix} 1 & 0 \\ -1 & 1 \\ 0.5 & -1 \end{pmatrix}, \qquad W_2 = \begin{pmatrix} 1 & -1 & 2 \\ 0.5 & 1 & -1 \end{pmatrix} .

Forward: z1=(1,1,−1.5)z_1 = (1, 1, -1.5), so h=(1,1,0)h = (1, 1, 0); z2=(0,1.5)z_2 = (0, 1.5), p=(0.182,0.818)p = (0.182, 0.818) and L=−log⁡0.182=1.701L = -\log 0.182 = 1.701. Backward:

  • zˉ2=p−e1=(−0.818,0.818)\bar z_2 = p - e_1 = (-0.818, 0.818);
  • Wˉ2=zˉ2h⊤\bar W_2 = \bar z_2 h^\top has rows (−0.818,−0.818,0)(-0.818, -0.818, 0) and (0.818,0.818,0)(0.818, 0.818, 0);
  • hˉ=W2⊤zˉ2=(−0.409,1.635,−2.453)\bar h = W_2^\top \bar z_2 = (-0.409, 1.635, -2.453);
  • zˉ1=hˉ⊙(1,1,0)=(−0.409,1.635,0)\bar z_1 = \bar h \odot (1, 1, 0) = (-0.409, 1.635, 0);
  • Wˉ1=zˉ1x⊤\bar W_1 = \bar z_1 x^\top has rows (−0.409,−0.818)(-0.409, -0.818), (1.635,3.270)(1.635, 3.270) and (0,0)(0, 0).

The third hidden unit receives the adjoint largest in magnitude, hˉ3=−2.453\bar h_3 = -2.453, but it is inactive, so the mask stops it and its incoming weights get zero gradient from this example. A unit that is inactive for every example never learns, which is the dead ReLU of the previous note.

Figure

Backpropagation through a two-layer network

Forward and backward passTop row: x, W₁, z₁, ReLU, h, W₂, z₂, softmax, p and the loss L, arrows to the right. Bottom row: the gradients L̄, z̄₂, h̄, z̄₁, arrows to the left through p − e₁, W₂ᵀ and the ReLU mask; below, the weight gradients W̄₁ and W̄₂ as matrices.forward pass: valuesbackward pass: gradients12xW111−1.50z11ReLU110h2W201.50z2softmax0.180.82p−log p₁1.70L31Lp − e₁−0.820.82z24W2T−0.411.64−2.45h−0.82−0.8200.820.820W2 = z2hT5⊙1[z₁ > 0]−0.411.640z16−0.41−0.821.643.2700W1 = z1xT7inputstateweights, gatesoutputgradientForward and backward passLeft column, top to bottom: x, W₁, z₁, ReLU, h, W₂, z₂, softmax, p, L. Right column, bottom to top: L̄, p − e₁, z̄₂, W₂ᵀ, h̄, the ReLU mask, z̄₁. Below, the matrices W̄₁ and W̄₂.forward ↓backward ↑12xW111−1.50z11ReLU110h2W201.50z2softmax0.180.82p−log p₁1.70L31Lp − e₁−0.820.82z24W2T−0.411.64−2.45h5−0.82−0.8200.820.820W2 = z2hT⊙1[z₁ > 0]−0.411.640z16−0.41−0.821.643.2700W1 = z1xT7inputstateweights, gatesoutputgradient
The worked example of the text: x = (1, 2), the weights W₁ and W₂ of the text, zero biases, true class 1. The top row is the forward pass, left to right (1–3): z₁ = (1, 1, −1.5), h = (1, 1, 0), z₂ = (0, 1.5), p = (0.182, 0.818), L = 1.701. The bottom row is the backward pass, right to left (4–7): starting from L̄ = 1, each step multiplies the gradient by the transposed Jacobian of its operation and uses values stored during the forward pass (dashed). The third hidden unit is off (z₁ < 0), so the ReLU mask zeroes its gradient and its incoming weights do not change for this example.
  1. The first layer computes z1=W1x+b1z_1 = W_1 x + b_1.
  2. The ReLU keeps the positive entries: h=max⁡(0,z1)h = \max(0, z_1), and the forward pass stores the mask 1[z1>0]\mathbf 1[z_1 > 0].
  3. The second layer gives the logits z2z_2, the softmax the probabilities pp, and the loss is L=−log⁡p1L = -\log p_1.
  4. The backward pass starts from Lˉ=1\bar L = 1; through softmax and cross-entropy, zˉ2=p−e1\bar z_2 = p - e_1.
  5. The second layer gives its weight gradient Wˉ2=zˉ2h⊤\bar W_2 = \bar z_2 h^\top and passes hˉ=W2⊤zˉ2\bar h = W_2^\top \bar z_2 down.
  6. The ReLU mask, stored in step 2, zeroes the adjoint of the inactive unit: zˉ1=hˉ⊙1[z1>0]\bar z_1 = \bar h \odot \mathbf 1[z_1 > 0].
  7. The first layer gives Wˉ1=zˉ1x⊤\bar W_1 = \bar z_1 x^\top and bˉ1=zˉ1\bar b_1 = \bar z_1.

Why reverse mode costs about one forward pass#

Count the work for one linear layer with a batch of nn examples. The forward product HW⊤H W^\top takes n din doutn\, d_{\text{in}}\, d_{\text{out}} multiply–adds. The backward pass computes two products of the same size, ZˉW\bar Z W and Zˉ⊤H\bar Z^\top H, so it costs twice the forward pass; the elementwise steps are negligible next to the matrix products. The whole gradient therefore costs about three forward passes, and this ratio does not depend on the number of parameters. In general, for f:Rn→Rmf: \mathbb R^n \to \mathbb R^m, reverse mode computes the full Jacobian with mm backward sweeps at a cost of m⋅c⋅ops⁡(f)m \cdot c \cdot \operatorname{ops}(f), where c<6c < 6 is guaranteed and cc is typically between 2 and 3 (Baydin et al., 2018, section 3.2; Griewank and Walther, 2008). A loss is a single number, m=1m = 1, so one sweep gives the whole gradient.

Forward mode applies the chain rule in the other direction. Along with every intermediate value uu it carries its derivative u˙=∂u/∂θi\dot u = \partial u / \partial \theta_i with respect to one input direction, computing a Jacobian–vector product JvJ v in one pass. A gradient with respect to nn parameters then needs nn passes, at a cost of n⋅c⋅ops⁡(f)n \cdot c \cdot \operatorname{ops}(f). Forward mode wins when there are few inputs and many outputs; training has millions of inputs (the parameters) and one output (the loss), which is why every deep learning library is built on reverse mode. The Code section computes the 17 partial derivatives of the worked example both ways: 17 forward-mode passes against one backward pass, with identical results.

The price of reverse mode is memory. The backward pass needs the values of the forward pass, such as hh for Wˉ2\bar W_2 and the ReLU masks, so all activations of all layers must be kept until the backward pass reaches them. For large networks this, not the parameters, fills the accelerator’s memory. Gradient checkpointing stores only some layers and recomputes the rest during the backward pass; for a network of nn layers it needs O(n)O(\sqrt n) memory at the cost of one extra forward pass (Chen et al., 2016).

What autograd computes#

Libraries such as PyTorch implement reverse mode automatically. While the forward pass runs, every operation on a tensor that requires gradients records a node of the computational graph, with a function that maps the adjoint of its output to the adjoints of its inputs. PyTorch rebuilds this graph on every forward pass, the operator-overloading approach to reverse-mode automatic differentiation (Paszke et al., 2019, section 4.3), so ordinary Python control flow is differentiable.

mathematicsPyTorch
parameters θ\thetatensors with requires_grad=True (every nn.Parameter)
the forward computational graphthe chain of grad_fn nodes attached to each result
start the backward pass with Lˉ=1\bar L = 1loss.backward()
the adjoint θˉ\bar\thetaparam.grad
sum over the users of a nodegradients are added into .grad, so call optimizer.zero_grad() before each step
one gradient stepoptimizer.step()
a vector–Jacobian product v⊤Jv^\top J for a non-scalar outputout.backward(v) or torch.autograd.grad(out, inputs, grad_outputs=v)
a forward pass that records nothingwith torch.no_grad():

Three consequences are worth knowing:

  • Gradients accumulate. Because .grad is added to, calling backward() twice without zeroing doubles the gradient. This is the multivariate chain rule’s sum, and it allows gradient accumulation over several small batches on purpose.
  • Kinks get a convention. The ReLU has no derivative at 0; PyTorch uses 0 there. Any choice in [0,1][0, 1] is a valid subgradient, and exact zeros are rare in practice.
  • Shared weights add up. A weight used in several places, such as the same recurrent matrix at every time step, gets the sum of the adjoints from all its uses. Unrolled over time, a recurrent network is just a computational graph with shared weights, and backpropagation through time is this note applied to it; the NLP course shows how the products of Jacobians along the time axis make those gradients vanish or explode.

Autograd computes exact derivatives of the program that was run, up to floating point. It does not decide whether those derivatives are useful: a gradient that is zero or nearly zero (a dead ReLU, a saturated sigmoid) or astronomically large is computed just as faithfully. A discrete decision is different: it has no gradient path at all. torch.argmax returns an integer index that is not attached to the graph (requires_grad is False), so a loss computed from the index alone cannot be backpropagated, and a loss that uses the index to select something passes no gradient to the scores that made the choice. When the choice must be learned, a differentiable surrogate replaces it, such as a softmax with a temperature; the Code section shows both.

Checking gradients by finite differences#

A hand-written backward pass, or a custom autograd function, can be wrong in ways that still let the loss decrease. The standard test compares it with the central difference of the first section, one coordinate at a time, through the relative error

∣gbackprop−gnumeric∣max⁡(∣gbackprop∣,∣gnumeric∣,δ),\frac{\lvert g_{\text{backprop}} - g_{\text{numeric}} \rvert}{\max(\lvert g_{\text{backprop}} \rvert, \lvert g_{\text{numeric}} \rvert, \delta)} ,

with a small floor such as δ=10−8\delta = 10^{-8}, so that two zero gradients count as agreement instead of giving 0/00/0.

The central difference is better than the one-sided [L(θ+εei)−L(θ)]/ε[L(\theta + \varepsilon e_i) - L(\theta)]/\varepsilon. By Taylor’s theorem L(θ±εei)=L±εL′+12ε2L′′±16ε3L′′′+…L(\theta \pm \varepsilon e_i) = L \pm \varepsilon L' + \tfrac12 \varepsilon^2 L'' \pm \tfrac16 \varepsilon^3 L''' + \dots, so the one-sided quotient has error 12εL′′\tfrac12 \varepsilon L'' while in the central one the even terms cancel and the error is 16ε2L′′′\tfrac16 \varepsilon^2 L'''. A smaller ε\varepsilon is not always better: each loss value carries a rounding error of about 10−1610^{-16} relative in double precision, and dividing by ε\varepsilon amplifies it.

Figure

The error of finite differences against the step

Two V-shaped error curvesError of central and forward differences against the step on log scales: on the right both fall as the step shrinks, on the left both grow because of rounding; the central difference has the lower minimum at a larger step.10⁻¹⁴10⁻¹²10⁻¹⁰10⁻⁸10⁻⁶10⁻⁴10⁻²10⁻¹³10⁻¹⁰10⁻⁷10⁻⁴10⁻¹step εtruncation ∼ ε² and εrounding ∼ 1/εcentral differenceforward differenceTwo V-shaped error curvesError of central and forward differences against the step on log scales: on the right both fall as the step shrinks, on the left both grow because of rounding; the central difference has the lower minimum at a larger step.10⁻¹⁴10⁻¹²10⁻¹⁰10⁻⁸10⁻⁶10⁻⁴10⁻²10⁻¹³10⁻¹⁰10⁻⁷10⁻⁴10⁻¹step εtruncation ∼ ε² and εrounding ∼ 1/εcentral differenceforward difference
The derivative of the worked example's loss with respect to one weight (W₁)₁₁, exact value −0.40879 from backpropagation, computed in double precision; both axes are logarithmic. On the right the error is the truncation of the Taylor series: at ε = 0.1 it is 2.0·10⁻⁵ for the central difference and 1.9·10⁻³ for the forward one, falling like ε² and like ε. On the left rounding dominates and grows like 1/ε. The central difference is best near ε ≈ 10⁻⁴·⁵ (error of order 10⁻¹²), the forward one near ε ≈ 10⁻⁷·⁵ (order 10⁻¹⁰).

In practice:

  • compute in double precision (torch.float64); in single precision the rounding error is about 10−710^{-7} and the check becomes unreliable;
  • use ε\varepsilon around 10−610^{-6} to 10−510^{-5} with central differences; a relative error near 10−810^{-8} or below means agreement, near 10−210^{-2} or above a bug;
  • avoid points next to kinks: if a ReLU input is within ε\varepsilon of zero, the two sides see different slopes;
  • check a few random coordinates of each parameter tensor for a large network; torch.autograd.gradcheck does the same for a custom function.

In the Code section, the hand-written backward pass of the worked example agrees with central differences on all 17 parameters to a relative error of 3.6⋅10−103.6 \cdot 10^{-10}. Removing the ReLU mask from the backward pass, a typical bug, raises it to 1.01.0: the third unit’s weights get a nonzero gradient where the true one is zero.

What changed#

Numerical differentiationBackpropagation
Cost of the full gradienttwo forward passes per parameterabout three forward passes in total
Accuracytruncation and rounding errorexact up to floating point
Memoryone forward passall stored activations (or checkpoints)
What must be writtenonly the forward passonly the forward pass, with autograd
Role todaytesting a backward passhow every network is trained

Limitations#

  • Memory grows with depth and batch size, because the activations of the forward pass are kept for the backward pass.
  • It needs differentiable operations. Discrete choices, sampling and argmax give no useful gradient: argmax returns an index outside the graph, and a step function has derivative zero almost everywhere. Surrogates such as a softmax with a temperature, and tricks such as the reparameterization trick of the Generative Models course make some of them differentiable.
  • The gradient is local. Backpropagation computes the derivative at the current weights exactly; it does not say whether gradient descent will reach a good solution, and products of many Jacobians can make the gradient vanish or explode in deep or recurrent networks.
  • Sequential in depth. The backward pass of a layer waits for the layer above it, which limits how much the layers of a deep network can be computed in parallel.

What you should remember#

  • The adjoint uˉ=∂L/∂u\bar u = \partial L / \partial u has the shape of uu; a node used several times sums the contributions of its users.
  • Linear layer: Wˉ=zˉh⊤\bar W = \bar z h^\top, bˉ=zˉ\bar b = \bar z, hˉ=W⊤zˉ\bar h = W^\top \bar z; activation: zˉ=φ′(z)⊙hˉ\bar z = \varphi'(z) \odot \bar h; softmax with cross-entropy: zˉ=p−ey\bar z = p - e_y.
  • Reverse mode costs a small constant times one forward pass (about 3 for an MLP), independent of the number of parameters; forward mode needs one pass per parameter.
  • The price is memory: all activations are stored until the backward pass uses them.
  • PyTorch records the graph during the forward pass; loss.backward() fills .grad by adding, hence zero_grad().
  • Check a hand-written gradient with central differences in double precision, ε≈10−6\varepsilon \approx 10^{-6}, by relative error.

Code#

The worked example by hand and by autograd, a gradient check that catches a planted bug, the step size of finite differences, forward against reverse mode, two autograd conventions, and an argmax that has no gradient:

import numpy as np, torch
import torch.autograd.forward_ad as fwAD

# the worked example: x = (1, 2), true class 0 (the first), zero biases
x = np.array([1.0, 2.0]); y = 0
W1 = np.array([[1.0, 0.0], [-1.0, 1.0], [0.5, -1.0]]); b1 = np.zeros(3)
W2 = np.array([[1.0, -1.0, 2.0], [0.5, 1.0, -1.0]]); b2 = np.zeros(2)

def forward(W1, b1, W2, b2):
    z1 = W1 @ x + b1; h = np.maximum(z1, 0); z2 = W2 @ h + b2
    m = z2.max(); logZ = m + np.log(np.exp(z2 - m).sum())
    return z1, h, z2, np.exp(z2 - logZ), logZ - z2[y]

def backward(W1, b1, W2, b2, mask=True):
    z1, h, z2, p, L = forward(W1, b1, W2, b2)
    dz2 = p - np.eye(2)[y]                       # softmax + cross-entropy
    dW2, db2, dh = np.outer(dz2, h), dz2, W2.T @ dz2
    dz1 = dh * (z1 > 0) if mask else dh          # mask=False plants a bug: the ReLU is skipped
    return np.outer(dz1, x), dz1, dW2, db2

z1, h, z2, p, L = forward(W1, b1, W2, b2)
dW1, db1, dW2, db2 = backward(W1, b1, W2, b2)
print(p.round(3), round(L, 3))                   # [0.182 0.818] 1.701
print(dW2.round(3).tolist())                      # [[-0.818, -0.818, -0.0], [0.818, 0.818, 0.0]]
print((W2.T @ (p - np.eye(2)[y])).round(3), dW1.round(3).tolist())
# [-0.409  1.635 -2.453] [[-0.409, -0.818], [1.635, 3.27], [-0.0, -0.0]]

# the same gradients from autograd
T = lambda a: torch.tensor(a, requires_grad=True)
tW1, tb1, tW2, tb2 = T(W1), T(b1), T(W2), T(b2)
loss = torch.nn.functional.cross_entropy((tW2 @ torch.relu(tW1 @ torch.tensor(x) + tb1) + tb2)[None], torch.tensor([y]))
loss.backward()
print(np.allclose(tW1.grad.numpy(), dW1), np.allclose(tW2.grad.numpy(), dW2))   # True True

# gradient check: central differences on every parameter, relative error against backprop
def check(mask):
    params = [W1.copy(), b1.copy(), W2.copy(), b2.copy()]
    grads = backward(*params, mask=mask)
    worst, eps = 0.0, 1e-6
    for P, G in zip(params, grads):
        for i in np.ndindex(P.shape):
            old = P[i]
            P[i] = old + eps; Lp = forward(*params)[-1]
            P[i] = old - eps; Lm = forward(*params)[-1]
            P[i] = old
            num = (Lp - Lm) / (2 * eps)
            worst = max(worst, abs(num - G[i]) / max(abs(num), abs(G[i]), 1e-8))
    return worst
print(f'{check(True):.1e} {check(False):.1e}')   # 3.6e-10 1.0e+00

# the step of a finite difference: truncation error for large steps, rounding error for tiny ones
f = lambda a: forward(np.array([[a, 0.0], [-1.0, 1.0], [0.5, -1.0]]), b1, W2, b2)[-1]
for eps in (1e-1, 1e-5, 1e-12):
    print(eps, f'{abs((f(1 + eps) - f(1 - eps)) / (2 * eps) - dW1[0, 0]):.1e}')
# 0.1 2.0e-05 | 1e-05 7.4e-12 | 1e-12 1.1e-04

# forward mode needs one pass per parameter, reverse mode one pass in total
flat = torch.cat([t.detach().flatten() for t in (tW1, tb1, tW2, tb2)])
def L_of(theta):
    A, a, B, b = theta[:6].view(3, 2), theta[6:9], theta[9:15].view(2, 3), theta[15:]
    return torch.nn.functional.cross_entropy((B @ torch.relu(A @ torch.tensor(x) + a) + b)[None], torch.tensor([y]))
def directional(v):                              # one forward-mode pass: the derivative along v
    with fwAD.dual_level():
        return fwAD.unpack_dual(L_of(fwAD.make_dual(flat, v))).tangent
fwd = torch.stack([directional(torch.eye(17, dtype=flat.dtype)[i]) for i in range(17)])
print(len(fwd), torch.allclose(fwd, torch.cat([t.grad.flatten() for t in (tW1, tb1, tW2, tb2)])))   # 17 True

# autograd details: the ReLU has derivative 0 at 0, and .grad accumulates until it is zeroed
u = torch.tensor(0.0, requires_grad=True); torch.relu(u).backward(); print(u.grad.item())   # 0.0
w = torch.tensor(3.0, requires_grad=True)
for _ in range(2):
    (w ** 2).backward()
print(w.grad.item())                              # 12.0: two backward calls add 6 + 6

# argmax returns an integer index with no autograd path; a softmax with temperature 0.5 is a differentiable surrogate
s = torch.tensor([1.0, 3.0, 2.0], requires_grad=True)
k = s.argmax(); print(k.item(), k.dtype, k.requires_grad)   # 1 torch.int64 False
(torch.softmax(s / 0.5, 0) @ torch.arange(3.0)).backward()   # the expected index under the softmax, 1.101
print([round(g, 3) for g in s.grad.tolist()])                 # [-0.035, -0.176, 0.211]

Next#

Backpropagation gives exact gradients for a network of any depth. Exact does not mean useful: in a deep stack the gradient is a product of one Jacobian per layer and can shrink or grow geometrically, and a network flexible enough to fit anything fits its training noise too. The next note covers what makes deep networks trainable and keeps them from overfitting: initialization that preserves the scale of signals, dropout, weight decay, learning-rate schedules, data augmentation and residual connections.

References#