Deep Learning Frameworks
Chapter: 3.3 Deep Learning Frameworks Difficulty: Level 5 — Mastery (cross-domain: math + coding + systems) Time limit: 90 minutes Total marks: 60
Instructions: Answer all three questions. Show mathematical derivations, working code, and reasoning. Partial credit is awarded for correct intermediate steps. Assume
import torchandtorch.nn as nnare available.
Question 1 — Autograd, Computational Graphs & Manual Gradient Verification (20 marks)
Consider a single training example passed through a tiny network:
with , , , , scalar target .
(a) Derive closed-form expressions for , , and using the chain rule. Clearly define every intermediate Jacobian/vector. (8)
(b) Using the numerical values
compute the numerical value of , , and . Give at least 4 significant figures. (6)
(c) Write a PyTorch snippet that builds this graph with requires_grad=True leaf tensors, runs .backward(), and asserts that the autograd gradient of matches your hand-computed value to within . Explain what retain_graph, .detach(), and a second .backward() call would each do to the graph. (6)
Question 2 — Building, Training & Checkpointing an nn.Module (20 marks)
You must implement, from scratch, a training pipeline for logistic regression on a 2-feature dataset.
(a) Implement a class LogReg(nn.Module) with a single linear layer (2→1) producing logits. Then write a complete training loop (no Trainer helpers) that: uses Dataset/DataLoader over tensors X (N×2) and y (N,), uses BCEWithLogitsLoss, an SGD optimizer, and performs exactly the correct sequence of zero_grad → forward → loss → backward → step. (10)
(b) Explain precisely why BCEWithLogitsLoss is numerically preferable to applying sigmoid then BCELoss. Show the log-sum-exp identity that makes it stable. (4)
(c) Extend the loop to save a checkpoint every epoch containing model weights, optimizer state, epoch number, and best validation loss; and write the code to resume training from a checkpoint correctly (including restoring optimizer state). State one bug that occurs if you save model instead of model.state_dict(). (6)
Question 3 — Mixed Precision, GPU & Distributed Reasoning (20 marks)
(a) In mixed-precision (FP16/BF16) training with torch.cuda.amp, explain the role of the gradient scaler. Derive why gradients underflow in FP16: given FP16's smallest positive normal number is , and a loss scale , show the condition on a true gradient under which scaling by prevents underflow. (6)
(b) Write a correct AMP training step using torch.cuda.amp.autocast and GradScaler (scale → backward → unscale/step → update). Note where .to(device) calls must happen and why moving data inside vs. outside the loop matters for throughput. (8)
(c) In data-parallel distributed training across GPUs with per-GPU batch size , the gradients are all-reduced (averaged). Prove that the averaged gradient equals the gradient of the mean loss over the global batch (assuming a mean-reduction loss). Then state how the learning rate should typically be scaled and one reason the "linear scaling rule" can break. (6)
Answer keyMark scheme & solutions
Question 1
(a) Derivation (8 marks)
Define intermediates:
- , elementwise; (2)
- ,
Let . (1)
w: . (2)
Backprop to z: , and . Call this . (2)
b: . (0.5)
W: (outer product, ). (0.5)
(b) Numerical (6 marks)
. (1)
in both components → . (1)
. (2)
. (1)
; . (1)
(c) Code + concepts (6 marks)
import torch
x = torch.tensor([1., -1.])
W = torch.tensor([[0.5,-0.5],[1.0,0.0]], requires_grad=True)
b = torch.zeros(2, requires_grad=True)
w = torch.tensor([1., 2.], requires_grad=True)
y = torch.tensor(0.)
z = W @ x + b
h = torch.tanh(z)
yhat = w @ h
L = 0.5*(yhat - y)**2
L.backward()
manual = (yhat.detach()-y)*h.detach()
assert torch.allclose(w.grad, manual, atol=1e-5)(3 marks for a correct runnable graph + backward + assert)
Concepts (1 each):
retain_graph=True: keeps the intermediate buffers after backward so a second.backward()can traverse the same graph; without it the graph is freed and a second backward raises a RuntimeError..detach(): returns a tensor sharing storage but cut from the graph (no grad flows through), used here so the manual gradient computation doesn't create graph nodes.- A second
.backward()accumulates into.grad(adds), not overwrites — hencezero_grad()in training loops.
Question 2
(a) Model + loop (10 marks)
import torch, torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader
class LogReg(nn.Module):
def __init__(self):
super().__init__()
self.lin = nn.Linear(2, 1)
def forward(self, x):
return self.lin(x).squeeze(-1) # logits, shape (B,)
ds = TensorDataset(X, y.float())
dl = DataLoader(ds, batch_size=32, shuffle=True)
model = LogReg()
crit = nn.BCEWithLogitsLoss()
opt = torch.optim.SGD(model.parameters(), lr=0.1)
for epoch in range(num_epochs):
for xb, yb in dl:
opt.zero_grad()
logits = model(xb)
loss = crit(logits, yb)
loss.backward()
opt.step()Marks: class (2), Dataset/DataLoader (2), correct loss/optimizer (2), correct ordering zero_grad→forward→loss→backward→step (4).
(b) Numerical stability (4 marks)
BCEWithLogitsLoss combines sigmoid + BCE and uses the log-sum-exp trick, avoiding computing explicitly (which saturates to 0 or 1 causing ). (2)
Identity: BCE loss for target , logit :
The form keeps the exponent , so never overflows. (2)
(c) Checkpointing (6 marks)
# save each epoch
torch.save({
'epoch': epoch,
'model_state': model.state_dict(),
'opt_state': opt.state_dict(),
'best_val': best_val,
}, 'ckpt.pt')
# resume
ckpt = torch.load('ckpt.pt', map_location=device)
model.load_state_dict(ckpt['model_state'])
opt.load_state_dict(ckpt['opt_state'])
start_epoch = ckpt['epoch'] + 1
best_val = ckpt['best_val']Marks: save dict (2), resume with optimizer restore (2), start epoch/best_val (1).
Bug (1): torch.save(model) pickles the whole object including the class/module path; loading later breaks if the code/class definition moves or refactors, and it ties the checkpoint to that exact source layout. state_dict() stores only tensors → portable.
Question 3
(a) Gradient scaler / underflow (6 marks)
FP16 has a narrow dynamic range; small gradients round to 0 (underflow), stalling learning. (2)
The scaler multiplies the loss by before backward. By linearity, every gradient is multiplied by : . Before the optimizer step, gradients are unscaled (divided by ). (2)
Condition: a true gradient component survives if , i.e.
Choosing large pushes the underflow threshold down; dynamic scaling raises until inf/nan appears then backs off. (2)
(b) AMP step (8 marks)
scaler = torch.cuda.amp.GradScaler()
model = model.to(device) # move model ONCE, outside loop
for xb, yb in dl:
xb, yb = xb.to(device, non_blocking=True), yb.to(device)
opt.zero_grad()
with torch.cuda.amp.autocast():
logits = model(xb)
loss = crit(logits, yb)
scaler.scale(loss).backward() # scaled backward
scaler.step(opt) # unscales then steps (skips if inf/nan)
scaler.update() # adjusts scale factorMarks: autocast context (2), scale→backward (1), step (1), update (1), model .to once outside (1), batch .to inside loop (1), throughput reasoning (1): moving the model each iteration wastes host↔device copies; batches must move inside the loop because each is different, ideally overlapped with non_blocking=True + pinned memory.
(c) Distributed proof (6 marks)
Global batch = samples. Per-GPU mean loss over its samples:
All-reduce averages:
Hence averaged gradient = gradient of the mean loss over the whole global batch. (4)
Learning-rate scaling: with global batch vs. base batch , scale LR by (linear scaling rule) with warmup. (1)
Breaks: at very large batch the gradient noise scale shrinks, curvature/generalization effects dominate, and linear scaling causes divergence or worse generalization; also breaks early in training (needs warmup). (1)
[
{"claim":"yhat = 3*tanh(1) ≈ 2.284782 and L ≈ 2.610135",
"code":"import sympy as sp\nh=sp.tanh(1)\nyhat=3*h\nL=sp.Rational(1,2)*yhat**2\nya=float(yhat); La=float(L)\nresult = abs(ya-2.284782)<1e-4 and abs(La-2.610135)<1e-4"},
{"claim":"dL/dw = delta*h = [1.740094, 1.740094]",
"code":"import sympy as sp\nh=float(sp.tanh(1))\nyhat=3*h\ndelta=yhat-0\ngrad=delta*h\nresult = abs(grad-1.740094)<1e-4"},
{"claim":"Averaged per-GPU mean gradients equal global mean gradient (K=2,B=2 symbolic check)",
"code":"import sympy as sp\na,b,c,d=sp.symbols('a b c d')\nB=2;K=2\ng1=(a+b)/B\ng2=(c+d)/B\nbar=(g1+g2)/K\nglobal_g=(a+b+c+d)/(K*B)\nresult = sp.simplify(bar-global_g)==0"},
{"claim":"Underflow threshold: |g| >= 6.10e-5/S, e.g. S=1024 gives threshold ~5.96e-8",
"code":"thr=6.10e-5/1024\nresult = abs(thr-5.957e-8)<1e-10"}
]