PyTorch records a graph as each operation runs, exactly as the engine of DL01.6 did, and backward() fills the .grad of every leaf. Reproduce the hand gradients of the running 2-2-2 network, watch gradients accumulate, cut the graph with detach and no_grad, and check a fused loss with gradcheck.
DL01.7.2L2 Intermediate25 minIn review (what this means)
The maths and code on this page are checked by automated tests on every change, but an independent reader has not signed it off yet. Spot a mistake or a confusing step? Tell us.
Explain what requires_grad, grad_fn and next_functions record, and which tensors are leaves.
Run backward() on a scalar loss and read the gradients from the .grad of the leaf tensors, matching them to the hand gradients of the 2-2-2 network.
Predict how .grad changes when backward runs twice, and use torch.autograd.grad to get a gradient without touching .grad.
Stop recording with detach() and torch.no_grad(), and say when each is needed.
Check a hand-written backward with torch.autograd.gradcheck in float64.
The question
In DL01.5 you worked every gradient of a small network by hand, and in DL01.6 you built an engine that found them by running a recorded graph backwards. PyTorch claims to do the same in one call, loss.backward(). Does it give exactly your numbers, where does it put them, and what does it remember between calls?
From the last lesson
1. A contiguous tensor has shape \((2, 3, 4)\). What are its strides?
Show answer
\((12, 4, 1)\): the last axis has stride 1, and each earlier stride is the next one times the next length.
2. Which of torch.from_numpy(a) and torch.tensor(a) shares memory with the array a?
Every tensor made by an operation remembers which operation made it. That memory is its grad_fn: a node of a graph that knows the backward rule of the operation and points, through next_functions, at the nodes of its inputs. The tensors you create yourself and mark with requires_grad=True are the leaves of the graph. They have no grad_fn; instead a node called AccumulateGrad waits at each of them to receive its gradient.
DL01.7.2-F1
What PyTorch records for the running network. The forward pass (vermillion) builds the graph as it runs; L.backward() walks it the other way (green) and deposits a gradient in the .grad of each leaf that asked for one.
This is the engine of DL01.6 again. There, each operation stored its parents and a backward closure, and backward() sorted the graph and called each closure once, adding into grad. In PyTorch the closure is the grad_fn node, the parents are next_functions, and the adding happens in AccumulateGrad. The graph is built afresh by each forward pass, so whatever Python runs, with any loop or if, is what gets differentiated.
Build it up
Step 1. Ask for gradients.W = torch.tensor(..., requires_grad=True) makes a leaf that will receive a gradient. An operation is recorded only if at least one of its inputs requires a gradient; the input x of a network usually does not.
Step 2. Run forward. Each result gets a grad_fn, such as MvBackward0 for a matrix times a vector or ReluBackward0. Nothing is differentiated yet. The nodes keep the tensors their backward rule will need, such as the input of the ReLU for its mask.
Step 3. Run backward.L.backward() seeds the loss with 1 and walks the graph from the root to the leaves, applying each node’s rule. Each leaf’s gradient is added into its .grad, a tensor of the leaf’s own shape. Intermediate results keep no .grad, unless you call retain_grad() on them first. The saved tensors are then freed, so a second backward() through the same graph fails unless the first was called with retain_graph=True.
DL01.7.2-F2
Three moments in the life of a gradient. Creating a leaf records nothing; the forward pass builds the graph; backward() fills the leaf’s .grad and frees what the graph had saved.
Step 4. Gradients accumulate.backward() adds into .grad; it never overwrites it. That is what a weight used twice needs, as DL01.5.3 showed: the gradients from both uses must add. It also means that between training steps someone must clear .grad, which is the optimizer’s zero_grad() in DL01.7.4.
Step 5. Leave the graph.t.detach() returns a tensor with the same numbers, sharing memory, that is cut off from the graph. Inside with torch.no_grad(): nothing is recorded at all, which is how weights are updated and how a network is evaluated without paying for a graph.
The formal statement
For a scalar \(L\) computed from leaves \(\theta_1, \theta_2, \dots\), one call L.backward() performs, for every leaf with requires_grad=True,
where a missing .grad counts as zero and \(\pd{L}{\theta}\) has the shape of \(\theta\). Each node computes a vector-Jacobian product: given the gradient \(\vect{g}\) of \(L\) with respect to its output \(\vect{u} = f(\vect{v})\), it returns \(\vect{g}\T \mat{J}\), where \(\mat{J}\) is the Jacobian of \(f\) (M05.4), the gradient with respect to its input \(\vect{v}\). Like the engine of DL01.6.1, it never forms \(\mat{J}\). For a tensor output \(\vect{u}\) that is not a scalar, u.backward(g) starts the same walk with the given \(\vect{g}\).
torch.autograd.grad(L, [theta]) runs the same walk and returns the gradients as a tuple instead of adding them to .grad.
DL01.7.2-F3
Two ways out of the graph. detach() cuts one tensor loose and keeps its memory; torch.no_grad() stops recording for a whole block.
The running network, by hand. The 2-2-2 network of DL01.5.1 has one input \(\vect{x}\) in \(\R^2\), ReLU hidden units, a softmax output and the cross-entropy loss with true class \(k\):
Here \(\vect{p}\) is the softmax of \(\vect{z}\), \(\vect{y}\) the one-hot target, \(\vect{m}\) the ReLU mask (\(m_i = 1\) where \(z^{(1)}_i > 0\) and 0 elsewhere) and \(\odot\) the elementwise product. At \(z^{(1)}_i = 0\) exactly, PyTorch’s rule for a function that is not differentiable there picks the smallest subgradient, so the ReLU passes back 0.
Worked example
These are the numbers fixed for the running network in DL01.5.1. The input is \(\vect{x} = (1, 2)\), the true class is the second one (\(k = 2\) in maths, label 1 in code), and
Forward.\(z^{(1)}_1 = 1 - 1 + 0.5 = 0.5\) and \(z^{(1)}_2 = -0.5 + 2 - 0.5 = 1\), so \(\vect{a}^{(1)} = (0.5, 1)\): both hidden units are on. Then \(z_1 = 0.5 + 1 - 0.5 = 1\) and \(z_2 = -0.5 + 0.5 + 0 = 0\). Dividing top and bottom by \(e^1\),
Backward.\(\vect{\delta}^{(2)} = {(0.7311, -0.7311)}\). The output weights get \(\vect{\delta}^{(2)} \vect{a}^{(1)\top}\), each row \(\delta^{(2)}_i\) times \({(0.5, 1)}\):
Every number of the running network, forward on top and backward below, the same as in DL01.5.1. Both hidden units are on, so the ReLU mask lets the whole gradient through to the first layer.
These are the twelve gradients of DL01.5.1, here to four decimals. Now PyTorch must find the same numbers.
Code
The network in float64, so that the comparison is exact to rounding. Only the weights and biases ask for gradients.
Every gradient matches the hand to \(10^{-12}\). DL01.6.4 checks that your engine reproduces the same hand gradients, so PyTorch and the engine agree with each other too.
Accumulation, a freed graph, and torch.autograd.grad, which leaves .grad alone.
def loss_of(W1, b1, W2, b2): z = W2 @ torch.relu(W1 @ x + b1) + b2return F.cross_entropy(z, torch.tensor(label, device=device))first = b2.grad.clone()loss_of(W1, b1, W2, b2).backward() # a second backward, no zeroingprint("b2.grad after two backward calls:", np.round(b2.grad.numpy(), 4).tolist())L2 = loss_of(W1, b1, W2, b2)L2.backward()try: L2.backward() # the saved tensors are goneexceptRuntimeErroras error:print("second backward on one graph:", str(error)[:40])(g_W2,) = torch.autograd.grad(loss_of(W1, b1, W2, b2), [W2])print("autograd.grad:", np.round(g_W2.numpy(), 4).tolist(), " b2.grad unchanged:", torch.equal(b2.grad, 3* first))
b2.grad after two backward calls: [1.4621, -1.4621]
second backward on one graph: Trying to backward through the graph a s
autograd.grad: [[0.3655, 0.7311], [-0.3655, -0.7311]] b2.grad unchanged: True
detach() and torch.no_grad(): the same numbers, no graph.
False None | False True
z.numpy(): Can't call numpy() on Tensor that requires
Last, a check for code you write yourself. DL01.6.2 fused the softmax and the cross-entropy into one operation with the backward rule \({(\mat{P} - \mat{Y})/n}\) for a batch of \(n\). Written as a PyTorch Function, its backward is ours, not autograd’s, so gradcheck compares it with central differences. It needs float64: in float32 the differences themselves are too noisy.
The correct rule passes and the one that forgot the \(1/n\) of the mean fails: its gradients are eight times too large.
Explore
This module’s interactive, Shapes and strides, lives in DL01.7.1. For autograd, the best explorer is the code above. In the running network \({\mat{W}^{(1)}\vect{x}} = {(0, 1.5)}\), so \(z^{(1)}_1\) equals the bias \(b^{(1)}_1\). Set that bias to 0, which puts the first hidden unit exactly on the kink of its ReLU, and predict from the rule for non-differentiable points which gradients become zero before you run it.
Pitfalls
DL01.7.2-F5
Wrong beside right. backward() adds into .grad, so three calls without clearing hold three times the gradient. Clearing .grad before each call keeps the true gradient.
“backward() gives me this step’s gradient.”
It gives this step’s gradient added to whatever was already in .grad. In a training loop the previous step’s gradient is still there, so the update uses the sum, as in the figure. Clear .grad before every backward pass: optimizer.zero_grad() does it for every parameter.
“Every tensor in the network has a .grad after backward.”
Only leaves with requires_grad=True do. z.grad of an intermediate result is None (PyTorch warns when you read it), because keeping every intermediate gradient would double the memory. Call z.retain_grad() before backward when you need one, or use torch.autograd.grad(L, [z]).
“I can update the weights with W -= lr * W.grad.”
Not on a leaf that requires a gradient: PyTorch refuses an in-place change to it while it is being recorded, and even if it allowed it, the update itself would become part of the graph. Make the update inside with torch.no_grad():, which is what the optimizer’s step() does.
Check yourself
Recall
1. What is a leaf tensor, and which leaves receive a .grad?
Show answer
A tensor not made by a recorded operation (it has no grad_fn), usually one you created. Only leaves with requires_grad=True receive a .grad.
2. What do grad_fn and next_functions hold?
Show answer
grad_fn is the graph node of the operation that made a tensor, with its backward rule; next_functions points to the nodes of that operation’s inputs, so the graph can be walked back.
3..grad of a weight holds \(\vect{g}\). You run the same forward and backward() once more without clearing. What does it hold?
Show answer
\(2\vect{g}\): backward adds into .grad.
4. What does torch.no_grad() change, and when do you use it?
Show answer
Inside it no operation is recorded and results have requires_grad=False. It is used for weight updates and for evaluating a network.
5. Why must gradcheck be run in float64?
Show answer
It compares the backward rule with finite differences over a step of \(10^{-6}\); in float32, with about 7 digits, those differences are mostly rounding error, and the check fails.
Exercises
1.★ With \(\vect{w} = (2, -1)\) as a leaf, \(\vect{x} = (3, 4)\) and \(L = (\vect{w} \cdot \vect{x})^2\), find w.grad after one L.backward(), and after a second forward and backward without clearing.
Show solution
\(\vect{w} \cdot \vect{x} = 6 - 4 = 2\) and \({\partial L / \partial \vect{w}} = {2(\vect{w} \cdot \vect{x})\,\vect{x}}
= {4\,(3, 4)} = (12, 16)\). After a second call without clearing, w.grad is \((24, 32)\).
2.★★ In the running network, change \(b^{(1)}_1\) from \(0.5\) to \(-1\). Find the new \(\vect{z}^{(1)}\), which hidden units are on, and both rows of \(\partial L / \partial \mat{W}^{(1)}\).
Show solution
\(z^{(1)}_1 = 1 - 1 - 1 = -1\) and \(z^{(1)}_2 = 1\), so the first unit is off and \(\vect{a}^{(1)} = (0, 1)\). Then \(z_1 = 0 + 1 - 0.5 = 0.5\) and \(z_2 = 0 + 0.5 + 0 = 0.5\), so \(\vect{p} = (0.5, 0.5)\), \(L = \ln 2\) and \(\vect{\delta}^{(2)} = {(0.5, -0.5)}\). Then
and the mask \((0, 1)\) gives \(\vect{\delta}^{(1)} = (0, 0.25)\). So the first row of \(\vect{\delta}^{(1)} \vect{x}\T\) is \({(0, 0)}\) and the second is \({0.25\,(1, 2)} = {(0.25, 0.5)}\). A unit that is off passes back nothing: its weights get no gradient from this example.
3.★★★ A colleague’s custom Function for \(f(u) = u^3\) returns 3 * u * grad_out from its backward (they forgot one factor \(u\)). Say what gradcheck compares at \(u = 2\), the two numbers it would see, and why the bug could go unnoticed at \(u = 1\).
Show solution
With grad_out\(= 1\), gradcheck compares the backward’s \({3u} = 6\) with the central difference
so it fails. At \(u = 1\) the wrong rule gives \(3 \cdot 1 = 3\) and the true derivative is \(3 \cdot 1^2 = 3\): they agree, which is why checks use random inputs, not ones and zeros.
Summary card
The forward pass records a graph; backward() walks it and adds into the leaves’ .grad.
DL01.7.2-F1
The graph PyTorch records.
Rule.L.backward() does \(\theta.\mathrm{grad} \mathrel{+}= \partial L / \partial \theta\) for each leaf.
Hand check.\(\vect{\delta}^{(2)} = \vect{p} - \vect{y}\), \(\partial L / \partial \mat{W}^{(2)} = \vect{\delta}^{(2)} \vect{a}^{(1)\top}\).
No .grad touched.torch.autograd.grad(L, [theta]).
Out of the graph.t.detach() for one tensor, with torch.no_grad(): for a block.
Own backward.torch.autograd.gradcheck in float64.
Sources and next steps
Sources. Each was opened and checked against this lesson on 15 November 2026.
PyTorch 2.15 documentation, Autograd mechanics: the graph is recorded as operations run, with inputs as leaves and outputs as roots, and each tensor’s grad_fn as its entry point; the graph is rebuilt on every iteration; an operation is recorded only if one of its inputs requires grad; computations in no-grad mode behave as if no input requires grad; gradients are accumulated into the .grad of leaf tensors; and the rules for non-differentiable points, where a locally convex function such as ReLU at 0 takes the minimum-norm subgradient.
PyTorch 2.15 documentation, torch.autograd.gradcheck: it checks analytical gradients against small finite differences, with defaults eps \(10^{-6}\), atol \(10^{-5}\) and rtol \(10^{-3}\) designed for double precision, and it will likely fail in lower precision.
A. Zhang, Z. C. Lipton, M. Li and A. J. Smola, Dive into Deep Learning, section 2.5: requires_grad_, backward and .grad (2.5.1), the gradient buffer not being reset automatically, backward on non-scalars (2.5.2), and detach (2.5.3).
Next.DL01.7.3 nn.Module and layers stops making leaves by hand: a module creates its parameters, registers them, and hands them to autograd.