3.3.2 · D4Deep Learning Frameworks

Exercises — Autograd and computational graphs in PyTorch

3,355 words15 min readBack to topic

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 + y
Recall 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.

Figure — Autograd and computational graphs in PyTorch

(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) aNot tracked. Default requires_grad=False.
  • (ii) bTracked. You explicitly asked for it.
  • (iii) cNot tracked. .detach() returns a copy that shares the data but is cut off from the graph — its requires_grad is False. Think of it as a photocopy of the number with no memory of where it came from.
  • (iv) dTracked. If any input to an operation requires grad, the output requires grad. Since b requires grad, d does too, with grad_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:

Figure — Autograd and computational graphs in PyTorch

Gradient w.r.t. x — sum both branches (multivariate chain rule):

Gradient w.r.t. yy 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 works

Numeric 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 Wwhy 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.4

Note 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 recorded

Operation 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_term

Key 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()?
When the output is not a scalar — you supply the upstream/seed gradient for the vector–Jacobian product.
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.