Article / Deep Learning

Neural networks and backpropagation from first principles

Derive backpropagation for fully connected networks in matrix form, work one example by hand, verify gradients numerically, and build a NumPy MLP from scratch.

Aayush Raj Regmi17 min read
In this article · 10 sections

A fully connected neural network is a chain of simple functions: multiply by a matrix, add a vector, apply a fixed nonlinearity, repeat. Training it means adjusting every matrix and vector so a loss gets smaller. Backpropagation is the bookkeeping that tells us, for every one of those numbers at once, which direction reduces the loss. It is not a separate learning theory. It is the chain rule from calculus, applied in an order that reuses intermediate results instead of recomputing them.

This guide derives backpropagation for a multilayer perceptron (MLP) carefully enough that you could reproduce it on paper, then checks every claim with numbers. We work one tiny network by hand, verify the gradient with finite differences, and finish with a complete NumPy implementation that trains on a toy dataset. Frameworks such as PyTorch automate exactly this computation; knowing what they automate makes their failures much easier to diagnose.

Neurons and layers are affine maps followed by a nonlinearity

A single neuron receives an input vector x∈Rnx\in\mathbb R^{n}, computes a weighted sum plus a bias, and passes the result through an activation function σ\sigma:

z=w⊤x+b,a=σ(z).z = w^\top x + b,\qquad a = \sigma(z).

The weighted sum zz is called the pre-activation. Geometrically, w⊤x+b=0w^\top x + b = 0 is a hyperplane, and the neuron reports how far, and on which side, the input lies, squashed or clipped by σ\sigma.

A layer is many neurons reading the same input. Stack their weight vectors as the rows of a matrix and the whole layer becomes one affine map followed by an elementwise nonlinearity. For layer ll of a network with LL layers:

zl=Wlal−1+bl,al=σ(zl),a0=x.z^{l} = W^{l} a^{l-1} + b^{l},\qquad a^{l} = \sigma(z^{l}),\qquad a^{0} = x.

The nonlinearity is essential. Without it, composing layers gives W2(W1x+b1)+b2W^{2}(W^{1}x + b^{1}) + b^{2}, which is still a single affine map. Depth adds expressive power only because σ\sigma bends the space between layers.

Keep shapes visible. Almost every backpropagation bug is a shape or transpose mistake that happens to broadcast without an error. We use column vectors for a single example and stack a batch of BB examples as columns.

SymbolMeaningShape
nln_lnumber of units in layer lscalar
a0=xa^{0} = xinputn0×1n_0 \times 1
WlW^{l}weights of layer lnl×nl−1n_l \times n_{l-1}
blb^{l}bias of layer lnl×1n_l \times 1
zlz^{l}pre-activationnl×1n_l \times 1
ala^{l}activationnl×1n_l \times 1
δl\delta^{l}error signal, the gradient of the loss with respect to the pre-activationnl×1n_l \times 1
Al,Zl,ΔlA^{l}, Z^{l}, \Delta^{l}batch versions, one column per examplenl×Bn_l \times B

The forward pass is just the recursion above, evaluated from l=1l=1 to l=Ll=L. The output aLa^{L} is the prediction, often written y^\hat y.

flowchart LR
  X["Input x<br/>a0"] --> Z1["z1 = W1 a0 + b1"]
  Z1 --> A1["a1 = sigma of z1"]
  A1 --> Z2["z2 = W2 a1 + b2"]
  Z2 --> A2["a2 = sigma of z2"]
  A2 --> ZL["zL = WL a_L-1 + bL"]
  ZL --> P["Output y_hat"]
  P --> LOSS["Loss L of y_hat and y"]

Loss functions turn predictions into one number

Gradient-based training needs a single scalar to decrease. For regression, the usual choice is squared error. Including a factor of one half makes the derivative cleaner:

LMSE=12∥y^−y∥2,∂LMSE∂y^=y^−y.\mathcal L_{\mathrm{MSE}} = \frac{1}{2}\lVert \hat y - y \rVert^{2},\qquad \frac{\partial \mathcal L_{\mathrm{MSE}}}{\partial \hat y} = \hat y - y.

For classification with KK classes, the last layer produces KK unconstrained scores, or logits, zLz^{L}. The softmax turns them into a probability vector:

pk=exp⁡(zkL)∑j=1Kexp⁡(zjL).p_k = \frac{\exp(z^{L}_k)}{\sum_{j=1}^{K}\exp(z^{L}_j)}.

With a one-hot target yy, the cross-entropy loss is the negative log probability assigned to the correct class:

LCE=−∑k=1Kyklog⁡pk.\mathcal L_{\mathrm{CE}} = -\sum_{k=1}^{K} y_k \log p_k.

If the correct class gets probability 0.90.9, the loss is about 0.1050.105. If it gets 0.010.01, the loss is about 4.6054.605. Confident mistakes are punished hard, which is exactly the signal a classifier needs. Over a batch, we average the per-example losses so that the scale of the gradient does not depend on batch size.

Computational graphs and the chain rule

Write the computation as a graph: each node is an intermediate value, and each edge says which values were used to compute which. The forward pass walks the graph from inputs to loss. To differentiate, we walk it backward.

The rule we need is the multivariable chain rule. If a scalar L\mathcal L depends on uu only through intermediate variables v1,…,vmv_1,\ldots,v_m, then

∂L∂u=∑i=1m∂L∂vi ∂vi∂u.\frac{\partial \mathcal L}{\partial u} = \sum_{i=1}^{m} \frac{\partial \mathcal L}{\partial v_i}\,\frac{\partial v_i}{\partial u}.

In words: the sensitivity of the loss to uu is the sum, over every path from uu to the loss, of the local derivative along that path times the downstream sensitivity. Each node only needs two things: the gradient arriving from above, and its own local derivative. That locality is why backpropagation is efficient. Computing the gradient for all parameters costs a small constant multiple of one forward pass, instead of one forward pass per parameter as naive finite differences would.

flowchart LR
  subgraph FWD["Forward pass: compute and cache values"]
    direction LR
    F0["a0 = x"] --> F1["z1"] --> F2["a1"] --> F3["z2"] --> F4["a2 = y_hat"] --> F5["L"]
  end
  subgraph BWD["Backward pass: reuse cached values"]
    direction RL
    B5["dL/dL = 1"] --> B4["dL/da2"] --> B3["delta2 = dL/dz2"] --> B2["dL/da1 = W2^T delta2"] --> B1["delta1 = dL/dz1"]
  end
  B3 --> G2["dW2 = delta2 a1^T"]
  B1 --> G1["dW1 = delta1 a0^T"]

The forward pass must store, or cache, the pre-activations zlz^{l} and activations ala^{l}. The backward pass reads them. This is why training uses much more memory than inference: inference can discard each activation once the next layer has consumed it.

Deriving backpropagation in matrix form

One layer

Start with a single layer, z=Wa+bz = W a + b, where aa is the layer input, and suppose something downstream has already given us δ=∂L/∂z\delta = \partial \mathcal L / \partial z. Entry zi=∑jWijaj+biz_i = \sum_j W_{ij} a_j + b_i depends on WijW_{ij} only through itself, and the local derivative is aja_j. So

∂L∂Wij=δi aj,∂L∂bi=δi.\frac{\partial \mathcal L}{\partial W_{ij}} = \delta_i\, a_j,\qquad \frac{\partial \mathcal L}{\partial b_i} = \delta_i.

Collecting all (i,j)(i,j) entries gives an outer product, which has the same shape as WW, as it must:

∂L∂W=δ a⊤,∂L∂b=δ.\frac{\partial \mathcal L}{\partial W} = \delta\, a^\top,\qquad \frac{\partial \mathcal L}{\partial b} = \delta.

To continue backward, we also need the gradient with respect to the layer input. Input aja_j influences every ziz_i, so we sum over ii:

∂L∂aj=∑iδiWij⟹∂L∂a=W⊤δ.\frac{\partial \mathcal L}{\partial a_j} = \sum_i \delta_i W_{ij} \quad\Longrightarrow\quad \frac{\partial \mathcal L}{\partial a} = W^\top \delta.

The transpose is not a convention to memorize. It appears because the forward pass sends input jj into every output ii, so the backward pass gathers from every ii back into jj.

L layers: the delta recursion

Define the error signal of layer ll as δl=∂L/∂zl\delta^{l} = \partial \mathcal L / \partial z^{l}. At the output layer, apply the chain rule through the final activation:

δL=∂L∂aL⊙σ′(zL).\delta^{L} = \frac{\partial \mathcal L}{\partial a^{L}} \odot \sigma'(z^{L}).

Here ⊙\odot is elementwise multiplication. It appears because σ\sigma acts elementwise, so its Jacobian is diagonal, and multiplying by a diagonal matrix is the same as multiplying elementwise.

For a hidden layer, zlz^{l} affects the loss only through al=σ(zl)a^{l} = \sigma(z^{l}), which affects the loss only through zl+1=Wl+1al+bl+1z^{l+1} = W^{l+1} a^{l} + b^{l+1}. Componentwise,

δjl=∑i∂L∂zil+1 ∂zil+1∂ajl ∂ajl∂zjl=(∑iδil+1Wijl+1) σ′(zjl).\delta^{l}_j = \sum_i \frac{\partial \mathcal L}{\partial z^{l+1}_i}\,\frac{\partial z^{l+1}_i}{\partial a^{l}_j}\,\frac{\partial a^{l}_j}{\partial z^{l}_j} = \Big(\sum_i \delta^{l+1}_i W^{l+1}_{ij}\Big)\,\sigma'(z^{l}_j).

In matrix form, this is the central equation of backpropagation:

δl=(Wl+1)⊤δl+1⊙σ′(zl).\delta^{l} = \big(W^{l+1}\big)^\top \delta^{l+1} \odot \sigma'(z^{l}).

Combined with the one-layer result, the parameter gradients for every layer are

∂L∂Wl=δl (al−1)⊤,∂L∂bl=δl.\frac{\partial \mathcal L}{\partial W^{l}} = \delta^{l}\,\big(a^{l-1}\big)^\top,\qquad \frac{\partial \mathcal L}{\partial b^{l}} = \delta^{l}.

Check the shapes: (Wl+1)⊤(W^{l+1})^\top is nl×nl+1n_l \times n_{l+1} and δl+1\delta^{l+1} is nl+1×1n_{l+1} \times 1, so their product is nl×1n_l \times 1, matching zlz^{l}. And δl(al−1)⊤\delta^{l}(a^{l-1})^\top is nl×nl−1n_l \times n_{l-1}, matching WlW^{l}.

Batch form

With a batch of BB examples stacked as columns, Zl=WlAl−1+bl1⊤Z^{l} = W^{l} A^{l-1} + b^{l}\mathbf 1^\top, where the bias is broadcast across columns. The batch loss is the mean of per-example losses, so each parameter gradient is the mean of per-example gradients. Writing Δl\Delta^{l} for the matrix whose columns are the per-example deltas:

Δl=(Wl+1)⊤Δl+1⊙σ′(Zl),\Delta^{l} = \big(W^{l+1}\big)^\top \Delta^{l+1} \odot \sigma'(Z^{l}), ∂L∂Wl=1B Δl(Al−1)⊤,∂L∂bl=1B Δl1.\frac{\partial \mathcal L}{\partial W^{l}} = \frac{1}{B}\,\Delta^{l}\big(A^{l-1}\big)^\top,\qquad \frac{\partial \mathcal L}{\partial b^{l}} = \frac{1}{B}\,\Delta^{l}\mathbf 1.

The matrix product Δl(Al−1)⊤\Delta^{l}(A^{l-1})^\top is precisely the sum of the BB outer products, so a single matrix multiplication does the averaging work. The bias gradient sums Δl\Delta^{l} across columns, because the bias was copied to every column in the forward pass. In code, it is convenient to divide by BB once, at the output delta, and then let the recursion carry that factor through.

The softmax and cross-entropy gradient is p minus y

For a classifier, we could compute ∂L/∂p\partial \mathcal L/\partial p and then multiply by the softmax Jacobian. That works but is numerically fragile, since ∂L/∂pk=−yk/pk\partial \mathcal L/\partial p_k = -y_k / p_k explodes when pkp_k is tiny. Combining the two steps gives a much simpler result.

First, the softmax Jacobian. Differentiate pk=exp⁡(zk)/Sp_k = \exp(z_k)/S with S=∑jexp⁡(zj)S = \sum_j \exp(z_j), using the quotient rule. If j=kj = k:

∂pk∂zk=exp⁡(zk) S−exp⁡(zk)exp⁡(zk)S2=pk(1−pk).\frac{\partial p_k}{\partial z_k} = \frac{\exp(z_k)\,S - \exp(z_k)\exp(z_k)}{S^{2}} = p_k(1 - p_k).

If j≠kj \neq k, only the denominator depends on zjz_j:

∂pk∂zj=−exp⁡(zk)exp⁡(zj)S2=−pkpj.\frac{\partial p_k}{\partial z_j} = -\frac{\exp(z_k)\exp(z_j)}{S^{2}} = -p_k p_j.

Both cases fit one formula using the Kronecker delta 1[j=k]\mathbb 1[j = k]:

∂pk∂zj=pk(1[j=k]−pj).\frac{\partial p_k}{\partial z_j} = p_k\big(\mathbb 1[j = k] - p_j\big).

Now apply the chain rule to L=−∑kyklog⁡pk\mathcal L = -\sum_k y_k \log p_k:

∂L∂zj=−∑kykpk pk(1[j=k]−pj)=−∑kyk 1[j=k]+pj∑kyk=−yj+pj.\frac{\partial \mathcal L}{\partial z_j} = -\sum_k \frac{y_k}{p_k}\,p_k\big(\mathbb 1[j = k] - p_j\big) = -\sum_k y_k\,\mathbb 1[j = k] + p_j \sum_k y_k = -y_j + p_j.

The last step uses ∑kyk=1\sum_k y_k = 1, which holds for one-hot targets and for any target probability vector. So

δL=p−y.\delta^{L} = p - y.

The intuition is pleasant: the error signal is the gap between what the network predicted and what was true. If the network assigns probability 0.70.7 to the correct class, that logit receives gradient −0.3-0.3 and is pushed up; every wrong class receives its own probability as gradient and is pushed down. The same simplification happens for a sigmoid output with binary cross-entropy, and for a linear output with squared error: each is the canonical pairing of an output function with its loss.

Activation derivatives, saturation, and initialization

The delta recursion multiplies by σ′(zl)\sigma'(z^{l}) at every layer. The size of that factor therefore controls whether gradients survive the trip back through a deep network. The three classic activations and their derivatives are:

sigmoid⁡(z)=11+e−z,sigmoid⁡′(z)=sigmoid⁡(z)(1−sigmoid⁡(z)),\operatorname{sigmoid}(z) = \frac{1}{1 + e^{-z}},\qquad \operatorname{sigmoid}'(z) = \operatorname{sigmoid}(z)\big(1 - \operatorname{sigmoid}(z)\big), tanh⁡′(z)=1−tanh⁡2(z),ReLU⁡(z)=max⁡(0,z),ReLU⁡′(z)=1[z>0].\tanh'(z) = 1 - \tanh^{2}(z),\qquad \operatorname{ReLU}(z) = \max(0, z),\qquad \operatorname{ReLU}'(z) = \mathbb 1[z > 0].

The sigmoid derivative peaks at z=0z = 0 with value 1/41/4 and decays quickly. When a neuron's pre-activation is large in magnitude, the neuron is saturated: its output barely changes, so its derivative is nearly zero, and almost no gradient passes through it. Even in the best case, a chain of sigmoid layers multiplies the signal by at most 0.250.25 per layer, ignoring the weights.

import numpy as np

sigmoid = lambda z: 1 / (1 + np.exp(-z))
for z in [0.0, 2.0, 5.0, 10.0]:
    s = sigmoid(z)
    print(f"z = {z:5.1f}   sigmoid'(z) = {s * (1 - s):.6f}")

# Best case: every layer sits at z = 0, so each factor is 0.25.
for depth in [1, 5, 10, 20]:
    print(f"{depth:2d} layers: 0.25 ** depth = {0.25 ** depth:.3e}")

Running it shows 0.250.25 at z=0z = 0, about 0.1050.105 at z=2z = 2, about 0.00660.0066 at z=5z = 5, and about 4.5×10−54.5\times 10^{-5} at z=10z = 10. Ten layers at the most favourable point already shrink the factor to about 9.5×10−79.5\times 10^{-7}. Larger weights can partly compensate, but they also push pre-activations into saturation. This is the vanishing-gradient problem in its simplest form. Tanh is zero-centred and has a maximum derivative of 11, which helps, but it saturates too. ReLU passes gradient with factor exactly 11 wherever it is active, which is a large part of why it made deeper networks practical. Its failure mode is different: a unit whose pre-activation is negative for every input receives zero gradient forever, a so-called dead ReLU.

Why initialization scale matters

Initialization decides where the pre-activations start. Consider one unit, zi=∑j=1ninWijajz_i = \sum_{j=1}^{n_{\mathrm{in}}} W_{ij} a_j, with weights drawn independently with mean zero and variance Var⁡(W)\operatorname{Var}(W), independent of zero-mean inputs. Then

Var⁡(zi)=ninVar⁡(W) E[aj2].\operatorname{Var}(z_i) = n_{\mathrm{in}}\operatorname{Var}(W)\,\mathbb E\big[a_j^{2}\big].

If we want the signal's scale to stay roughly constant from layer to layer, we need ninVar⁡(W)≈1n_{\mathrm{in}}\operatorname{Var}(W) \approx 1 for an activation that behaves like the identity near zero, such as tanh. Applying the same argument to the backward pass gives noutVar⁡(W)≈1n_{\mathrm{out}}\operatorname{Var}(W) \approx 1. Glorot and Bengio (2010) compromise between the two:

Var⁡(W)=2nin+nout.\operatorname{Var}(W) = \frac{2}{n_{\mathrm{in}} + n_{\mathrm{out}}}.

ReLU zeroes roughly half of a symmetric input distribution, so E[a2]=Var⁡(z)/2\mathbb E[a^{2}] = \operatorname{Var}(z)/2 for the previous layer's pre-activation. To compensate, He et al. (2015) double the variance:

Var⁡(W)=2nin.\operatorname{Var}(W) = \frac{2}{n_{\mathrm{in}}}.

These are heuristics derived under independence assumptions, not guarantees. They matter most in deep networks; in a two-layer toy problem, almost any reasonable small scale trains. One rule is not optional: do not initialize all weights to the same value. Every unit in a layer would then compute the same function and receive the same gradient, and they would never become different.

A fully worked numeric example

Here is one forward pass, one backward pass, and one stochastic gradient descent (SGD) step for a 2-2-1 network, small enough to follow with a calculator. The hidden layer uses a sigmoid; the output is linear with loss 12(y^−y)2\frac12(\hat y - y)^{2}, so δ2=y^−y\delta^{2} = \hat y - y.

x=[12],y=1,W1=[0.10.2−0.30.4],b1=[00.1],W2=[0.5−0.6],b2=0.2.x = \begin{bmatrix} 1 \\ 2 \end{bmatrix},\quad y = 1,\quad W^{1} = \begin{bmatrix} 0.1 & 0.2 \\ -0.3 & 0.4 \end{bmatrix},\quad b^{1} = \begin{bmatrix} 0 \\ 0.1 \end{bmatrix},\quad W^{2} = \begin{bmatrix} 0.5 & -0.6 \end{bmatrix},\quad b^{2} = 0.2.

Forward

z1=[0.1+0.4+0−0.3+0.8+0.1]=[0.50.6],a1=sigmoid⁡(z1)≈[0.6224590.645656].z^{1} = \begin{bmatrix} 0.1 + 0.4 + 0 \\ -0.3 + 0.8 + 0.1 \end{bmatrix} = \begin{bmatrix} 0.5 \\ 0.6 \end{bmatrix},\qquad a^{1} = \operatorname{sigmoid}(z^{1}) \approx \begin{bmatrix} 0.622459 \\ 0.645656 \end{bmatrix}. y^=z2=0.5(0.622459)−0.6(0.645656)+0.2≈0.123836,L=12(0.123836−1)2≈0.383832.\hat y = z^{2} = 0.5(0.622459) - 0.6(0.645656) + 0.2 \approx 0.123836,\qquad \mathcal L = \tfrac12(0.123836 - 1)^{2} \approx 0.383832.

Backward

The output delta and the output-layer gradients:

δ2=y^−y≈−0.876164,∂L∂W2=δ2(a1)⊤≈[−0.545377−0.565701],∂L∂b2≈−0.876164.\delta^{2} = \hat y - y \approx -0.876164,\qquad \frac{\partial \mathcal L}{\partial W^{2}} = \delta^{2}(a^{1})^\top \approx \begin{bmatrix} -0.545377 & -0.565701 \end{bmatrix},\qquad \frac{\partial \mathcal L}{\partial b^{2}} \approx -0.876164.

Send the error back through W2W^{2}, then through the sigmoid, whose derivative is a1⊙(1−a1)a^{1}\odot(1 - a^{1}):

(W2)⊤δ2≈[−0.4380820.525698],sigmoid⁡′(z1)≈[0.2350040.228784],δ1≈[−0.1029510.120272].(W^{2})^\top \delta^{2} \approx \begin{bmatrix} -0.438082 \\ 0.525698 \end{bmatrix},\qquad \operatorname{sigmoid}'(z^{1}) \approx \begin{bmatrix} 0.235004 \\ 0.228784 \end{bmatrix},\qquad \delta^{1} \approx \begin{bmatrix} -0.102951 \\ 0.120272 \end{bmatrix}. ∂L∂W1=δ1x⊤≈[−0.102951−0.2059020.1202720.240543],∂L∂b1=δ1.\frac{\partial \mathcal L}{\partial W^{1}} = \delta^{1}x^\top \approx \begin{bmatrix} -0.102951 & -0.205902 \\ 0.120272 & 0.240543 \end{bmatrix},\qquad \frac{\partial \mathcal L}{\partial b^{1}} = \delta^{1}.

Notice how much smaller δ1\delta^{1} is than δ2\delta^{2}: the sigmoid derivative of roughly 0.230.23 cut it by a factor of about four, as the previous section predicted.

One SGD step

With learning rate η=0.1\eta = 0.1, each parameter moves by −η-\eta times its gradient, θ←θ−η ∂L/∂θ\theta \leftarrow \theta - \eta\,\partial\mathcal L/\partial\theta:

W1≈[0.1102950.220590−0.3120270.375946],b1≈[0.0102950.087973],W2≈[0.554538−0.543430],b2≈0.287616.W^{1} \approx \begin{bmatrix} 0.110295 & 0.220590 \\ -0.312027 & 0.375946 \end{bmatrix},\quad b^{1} \approx \begin{bmatrix} 0.010295 \\ 0.087973 \end{bmatrix},\quad W^{2} \approx \begin{bmatrix} 0.554538 & -0.543430 \end{bmatrix},\quad b^{2} \approx 0.287616.

Re-running the forward pass with the updated parameters gives a loss of about 0.2457180.245718, down from 0.3838320.383832. The code below reproduces every number, and also checks one weight gradient with a centred finite difference; the analytic and numeric values agree to about ten significant figures.

import numpy as np

sigmoid = lambda z: 1 / (1 + np.exp(-z))
x = np.array([[1.0], [2.0]])
y = 1.0
W1 = np.array([[0.1, 0.2], [-0.3, 0.4]])
b1 = np.array([[0.0], [0.1]])
W2 = np.array([[0.5, -0.6]])
b2 = np.array([[0.2]])
lr = 0.1


def loss(W1, b1, W2, b2):
    a1 = sigmoid(W1 @ x + b1)
    return 0.5 * ((W2 @ a1 + b2).item() - y) ** 2


# Forward
z1 = W1 @ x + b1
a1 = sigmoid(z1)
y_hat = (W2 @ a1 + b2).item()
print("z1", z1.ravel(), "a1", a1.ravel().round(6))
print("y_hat", round(y_hat, 6), "loss", round(loss(W1, b1, W2, b2), 6))

# Backward
d2 = np.array([[y_hat - y]])
dW2, db2 = d2 @ a1.T, d2
d1 = (W2.T @ d2) * a1 * (1 - a1)
dW1, db1 = d1 @ x.T, d1
print("dW2", dW2.round(6), "db2", db2.ravel().round(6))
print("delta1", d1.ravel().round(6))
print("dW1", dW1.round(6))

# One SGD step
W1n, b1n, W2n, b2n = W1 - lr * dW1, b1 - lr * db1, W2 - lr * dW2, b2 - lr * db2
print("new loss", round(loss(W1n, b1n, W2n, b2n), 6))

# Finite-difference check of dL/dW1[0, 1]
eps = 1e-6
Wp, Wm = W1.copy(), W1.copy()
Wp[0, 1] += eps
Wm[0, 1] -= eps
numeric = (loss(Wp, b1, W2, b2) - loss(Wm, b1, W2, b2)) / (2 * eps)
print(f"numeric {numeric:.10f}  analytic {dW1[0, 1]:.10f}")

Gradient checking with finite differences

A backpropagation implementation can be subtly wrong and still reduce the loss, because a gradient that is merely correlated with the true one can point downhill. The only way to know is to compare against an independent estimate. The centred difference

∂L∂θi≈L(θ+ϵei)−L(θ−ϵei)2ϵ\frac{\partial \mathcal L}{\partial \theta_i} \approx \frac{\mathcal L(\theta + \epsilon e_i) - \mathcal L(\theta - \epsilon e_i)}{2\epsilon}

has truncation error proportional to ϵ2\epsilon^{2}, compared with ϵ\epsilon for the one-sided version. Making ϵ\epsilon too small introduces floating-point cancellation, so in double precision a value around 10−510^{-5} to 10−610^{-6} is a sensible default. Compare with a relative error, because gradients vary widely in scale:

rel⁡(gnum,gbp)=∣gnum−gbp∣∣gnum∣+∣gbp∣.\operatorname{rel}(g_{\mathrm{num}}, g_{\mathrm{bp}}) = \frac{\lvert g_{\mathrm{num}} - g_{\mathrm{bp}}\rvert}{\lvert g_{\mathrm{num}}\rvert + \lvert g_{\mathrm{bp}}\rvert}.

As a rough guide in float64, values around 10−710^{-7} or smaller indicate a correct implementation, while values near 10−210^{-2} or larger almost always mean a bug. Some practical rules make the check trustworthy:

  • Use float64 for the check, even if training later uses lower precision.
  • Turn off randomness such as dropout, and keep the batch fixed between the two evaluations.
  • Check a small batch and a random sample of parameters in every layer, including biases. A bug confined to one layer can hide in an average.
  • ReLU has a kink at zero. If a perturbation crosses it, the finite difference is meaningless for that coordinate. Occasional isolated failures with ReLU are worth inspecting before panicking.
  • Gradient checking is slow, roughly two forward passes per parameter checked. Use it to validate the code, then switch it off.

A complete MLP in NumPy

The program below generates a two-moons dataset, which is not linearly separable, builds a 2-16-16-2 network with ReLU hidden layers and a softmax output, checks its gradients, and trains it with minibatch SGD. Every gradient follows the equations above: the output delta is (P−Y)/B(P - Y)/B, the hidden deltas follow the recursion with the ReLU derivative, and the weight gradient is Δ(Al−1)⊤\Delta (A^{l-1})^\top. Examples are columns, so the code and the math use the same shapes.

import numpy as np

rng = np.random.default_rng(0)


def make_moons(n, noise=0.15):
    """Two interleaving half-circles. Returns X of shape (2, n) and labels y of shape (n,)."""
    m = n // 2
    t1 = rng.uniform(0, np.pi, m)
    t2 = rng.uniform(0, np.pi, n - m)
    upper = np.stack([np.cos(t1), np.sin(t1)])
    lower = np.stack([1 - np.cos(t2), 0.5 - np.sin(t2)])
    X = np.concatenate([upper, lower], axis=1) + noise * rng.standard_normal((2, n))
    y = np.concatenate([np.zeros(m, dtype=int), np.ones(n - m, dtype=int)])
    return X, y


def init_params(sizes):
    """He initialization for ReLU layers: W ~ N(0, 2 / fan_in)."""
    params = []
    for n_in, n_out in zip(sizes[:-1], sizes[1:]):
        W = rng.standard_normal((n_out, n_in)) * np.sqrt(2.0 / n_in)
        b = np.zeros((n_out, 1))
        params.append([W, b])
    return params


def relu(z):
    return np.maximum(z, 0.0)


def softmax(z):
    z = z - z.max(axis=0, keepdims=True)  # stability: subtract the column max
    e = np.exp(z)
    return e / e.sum(axis=0, keepdims=True)


def forward(params, X):
    """Columns are examples. Returns the class probabilities and a cache for backprop."""
    a = X
    cache = [(None, a)]
    for i, (W, b) in enumerate(params):
        z = W @ a + b
        a = softmax(z) if i == len(params) - 1 else relu(z)
        cache.append((z, a))
    return a, cache


def loss_and_grads(params, X, y):
    B = X.shape[1]
    P, cache = forward(params, X)
    Y = np.eye(P.shape[0])[:, y]  # one-hot targets, shape (K, B)
    loss = -np.mean(np.log(P[y, np.arange(B)] + 1e-12))

    grads = [None] * len(params)
    delta = (P - Y) / B  # dL/dz at the output layer, already averaged over the batch
    for l in range(len(params) - 1, -1, -1):
        W, _ = params[l]
        a_prev = cache[l][1]
        grads[l] = [delta @ a_prev.T, delta.sum(axis=1, keepdims=True)]
        if l > 0:
            z_prev = cache[l][0]
            delta = (W.T @ delta) * (z_prev > 0)  # ReLU derivative
    return loss, grads


def grad_check(params, X, y, eps=1e-5, checks=8):
    _, grads = loss_and_grads(params, X, y)
    worst = 0.0
    for l, (W, b) in enumerate(params):
        for P, G in ((W, grads[l][0]), (b, grads[l][1])):
            for _ in range(checks):
                idx = tuple(rng.integers(s) for s in P.shape)
                old = P[idx]
                P[idx] = old + eps
                lp, _ = loss_and_grads(params, X, y)
                P[idx] = old - eps
                lm, _ = loss_and_grads(params, X, y)
                P[idx] = old
                numeric = (lp - lm) / (2 * eps)
                rel = abs(numeric - G[idx]) / max(1e-8, abs(numeric) + abs(G[idx]))
                worst = max(worst, rel)
    return worst


X, y = make_moons(400)
X_test, y_test = make_moons(200)
params = init_params([2, 16, 16, 2])

print(f"worst relative error in gradient check: {grad_check(params, X[:, :20], y[:20]):.2e}")
print(f"loss before training: {loss_and_grads(params, X, y)[0]:.4f}")

lr, batch = 0.5, 32
for epoch in range(301):
    order = rng.permutation(X.shape[1])
    for start in range(0, X.shape[1], batch):
        idx = order[start:start + batch]
        _, grads = loss_and_grads(params, X[:, idx], y[idx])
        for (W, b), (dW, db) in zip(params, grads):
            W -= lr * dW
            b -= lr * db
    if epoch % 100 == 0:
        loss, _ = loss_and_grads(params, X, y)
        acc = np.mean(forward(params, X)[0].argmax(axis=0) == y)
        print(f"epoch {epoch:3d}  loss {loss:.4f}  train accuracy {acc:.3f}")

test_acc = np.mean(forward(params, X_test)[0].argmax(axis=0) == y_test)
print(f"test accuracy: {test_acc:.3f}")

In my run with this seed, the worst relative error in the gradient check was about 6.6×10−106.6\times 10^{-10}. The training loss started at about 1.061.06, fell to about 0.280.28 after the first epoch, and reached about 0.0100.010 after 300 epochs. Training accuracy ended near 99.899.8 percent and accuracy on the 200 held-out points was 97.597.5 percent. Your numbers will vary with the seed, the noise level, and the NumPy version. The point is not the score on a toy problem; it is that the gradient check passed before any training happened, so a falling loss can be trusted to mean the optimizer is following the true gradient.

A few design choices deserve a note. The softmax subtracts the column maximum before exponentiating; this leaves the probabilities unchanged but prevents overflow. The loss adds a tiny constant inside the logarithm only as a guard; the gradient never uses that logarithm, because it uses p−yp - y directly. And the update modifies the arrays in place with -=, so the list params sees the new values without being rebuilt.

flowchart TD
  S["Sample a minibatch"] --> F["Forward pass<br/>cache z and a for every layer"]
  F --> L["Compute mean loss"]
  L --> D["Output delta = P - Y over B"]
  D --> R["Backward recursion<br/>delta = W^T delta times relu prime"]
  R --> G["Gradients dW = delta a_prev^T and db = row sums"]
  G --> U["SGD update W -= lr dW"]
  U --> S

Common bugs and practice exercises

Most backpropagation bugs fall into a handful of families. Knowing them turns a mysterious plateau into a short checklist.

  • Missing or extra transpose. Using Wl+1δl+1W^{l+1}\delta^{l+1} instead of (Wl+1)⊤δl+1(W^{l+1})^\top\delta^{l+1} fails loudly when layers have different widths, but silently runs when they are square. Test with unequal layer sizes.
  • Wrong cached value. The derivative must be evaluated at the pre-activation zlz^{l} of the same forward pass. Writing σ′(al)\sigma'(a^{l}) instead of σ′(zl)\sigma'(z^{l}) is a classic sigmoid mistake; for sigmoid, the shortcut al(1−al)a^{l}(1 - a^{l}) is correct precisely because it is written in terms of ala^{l}.
  • Batch averaging twice or not at all. If the loss is a mean, divide by BB exactly once. Dividing at the output delta and again in the weight gradient makes the effective learning rate shrink with batch size.
  • Bias gradient summed over the wrong axis. With examples as columns, sum across columns. Broadcasting will happily produce an array of the wrong shape that still subtracts from bb without an error.
  • Applying softmax twice, or softmax and then a loss that expects logits. Framework losses such as cross-entropy on logits include the softmax internally.
  • Updating weights before finishing the backward pass. The recursion for layer ll needs the old Wl+1W^{l+1}. Compute all gradients first, then update.
  • Symmetric or badly scaled initialization. All-zero hidden weights never break symmetry. Very large weights saturate sigmoids or tanh units from the first step.
  • Overflow in exp or log. Subtract the maximum logit before exponentiating and never take the log of a probability that can be exactly zero.

The habit that catches nearly all of these: run a gradient check on a tiny batch, then confirm that the network can drive the loss almost to zero on a handful of examples. If it cannot overfit eight points, something is wrong with the gradients, the data pipeline, or the learning rate, and adding data or depth will not fix it.

Practice

  1. Redo the worked example by hand with a tanh hidden layer instead of a sigmoid. Before computing, predict whether δ1\delta^{1} will be larger or smaller, using the maximum derivative of each activation.
  2. Show that for a sigmoid output p=sigmoid⁡(z)p = \operatorname{sigmoid}(z) with binary cross-entropy −ylog⁡p−(1−y)log⁡(1−p)-y\log p - (1-y)\log(1-p), the gradient with respect to zz is p−yp - y.
  3. Introduce a deliberate bug into the NumPy MLP, such as removing the transpose or dividing by BB twice, and see how the gradient-check error changes. Which bugs does training alone fail to reveal?
  4. Replace He initialization with weights multiplied by 0.010.01 and then by 33 in a network with eight hidden layers. Print the standard deviation of each layer's activations at initialization and explain what you see using the variance argument.
  5. Train the MLP on XOR, generated as the four corners of the unit square, with a single hidden layer of two units. Run several seeds. How often does it fail, and what do the hidden units look like when it does?

Further reading

The method was popularized for neural networks by Rumelhart, Hinton and Williams (1986). The Deep Learning book by Goodfellow, Bengio and Courville treats computational graphs and backpropagation in its sixth chapter, and the initialization papers by Glorot and Bengio and He et al. are short and readable once the variance argument above is familiar.

Sources & further reading

The explanations and worked examples in this article are original. These papers and official references support the technical background and provide further detail.

  1. Glorot and Bengio (2010) ↗
  2. He et al. (2015) ↗
  3. Rumelhart, Hinton and Williams (1986) ↗
  4. Deep Learning book ↗
Back to all writing