Exercises — Autograd and computational graphs in PyTorch
This is a self-testing page. Every problem has a hidden solution — try it first, then reveal. Problems climb from recognition (can you read the graph?) up to mastery (can you design your own autograd behaviour?). If any symbol is unfamiliar, return to the parent note or the prerequisites below.
Prerequisites you may want open: 3.301-Introduction-to-PyTorch-tensors-and-operations · 3.2.04-Backpropagation-algorithm · 3.5.02-Gradient-descent-variants
Level 1 — Recognition
Goal: read a computational graph and state what each piece means. No calculus yet.
Exercise 1.1 — Name the parts
Given this code, state (a) which tensors are leaves that will receive a .grad, (b) what
z.grad_fn will be, and (c) whether x itself has a grad_fn.
x = torch.tensor([4.0], requires_grad=True)
y = torch.tensor([5.0], requires_grad=True)
z = x + yRecall Solution 1.1
The figure below draws the graph, but here is the same thing in words so you can follow even
without the picture: there are three boxes. Two boxes on the left — x and y — are the
inputs you typed in; these are the nodes that hold data. One box on the right — z — is the
result of the + operation. The two arrows (edges) run from x and from y into z;
each arrow means "this data flows into that operation." Data flows left-to-right in the
forward pass, and gradients will later flow right-to-left.

(a) Leaves with a .grad: ==x and y==. A leaf is a tensor you created directly
(not the output of an operation) that has requires_grad=True. In the picture, leaves are the two
boxes with no arrow pointing into them. Both x and y were typed in by hand, so both are
leaves and both will hold a gradient after .backward().
(b) z.grad_fn: z is the result of an addition — the box that has arrows pointing
into it — so PyTorch attaches grad_fn=<AddBackward0>. This little object is the "recipe card"
that remembers how z was made, so it can later be undone.
(c) Does x have a grad_fn? ==No — it is None==. x was not computed from anything; it
is a raw input (no arrow enters its box). Only tensors that come out of an operation carry a
grad_fn. Leaves do not.
Exercise 1.2 — Will it track?
For each tensor, state whether operations on it are recorded for autograd:
a = torch.tensor([1.0]) # (i)
b = torch.tensor([1.0], requires_grad=True) # (ii)
c = b.detach() # (iii)
d = b * 2 # (iv)Recall Solution 1.2
- (i)
a→ Not tracked. Defaultrequires_grad=False. - (ii)
b→ Tracked. You explicitly asked for it. - (iii)
c→ Not tracked..detach()returns a copy that shares the data but is cut off from the graph — itsrequires_gradisFalse. Think of it as a photocopy of the number with no memory of where it came from. - (iv)
d→ Tracked. If any input to an operation requires grad, the output requires grad. Sincebrequires grad,ddoes too, withgrad_fn=<MulBackward0>.
Level 2 — Application
Goal: actually turn the crank. Compute numeric gradients by hand and match PyTorch.
Exercise 2.1 — A scalar with two paths
Let and . Compute by hand, then give the number PyTorch
prints for x.grad.
Recall Solution 2.1
What we do: differentiate term by term.
Why for ? The derivative measures "output change per tiny input nudge." Let be that tiny nudge — a small number we imagine adding to and then shrink toward zero (it is the "hair" we push by). Nudging to changes into , a change of . Because is tiny, is tinier still and negligible, so the change is about — meaning the rate (change per unit of ) is . Likewise, nudging changes by exactly , rate .
Plug in : .
PyTorch prints tensor([11.]).
Exercise 2.2 — The shared node (multivariate chain rule)
Let , , and . This is the parent note's running example. Compute
x.grad and y.grad.
Recall Solution 2.2
Here x feeds two operations at once — it goes into xy and into x^2. In words, the graph
has five boxes: input x, input y, an operation box x * y, an operation box x ** 2, and
the final z = sum. There are two arrows leaving x — one into x * y, one into x ** 2 —
and that fan-out is the whole point: when gradients flow back, each arrow carries its own share
and we add them. The figure shows this fan-out:

Gradient w.r.t. x — sum both branches (multivariate chain rule):
Gradient w.r.t. y — y only appears in xy (only one arrow leaves y):
So x.grad = tensor([7.]), y.grad = tensor([2.]).
Exercise 2.3 — ReLU at exactly zero
Let with , so . Then and . What is that PyTorch reports?
Recall Solution 2.3
The subtle bit: ReLU is . Its slope is for and for , but at exactly there is a corner — no single slope exists mathematically. PyTorch makes a convention: at it uses the left derivative, giving .
So the chain gives
x.grad = tensor([0.]).
Level 3 — Analysis
Goal: reason about accumulation, retained graphs, and detach — the things that surprise people.
Exercise 3.1 — Forgot to zero the grad
Predict x.grad after this whole block runs:
x = torch.tensor([2.0], requires_grad=True)
for _ in range(3):
y = x ** 2
y.backward()
print(x.grad) # ?Recall Solution 3.1
What .backward() does to .grad: it does not overwrite — it adds:
Each pass: . There is no .zero_(), so three passes add up:
x.grad = tensor([12.]). To get the "expected" 4, you must call x.grad.zero_() at the top of
each loop. This is exactly why training loops always contain optimizer.zero_grad().
Exercise 3.2 — Why the second backward errors
Explain the error, then give the two-line fix.
x = torch.tensor([3.0], requires_grad=True)
y = x ** 3
y.backward()
y.backward() # RuntimeError!Recall Solution 3.2
Why it errors: PyTorch builds the graph dynamically and, to save memory, frees the
intermediate buffers as soon as the first .backward() finishes. The second call finds the
graph already destroyed → RuntimeError: Trying to backward through the graph a second time.
Fix: ask PyTorch to keep the graph alive on the first call:
y.backward(retain_graph=True)
y.backward() # now worksNumeric check: per call. After two accumulated backwards
(no zeroing) x.grad = tensor([54.]).
Exercise 3.3 — What detach() blocks
Predict x.grad (or state if it errors):
x = torch.tensor([2.0], requires_grad=True)
a = x ** 2 # a = 4
b = a.detach() # cut here
c = b * x # c = 4 * x = 8
c.backward()
print(x.grad) # ?Recall Solution 3.3
The cut: b = a.detach() makes b a constant (value ) with no path back to x.
So although a came from x, autograd treats b as a frozen number.
The only live path from c to x is the explicit x in c = b * x:
The would-be second contribution through b (which secretly holds ) is severed. So
x.grad = tensor([4.]), not the you'd
get without the detach.
Level 4 — Synthesis
Goal: assemble a full layer's backward by hand and match matrix calculus.
Exercise 4.1 — Full linear + ReLU layer gradients
Given
compute the forward , then , loss , and finally give
W.grad and x.grad.
Recall Solution 4.1
Forward. Both positive, so ReLU passes them through: , and .
Backward through the sum. .
Backward through ReLU. Both so : .
Backward into W — why it becomes an outer product. Write out one entry of the forward.
Since , the weight appears in exactly one place: it multiplies
inside . So nudging only changes , at rate .
Chaining with the loss:
Look at that formula: row index comes only from , column index comes only from
. Two indices that vary independently and get multiplied is
exactly the definition of an outer product — a column vector times a row vector fills a
whole matrix. Hence, stacking all :
Shape sanity check: x is so is ; is
; — the same shape as W. A gradient must always match the
shape of the thing it differentiates, and it does.
Backward into x. :
So W.grad = [[1,1],[2,2]] and x.grad = [0.2, 0.6].
Exercise 4.2 — Non-scalar backward needs a vector
L is not a scalar here. What must you pass to .backward(), and what will x.grad be?
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x ** 2 # y = [1, 4, 9], a vector!
# y.backward() # ERROR: grad can be implicitly created only for scalar outputs
y.backward(torch.tensor([1.0, 1.0, 1.0]))
print(x.grad) # ?Recall Solution 4.2
Why the vector argument? .backward() needs a scalar to start from
(). When the output y is a vector, PyTorch cannot guess how
to collapse it, so you supply the upstream gradient (the "seed").
The Jacobian . Since each output depends on its own input only (, and does not depend on ), the matrix of all partials is diagonal: (Off-diagonal entries are because ignores , etc.)
How the seed contracts against . .backward(v) computes the vector–Jacobian product
(a row vector times the matrix). For our diagonal :
With :
This equals y.sum().backward(), because summing means "weight every output by ."
Edge case — a non-ones seed. If instead , the contraction reweights each output: The middle component is killed (its seed is ) and the last is doubled (its seed is ). So the seed vector lets you ask "how much does each output matter to me?" before backpropagating.
Level 5 — Mastery
Goal: design autograd behaviour and reason about it at the level you'd use in real training code.
Exercise 5.1 — Simulate one SGD step
You have a scalar parameter and loss . Using autograd's gradient and a learning rate , perform one gradient-descent update . Give the new .
Recall Solution 5.1
Gradient. .
Why the minus in the update? We want to lower the loss. The gradient points uphill (direction of steepest increase), so we step the opposite way — that's the in gradient descent:
The parameter moved from toward the minimum at . Good — descent is working.
Code that produces it:
w = torch.tensor([1.0], requires_grad=True)
L = (w - 3) ** 2
L.backward()
with torch.no_grad():
w -= 0.1 * w.grad
# w is now 1.4Note the with torch.no_grad(): — the update itself must not be recorded, or autograd would
try to differentiate through your optimizer step.
Exercise 5.2 — Custom gradient control
You want a tensor p to be trainable most of the time, but for one forward pass you need to use
its value without letting gradient flow into it (a "frozen" pass, as in
transfer learning when you freeze a backbone). Name the two
operations that both achieve "no gradient into p here," and state the key difference.
Recall Solution 5.2
Operation A — with torch.no_grad(): wraps a block; no operation inside builds graph
nodes at all. Cheapest — saves memory. Use it for whole frozen forward passes / inference.
with torch.no_grad():
out = model_backbone(p) # nothing here is recordedOperation B — p.detach() produces a single new tensor sharing p's data but severed from
the graph, while the rest of your computation still records normally.
frozen = p.detach() # only this value is cut
out = head(frozen) + other_live_termKey difference: no_grad() disables tracking for everything in the block; detach()
surgically cuts one tensor and leaves the surrounding graph intact. Choose no_grad() for
"freeze this whole region," detach() for "freeze just this value but keep the rest live."
Recall Quick self-check ledger (all numeric answers)
2.1 ::: x.grad = 11
2.2 ::: x.grad = 7, y.grad = 2
2.3 ::: x.grad = 0 (ReLU'(0)=0 convention)
3.1 ::: x.grad = 12 (three un-zeroed backwards of 4)
3.2 ::: after fix, x.grad = 54 (two accumulated backwards of 27)
3.3 ::: x.grad = 4 (detach cuts the path)
4.1 ::: W.grad = [[1,1],[2,2]], x.grad = [0.2, 0.6]
4.2 ::: x.grad = [2, 4, 6]; with seed [1,0,2] → [2, 0, 12]
5.1 ::: new w = 1.4
Recall
What is the difference between requires_grad=True and grad_fn?
requires_grad says the engine should watch the tensor; grad_fn records which operation built it. A leaf can have requires_grad=True but grad_fn=None.Why must you zero gradients each training iteration?
.backward() accumulates (adds) into .grad; without .zero_() the sums from previous iterations pile up.When must you pass a vector to .backward()?
What does .detach() do differently from setting requires_grad=False?
.detach() returns a new cut copy and leaves the original untouched; requires_grad=False permanently modifies the original leaf.