Lecture 4 · Computation graphs, backpropagation, and autograd
This is the single complete companion to the lecture. It follows the same numbers and notation as the slides:
local backward rules;
one complete scalar graph;
symbolic differentiation and finite differences as independent checks;
the same graph in PyTorch autograd;
gradient accumulation and zero_grad();
branch accumulation and a vector neuron;
one dense-layer vector-Jacobian product;
a three-example mean batch and equivalent microbatches;
one concrete optimizer step and a tiny MLP.
Evidence contract. Every input is a declared teaching construction. Every displayed result is freshly computed by the cells below. There is no dataset download, randomness, or hidden model training.
Checkpoint 1 · Setup and a compact display helper
The notebook uses only PyTorch and standard Colab/Jupyter display utilities. Double precision keeps the small arithmetic easy to compare with the slides.
import htmlimport mathimport torchfrom IPython.display import HTML, displaytorch.set_default_dtype(torch.float64)def fmt(value):ifisinstance(value, torch.Tensor): value = value.detach().cpu().tolist()return html.escape(str(value))def show_table(headers, rows, caption=None): head ="".join(f"<th>{html.escape(str(h))}</th>"for h in headers) body ="".join("<tr>"+"".join(f"<td>{fmt(v)}</td>"for v in row) +"</tr>"for row in rows ) cap =f"<caption>{html.escape(caption)}</caption>"if caption else"" display(HTML("<style>div.l4-wrap{max-width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}""table.l4{border-collapse:collapse;margin:.6em 0}""table.l4 th,table.l4 td{border:1px solid #aeb8bd;padding:.32em .6em;""text-align:left}table.l4 th{background:#edf4f4;color:#17383b}""table.l4 caption{text-align:left;font-weight:700;margin-bottom:.35em}</style>"f"<div class='l4-wrap'><table class='l4'>{cap}<thead><tr>{head}</tr></thead>"f"<tbody>{body}</tbody></table></div>" ))print("torch", torch.__version__)print("default dtype", torch.get_default_dtype())
torch 2.13.0
default dtype torch.float64
Checkpoint 2 · Three local backward rules
If a later part of the graph sends an upstream gradient \(g_v=\partial L/\partial v\), each primitive returns \(g_v\) times its local derivative.
Checkpoint 4 · Reverse sweep through the same stored values
Seed \(g_L=\partial L/\partial L=1\). Then repeatedly apply upstream × local. When a value fans out, returned contributions add.
gL =1.0ge = gL * (2*e) # L=e^2ga = ge *1.0# e=a-ygy = ge * (-1.0)gm = ga *1.0# a=m+bgb = ga *1.0gw = gm * x # m=w*xgx = gm * wexpected = {"L": 1.0, "e": -6.0, "a": -6.0, "m": -6.0,"w": -18.0, "x": -12.0, "b": -6.0, "y": 6.0}actual = {"L": gL, "e": ge, "a": ga, "m": gm,"w": gw, "x": gx, "b": gb, "y": gy}assert actual == expectedshow_table( ["stored value q", "q", "g_q = ∂L/∂q"], [[name, {"L":L,"e":e,"a":a,"m":m,"w":w,"x":x,"b":b,"y":y}[name], grad]for name, grad in actual.items()],"All stored values and their gradients",)
All stored values and their gradients
stored value q
q
g_q = ∂L/∂q
L
9.0
1.0
e
-3.0
-6.0
a
7.0
-6.0
m
6.0
-6.0
w
2.0
-18.0
x
3.0
-12.0
b
1.0
-6.0
y
10.0
6.0
Checkpoint 5 · Two independent checks
Symbolic differentiation transforms a formula into a derivative formula; it can be done on paper or by a computer-algebra system. Finite differences instead probe nearby loss values and approximate one derivative number. Neither is how PyTorch performs reverse-mode autograd.
residual = w*x+b-ysymbolic = {"w": 2*residual*x,"x": 2*residual*w,"b": 2*residual,"y": -2*residual,}def loss_value(w_, x_, b_, y_):return (w_*x_+b_-y_)**2eps =1e-5base = {"w": w, "x": x, "b": b, "y": y}finite = {}order = ["w", "x", "b", "y"]for name in order: plus = base.copy(); minus = base.copy() plus[name] += eps; minus[name] -= eps finite[name] = ( loss_value(plus["w"], plus["x"], plus["b"], plus["y"])- loss_value(minus["w"], minus["x"], minus["b"], minus["y"]) ) / (2*eps)manual = {"w": gw, "x": gx, "b": gb, "y": gy}for name in order:assert symbolic[name] == manual[name]assert math.isclose(finite[name], manual[name], rel_tol=1e-9, abs_tol=1e-8)show_table( ["q", "manual reverse", "symbolic formula at this point", "central difference"], [[name, manual[name], symbolic[name], f"{finite[name]:.8f}"] for name in order],"Three routes agree",)
Three routes agree
q
manual reverse
symbolic formula at this point
central difference
w
-18.0
-18.0
-18.00000000
x
-12.0
-12.0
-12.00000000
b
-6.0
-6.0
-6.00000000
y
6.0
6.0
6.00000000
Checkpoint 6 · One gradient step lowers this loss
Backprop computes gradients; an optimizer later uses them. For this tiny example, one gradient-descent step with \(\eta=0.01\) is easy to audit.
X = torch.tensor([[2.0, -1.0], [-1.0, 2.0], [2.0, 2.0]])Y = torch.tensor([[-5.0, -2.0], [7.0, 1.0], [7.0, -2.0]])B = X.shape[0]Z = X@W_dense.T+b_denseR = Z-Yloss_each =0.5*(R**2).sum(dim=1)loss_mean = loss_each.mean()G_Z = R/Btorch.testing.assert_close(Z, torch.tensor([[-1.0,-4.0],[5.0,5.0],[8.0,-1.0]]))torch.testing.assert_close(R, torch.tensor([[4.0,-2.0],[-2.0,4.0],[1.0,1.0]]))torch.testing.assert_close(loss_each, torch.tensor([10.0,10.0,1.0]))torch.testing.assert_close(loss_mean, torch.tensor(7.0))torch.testing.assert_close(G_Z, R/3)show_table( ["n", "x^(n)", "z^(n)", "y^(n)", "r^(n)", "ell_n"], [[n+1, X[n].tolist(), Z[n].tolist(), Y[n].tolist(), R[n].tolist(), float(loss_each[n])]for n inrange(B)],"Three examples reuse the same W and b",)print("mean loss L =", float(loss_mean))print("Z.grad should be R/B =\n", G_Z)
Three examples reuse the same W and b
n
x^(n)
z^(n)
y^(n)
r^(n)
ell_n
1
[2.0, -1.0]
[-1.0, -4.0]
[-5.0, -2.0]
[4.0, -2.0]
10.0
2
[-1.0, 2.0]
[5.0, 5.0]
[7.0, 1.0]
[-2.0, 4.0]
10.0
3
[2.0, 2.0]
[8.0, -1.0]
[7.0, -2.0]
[1.0, 1.0]
1.0
mean loss L = 7.0
Z.grad should be R/B =
tensor([[ 1.3333, -0.6667],
[-0.6667, 1.3333],
[ 0.3333, 0.3333]])
Checkpoint 13 · Shared parameter paths add; the mean then scales
Each example proposes one outer product \(r^{(n)}(x^{(n)})^T\) to the same \(W\). The batch gradient adds those proposals and divides by \(B\). The three input rows are distinct, so their gradients remain separate.
Checkpoint 14 · Microbatches reproduce the same mean gradient
Clear once, backpropagate each \(\ell_n/B\), and update once. This equals one full-batch backward when parameters stay fixed and the reduction is scaled consistently. It need not remain equivalent with batch-coupled operations such as BatchNorm.
W_micro = W_dense.clone().requires_grad_()b_micro = b_dense.clone().requires_grad_()snapshots = []for n inrange(B): z_n = W_micro@X[n]+b_micro scaled_loss_n =0.5*((z_n-Y[n])**2).sum()/B scaled_loss_n.backward() snapshots.append(W_micro.grad.detach().clone())torch.testing.assert_close(W_micro.grad, W_full.grad)torch.testing.assert_close(b_micro.grad, b_full.grad)show_table( ["after microbatch", "accumulated W.grad"], [[n+1, snapshots[n].tolist()] for n inrange(B)],"The running sum reaches the full mean-batch gradient",)print("full-batch W.grad =\n", W_full.grad)
The running sum reaches the full mean-batch gradient
Checkpoint 15 · A concrete training step uses the batch gradient
Now the familiar loop is executable: clear, forward, reduce to one scalar, backward, and update. With learning rate \(0.01\), the same fixed batch moves from loss \(7\) to \(6.5865\).
Checkpoint 16 · A tiny MLP is the same three-block pattern
The slides finish with dense \(\rightarrow\) ReLU \(\rightarrow\) dense. We set deterministic weights so every forward value is auditable, then let PyTorch return one gradient for every parameter. The inactive hidden unit returns zero.
Reverse-mode backprop repeatedly applies upstream × local and adds at shared values.
Symbolic differentiation derives a formula; finite differences approximate a point derivative; autograd records the executed graph and runs the reverse sweep.
A dense layer repeats the scalar affine rule row by row.
A batch adds an example axis. Shared parameter gradients add across examples; the loss reduction determines whether the final result is a sum or a mean.
Multiple backward calls accumulate into .grad; clear once per intended update window and step once after all correctly scaled contributions arrive.
A deeper MLP is not a new differentiation idea: each dense or activation block receives one arriving gradient and returns the next one.