Skip to content
Glacius

Learn the math behind machine learning.

Machine learning

What backpropagation does before gradient descent takes a step

Work through backpropagation and a gradient descent update in matrix notation, with predictions, numerical gradients, dimension checks, and NumPy code.

In this article
  1. Start with a prediction and a loss
  2. Work backward to find the gradients
  3. Use the gradients to update the parameters
  4. How this extends to a multilayer network
  5. Changing the learning rate does not change this starting gradient
  6. Reproduce the whole step in Python
  7. What changes when you use a different optimizer?
A batch with two examples, two features, and two outputs. Starting weights are the 2 by 2 identity and both biases are zero; loss is 1. Backpropagation computes a 2 by 2 weight gradient with every entry 1.5 and a bias gradient of (1, 1), without changing parameters. Gradient descent with learning rate 0.1 gives weights [[0.85, −0.15], [−0.15, 0.85]] and biases (−0.1, −0.1); new loss is 0.2025.A batch with two examples, two features, and two outputs. Starting weights are the 2 by 2 identity and both biases are zero; loss is 1. Backpropagation computes a 2 by 2 weight gradient with every entry 1.5 and a bias gradient of (1, 1), without changing parameters. Gradient descent with learning rate 0.1 gives weights [[0.85, −0.15], [−0.15, 0.85]] and biases (−0.1, −0.1); new loss is 0.2025.
Backpropagation calculates a gradient for each parameter array

Two examples and two outputs: X = [[1, 2], [2, 1]], Y = [[0, 1], [1, 0]]. Predict with Ŷ = XW + 1bᵀ and divide the sum of squared residuals by 2m, with m = 2. Start at W = I and b = 0. Backpropagation leaves those parameters unchanged; gradient descent updates them using η = 0.1.

Original matrix worked example; values calculated from the batch and loss shown.

A typical PyTorch training loop calls loss.backward() and then optimizer.step(). You can stop between those calls and inspect the difference: the gradients have been calculated, while the parameters still have their old values.

Backpropagation computes the gradient of the loss with respect to each trainable parameter. Plain gradient descent uses those gradients to update the parameters. Other optimizers use different update rules, but occupy the same place in the PyTorch training loop, after the backward pass.

We will follow one batch through an affine layer using matrices throughout. Two examples, two input features, and two outputs keep the arithmetic small while showing how a batch produces a gradient for an entire weight matrix. The worked layer has no nonlinear activation; later, we connect its backward calculation to a multilayer network.

Start with a prediction and a loss

Put examples in the rows of an input matrix XX. For a batch of mm examples with dd input features and kk outputs, the prediction is

Y^=XW+1mb⊤.\hat Y = XW + \mathbf{1}_m b^\top.
SymbolShapeMeaning
XXm×dm \times dOne input example per row
WWd×kd \times kTrainable weights
bbk×1k \times 1One trainable bias per output
1m\mathbf{1}_mm×1m \times 1A column of ones that repeats the bias across rows
Y^,Y\hat Y, Ym×km \times kPredictions and targets

Every entry of WW is a separate parameter. For example, W12W_{12} connects input feature 1 to output 2. All rows in the batch use the same WW and bb.

Here are our inputs and targets:

X=[1221],Y=[0110].\begin{gathered} X = \begin{bmatrix}1 & 2\\2 & 1\end{bmatrix}, \\[6pt] Y = \begin{bmatrix}0 & 1\\1 & 0\end{bmatrix}. \end{gathered}

Start with an identity weight matrix and zero biases:

W=[1001],b=[00].\begin{gathered} W = \begin{bmatrix}1 & 0\\0 & 1\end{bmatrix}, \\[6pt] b = \begin{bmatrix}0\\0\end{bmatrix}. \end{gathered}

Multiplication by the identity leaves XX unchanged, so the predictions and residuals are

Y^=[1221],R=Y^−Y=[1111].\begin{gathered} \hat Y = \begin{bmatrix}1 & 2\\2 & 1\end{bmatrix}, \\[6pt] R = \hat Y-Y = \begin{bmatrix}1 & 1\\1 & 1\end{bmatrix}. \end{gathered}

Use half the average squared error per example, summing over that example's outputs:

L=12m∥R∥F2=12m∑i=1m∑j=1kRij2.L = \frac{1}{2m}\|R\|_F^2 = \frac{1}{2m}\sum_{i=1}^{m}\sum_{j=1}^{k}R_{ij}^2.

The squared Frobenius norm, ∥R∥F2\|R\|_F^2, means square every entry and add. Here m=2m=2 and all four residuals equal one, giving L=4/4=1L=4/4=1. The loss is a scalar even though its inputs and parameters are matrices. This normalization averages over examples, not over all matrix entries; the code below uses exactly this definition.

Work backward to find the gradients

Start at the loss. Its gradient with respect to the prediction matrix has one entry for every prediction:

G=∇Y^L=Rm=[0.50.50.50.5].G = \nabla_{\hat Y}L = \frac{R}{m} = \begin{bmatrix}0.5 & 0.5\\0.5 & 0.5\end{bmatrix}.

Here Gij=∂L/∂Y^ijG_{ij}=\partial L/\partial\hat Y_{ij} is a partial derivative. It records how sensitive the loss is to prediction jj for example ii, holding the other prediction entries fixed.

For a scalar-valued loss, a gradient with respect to a matrix collects those partial derivatives in an array with the matrix's shape. Flattening that array gives the gradient vector for the flattened inputs. A Jacobian uses a different layout: one row per output and one column per input. The loss has just one output, so its Jacobian with respect to the flattened predictions would have one row; GG keeps the derivatives in the prediction matrix's original layout.

The backward pass through the affine layer gives

∇WL=X⊤G,∇bL=G⊤1m.\begin{gathered} \nabla_W L = X^\top G, \\[6pt] \nabla_b L = G^\top\mathbf{1}_m. \end{gathered}

The matrix multiplication collects each example's contribution to every weight. In component form, (∇WL)aj=∑iXiaGij(\nabla_W L)_{aj}=\sum_i X_{ia}G_{ij}. The bias derivative sums the incoming gradients over examples because the same bias was added to every row. Each gradient has the same shape as its parameter: d×kd\times k for WW, and k×1k\times1 for bb.

For our batch,

∇WL=[1221][0.50.50.50.5]=[1.51.51.51.5].\begin{gathered} \nabla_W L = \begin{bmatrix}1 & 2\\2 & 1\end{bmatrix} \begin{bmatrix}0.5 & 0.5\\0.5 & 0.5\end{bmatrix} \\[6pt]= \begin{bmatrix}1.5 & 1.5\\1.5 & 1.5\end{bmatrix}. \end{gathered} ∇bL=[0.50.50.50.5][11]=[11].\begin{gathered} \nabla_b L = \begin{bmatrix}0.5 & 0.5\\0.5 & 0.5\end{bmatrix} \begin{bmatrix}1\\1\end{bmatrix} \\[6pt]= \begin{bmatrix}1\\1\end{bmatrix}. \end{gathered}

For example, the derivative for W12W_{12} is 1×0.5+2×0.5=1.51\times0.5+2\times0.5=1.5. Both examples contribute to that one parameter. The equal entries in these gradients come from the symmetric numbers chosen for this example; gradients in a trained network generally differ from entry to entry.

We have calculated six derivatives: four for the weights and two for the biases. The parameter matrices have not changed. Backpropagation has evaluated the chain rule through the graph. It has not chosen a learning rate or taken an optimization step. Our chain rule lesson explains the scalar case; the multivariable chain rule lesson shows how derivative matrices compose through intermediate values.

Use the gradients to update the parameters

The gradient descent update subtracts a scaled gradient from each parameter array. With learning rate η\eta,

Wnew=W−η∇WL,bnew=b−η∇bL.\begin{gathered} W_{\text{new}} = W-\eta\nabla_W L, \\[6pt] b_{\text{new}} = b-\eta\nabla_b L. \end{gathered}

Using η=0.1\eta=0.1 gives

Wnew=[0.85−0.15−0.150.85],bnew=[−0.1−0.1].\begin{gathered} W_{\text{new}}= \begin{bmatrix}0.85 & -0.15\\-0.15 & 0.85\end{bmatrix}, \\[6pt] b_{\text{new}}= \begin{bmatrix}-0.1\\-0.1\end{bmatrix}. \end{gathered}

Every derivative came from the same starting state. We calculate both gradients before changing either parameter array.

A new forward pass gives

Y^new=XWnew+1mbnew⊤=[0.451.451.450.45].\begin{gathered} \hat Y_{\text{new}} = XW_{\text{new}}+\mathbf{1}_m b_{\text{new}}^\top \\[6pt]= \begin{bmatrix}0.45 & 1.45\\1.45 & 0.45\end{bmatrix}. \end{gathered}

Each prediction is now 0.450.45 above its target. The new loss is 4×0.452/4=0.20254\times0.45^2/4=0.2025, down from 11.

Point in the calculationParameters changed?Gradients available?Loss at these parameters
After the forward passNoNot yet1
After backpropagationNoYes1
After gradient descent and a new forward passYesGradients from the old state0.2025

The second row is the distinction you can inspect between loss.backward() and optimizer.step(). The backward pass adds derivative information; the optimization step changes the model.

For a classification loss, the starting derivative changes. Our cross-entropy derivative example derives the softmax logit gradient p−yp-y and checks it numerically. The same chain rule then carries that gradient through the network.

How this extends to a multilayer network

An affine layer inside a larger network still computes Z=XW+1mb⊤Z=XW+\mathbf{1}_m b^\top. Its incoming gradient G=∇ZLG=\nabla_Z L now comes from the layers after it. Once that incoming gradient is known, the local backward rules remain

∇WL=X⊤G,∇bL=G⊤1m,∇XL=GW⊤.\begin{gathered} \nabla_W L=X^\top G, \\[6pt] \nabla_b L=G^\top\mathbf{1}_m, \\[6pt] \nabla_X L=GW^\top. \end{gathered}

The last expression passes a gradient to the preceding layer when XX is itself a learned intermediate activation. Our vector–Jacobian product lesson explains how an incoming output sensitivity travels backward through a vector operation. These matrix formulas perform that same propagation while preserving the batch and feature axes.

If the affine layer is followed by an elementwise activation A=ϕ(Z)A=\phi(Z), the backward pass through that activation first computes G=(∇AL)⊙ϕ′(Z)G=(\nabla_A L)\odot\phi'(Z), wherever the activation is differentiable. The symbol ⊙\odot means entry-by-entry multiplication. Branches add contributions when multiple paths lead back to the same value. This is how the autograd computation graph organizes the chain rule across a network.

You may also see all weights and biases collected into one parameter vector θ\theta. In that notation, the same plain gradient descent update is θt+1=θt−η∇θL(θt)\theta_{t+1}=\theta_t-\eta\nabla_\theta L(\theta_t). Keeping separate matrices simply preserves the shapes of the layers.

Changing the learning rate does not change this starting gradient

Return to the original WW and bb, keep the same batch and loss, and choose η=0.5\eta=0.5. The starting gradients are unchanged. The larger update gives

Wnew=[0.25−0.75−0.750.25],bnew=[−0.5−0.5].\begin{gathered} W_{\text{new}}= \begin{bmatrix}0.25 & -0.75\\-0.75 & 0.25\end{bmatrix}, \\[6pt] b_{\text{new}}= \begin{bmatrix}-0.5\\-0.5\end{bmatrix}. \end{gathered}

Now every residual is −1.75-1.75, and the loss rises to 3.06253.0625. Even a direction that reduces the loss locally can make it worse if the step is too large.

For these starting matrices, each residual after one update is 1−5.5η1-5.5\eta. The resulting loss is

Lafter(η)=(1−5.5η)2.L_{\text{after}}(\eta)=(1-5.5\eta)^2.
Batch loss after one simultaneous matrix gradient descent update, plotted against learning rate. Starting weight gradients are all 1.5 and bias gradients are both 1. Learning rate 0.1 gives loss 0.2025, 2/11 gives zero loss on this batch, and 0.5 gives loss 3.0625. The dashed horizontal line marks the initial loss of 1.Batch loss after one simultaneous matrix gradient descent update, plotted against learning rate. Starting weight gradients are all 1.5 and bias gradients are both 1. Learning rate 0.1 gives loss 0.2025, 2/11 gives zero loss on this batch, and 0.5 gives loss 3.0625. The dashed horizontal line marks the initial loss of 1.
The same matrix gradients can lead to very different losses

Every point starts from W = I and b = 0 on the same two-example batch and takes one matrix update. Only η changes. The minimum fits this illustrative batch exactly; it is not a recommended learning rate for other models.

Calculated from batch loss after one update = (1 − 5.5η)².

This plot varies one scalar, the learning rate, while the underlying update still changes all six parameters. The rate η=2/11≈0.1818\eta=2/11\approx0.1818 happens to fit this particular batch exactly. That is a consequence of our chosen numbers, not a recommended learning rate for a neural network. Our gradient descent oscillation article examines how step size affects repeated updates.

Reproduce the whole step in Python

The NumPy code below uses @ for matrix multiplication. b is stored as a column; adding b.T broadcasts the bias row across the batch. Both gradients are calculated before either parameter is updated.

import numpy as np

X = np.array([[1., 2.], [2., 1.]])
Y = np.array([[0., 1.], [1., 0.]])
W = np.eye(2)
b = np.zeros((2, 1))
m = X.shape[0]
eta = 0.1

# Forward pass: half the mean per-example squared error.
prediction = X @ W + b.T
residual = prediction - Y
loss = np.sum(residual ** 2) / (2 * m)

# Backward pass: each gradient matches its parameter's shape.
G = residual / m
grad_W = X.T @ G
grad_b = G.T @ np.ones((m, 1))

# Plain gradient descent uses gradients from the same state.
W_new = W - eta * grad_W
b_new = b - eta * grad_b
new_prediction = X @ W_new + b_new.T
new_loss = np.sum((new_prediction - Y) ** 2) / (2 * m)

print("weight gradient:\n", grad_W)
print("bias gradient:\n", grad_b)
print("new weights:\n", W_new)
print("new biases:\n", b_new)
print(f"loss: {loss:.4f} -> {new_loss:.4f}")

It prints the gradient and parameter matrices derived above, and loss: 1.0000 -> 0.2025. This code implements the local backward rules explicitly. In PyTorch, loss.backward() performs the differentiation and accumulates the results in parameter .grad attributes. A usual loop clears old gradients before the next backward pass unless it deliberately accumulates them across batches. The autograd tutorial explains that behavior.

What changes when you use a different optimizer?

The gradient calculation can stay the same when the update rule changes. SGD with momentum or Adam uses gradients together with optimizer state to determine an update. Its parameter changes therefore differ from the plain subtraction used in this article.

Gradient clipping adds another possible operation between the backward pass and the optimizer step. It limits the calculated gradients; the optimizer still determines how those gradients change the parameters.

When debugging, inspect the loss and computation graph if derivatives are wrong. If the derivatives are right but the loss grows after an update, examine the optimizer and step size. In our matrix example, changing only the learning rate was enough to turn an improving step into a harmful one.

Learn with Glacius

Want to go deeper into the math?

Build your understanding with visual lessons and practice on the concepts from this article.

Check the reasoning

Sources & notes

  1. PyTorch: Automatic Differentiation with torch.autograd ↗

    Documents backward(), parameter gradients, and gradient accumulation.

  2. PyTorch: Optimizing Model Parameters ↗

    Separates the backward pass from the optimizer update and explains the training loop.

Our figures use illustrative mathematical examples unless a dataset is explicitly identified. You can share the original Glacius figures with attribution and a link to this article; linked third-party material retains its own terms.

Keep exploring

Lessons behind this article