Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Backpropagation Through an Affine–ReLU Layer

Welcome back. In the previous lesson, a Jacobian described how a small input displacement changes a transformation’s output. Now we will use that same derivative information in reverse: given a scalar loss, how does a change in a layer’s input, weights, or bias affect it?

You will derive the backward pass for an affine layer followed by a ReLU, then check the result against PyTorch autograd. This is a foundation for training the networks you will later apply to 3D points and other geometric data. Allow about 40 minutes, including the video, reading, and code check.


Why gradients travel backward

Consider a layer with input , weights , and bias :

Suppose later computations produce a scalar loss . We could try to write directly as one enormous expression in every parameter, but that becomes unwieldy as layers are added. Reverse-mode differentiation instead starts with the effect of on , then uses each operation’s local derivative to work back toward its inputs.

Stanford CS231N | Spring 2025 | Lecture 4: Neural Networks and Backpropagation

Watch the selected passages of Stanford Online’s “Neural Networks and Backpropagation” for the motivation behind ReLU and the distinction between an incoming gradient and a local derivative.

In the neural-network discussion, watch why ReLU matters: without an activation between affine layers, their combined mapping is still affine. Then, in the computational-graph example, watch backpropagation. Focus on how a gradient arriving from the final output is combined with a local derivative and passed to an earlier variable.

There are two kinds of quantities to keep distinct. The incoming gradient tells us how the final loss responds to the output of the operation we are examining. A local derivative tells us how that operation’s output responds to one of its inputs. Their chain-rule combination gives the gradient needed by the preceding operation.


Set the shapes and convention

Let , , and . We will write vectors, including gradients, as columns. Assume that the rest of the network has supplied

Our task is to obtain , , and . The backward pass must first cross ReLU and then the affine operation, because ReLU was applied last in the forward calculation.

The following reading develops the same two local backward rules. It uses row gradients in much of its linear-layer derivation, so pay particular attention to orientation: a transposed formula may express the same result under our column-gradient convention.

14 Backpropagation

Read the linear-layer and pointwise-nonlinearity derivations in “14 Backpropagation” from Foundations of Computer Vision. They connect the Jacobian viewpoint from the previous lesson to efficient backward calculations.

In Section 14.6, “Example: Backpropagation for an MLP,” start with subsection 14.6.1, “Backpropagation for a Linear Layer.” Read the affine derivation. Follow the individual weight derivative before looking at its outer-product form. Then read subsection 14.6.2, “Backpropagation for a Pointwise Nonlinearity,” from the pointwise rule. Notice why its Jacobian is diagonal. The reading chooses a backward value of one at a ReLU input of zero; below, we will use PyTorch’s choice of zero.


Derive the backward pass

ReLU acts independently on each component:

For , its local derivative is one; for , it is zero. At , ReLU is not differentiable. We will choose a backward value of zero there, matching PyTorch. Define the componentwise mask . The gradient arriving at the input of ReLU is therefore

where means componentwise multiplication. A negative pre-activation blocks that component’s incoming gradient, even if the incoming gradient itself is large.

Now examine the affine operation component by component:

A weight affects at a rate of , so the chain rule gives

Collecting these entries produces an outer product. The bias changes at a rate of one. An input , meanwhile, contributes to every , so its gradient sums contributions across output components:

The shapes check the calculation: is , has entries, and the bias gradient has entries.

An affine layer’s forward calculation and its gradients with respect to input, weights, and bias. The diagram writes incoming gradients as rows; the equations above use column gradients, so the corresponding matrix products appear transposed.

This also connects directly to the previous lesson. Where ReLU is differentiable, its Jacobian is the diagonal matrix . The Jacobian of the combined map from to is . Reverse-mode differentiation applies its transpose to , yielding . In practice, we do not build the diagonal matrix: multiplying by its entries as a mask is simpler.

For a quick numerical check, take ,

The forward pass gives , so . Thus

The second ReLU output contributes no gradient to this layer’s weights or bias for this input. The first output still sends a gradient to both input coordinates because it depends on both.


Check a batch against autograd

PyTorch typically stores one input per row of a batch matrix . If retains shape , the forward affine calculation is X @ W.T + b. The bias is shared across rows, so its gradient is the sum of their contributions. Writing for the ReLU-masked incoming gradients, the batched results are

This is the same single-input rule applied to each row, with shared-parameter gradients added together. If the loss were a mean over the batch rather than a sum, its incoming gradients—and consequently these parameter gradients—would include the appropriate averaging factor.

Run the following as one block. The simple scalar supplies known incoming gradients; it is a gradient check, not a training objective.

import torch

dtype = torch.float64
X = torch.tensor(
    [[1.0, -1.0, 2.0],
     [-1.0, 0.0, 0.0]],
    dtype=dtype, requires_grad=True,
)
W = torch.tensor(
    [[1.0, -1.0, 0.5],
     [-2.0, 0.0, 1.0]],
    dtype=dtype, requires_grad=True,
)
b = torch.tensor([0.1, -0.2], dtype=dtype, requires_grad=True)

# An explicitly chosen incoming gradient for each ReLU output.
U = torch.tensor(
    [[2.0, -3.0],
     [4.0, 5.0]],
    dtype=dtype,
)

Z = X @ W.T + b
A = torch.relu(Z)
J = (A * U).sum()
J.backward()

# Derive the same gradients without using autograd on these expressions.
G = U * (Z.detach() > 0).to(dtype)
manual_dX = G @ W.detach()
manual_dW = G.T @ X.detach()
manual_db = G.sum(dim=0)

torch.testing.assert_close(X.grad, manual_dX)
torch.testing.assert_close(W.grad, manual_dW)
torch.testing.assert_close(b.grad, manual_db)

print("pre-activations:\n", Z.detach())
print("masked gradients:\n", G)
print("weight gradient:\n", manual_dW)
print("bias gradient:", manual_db)
print("input gradient:\n", manual_dX)

The two batch rows activate different ReLU components. You should see a weight gradient of [[2, -2, 4], [-5, 0, 0]] and a bias gradient of [2, 5]. Notice that the code needs saved forward values to determine the ReLU mask and to form the weight gradient. None of the pre-activations here equals zero, so the gradient check does not depend on the chosen convention at that kink.


Takeaways

Reverse-mode differentiation combines an incoming loss gradient with local derivatives, working from ReLU back through the affine layer. ReLU masks the gradient componentwise; the affine layer sends to its input and produces an outer-product weight gradient and a bias gradient. For batches, shared-parameter contributions are summed.

You can now explain the gradients that autograd computes rather than treating .backward() as a black box. Next, you will use parameter gradients to implement gradient-descent and Adam updates, including how to recognize an unstable learning rate.

Can't find a good explanation? Sign up and we'll make it for you

Sign up