Level 5 — MasteryDeep Learning Frameworks

Deep Learning Frameworks

90 minutes60 marksprintable — key stays hidden on paper

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 torch and torch.nn as nn are available.


Question 1 — Autograd, Computational Graphs & Manual Gradient Verification (20 marks)

Consider a single training example passed through a tiny network:

z=Wx+b,h=tanh(z),y^=wh,L=12(y^y)2z = W x + b, \qquad h = \tanh(z), \qquad \hat{y} = w^\top h, \qquad L = \tfrac{1}{2}(\hat{y} - y)^2

with xR2x \in \mathbb{R}^2, WR2×2W \in \mathbb{R}^{2\times 2}, bR2b \in \mathbb{R}^2, wR2w \in \mathbb{R}^2, scalar target yy.

(a) Derive closed-form expressions for Lw\dfrac{\partial L}{\partial w}, LW\dfrac{\partial L}{\partial W}, and Lb\dfrac{\partial L}{\partial b} using the chain rule. Clearly define every intermediate Jacobian/vector. (8)

(b) Using the numerical values

x=[11],  W=[0.50.51.00.0],  b=[00],  w=[12],  y=0,x=\begin{bmatrix}1\\ -1\end{bmatrix},\; W=\begin{bmatrix}0.5 & -0.5\\ 1.0 & 0.0\end{bmatrix},\; b=\begin{bmatrix}0\\ 0\end{bmatrix},\; w=\begin{bmatrix}1\\ 2\end{bmatrix},\; y=0,

compute the numerical value of y^\hat{y}, LL, and Lw\dfrac{\partial L}{\partial w}. 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 ww matches your hand-computed value to within 10510^{-5}. 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 6.10×105\approx 6.10\times 10^{-5}, and a loss scale SS, show the condition on a true gradient gg under which scaling by SS 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 KK GPUs with per-GPU batch size BB, the gradients are all-reduced (averaged). Prove that the averaged gradient equals the gradient of the mean loss over the global batch KBKB (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:

  • z=Wx+bR2z = Wx + b \in \mathbb{R}^2
  • h=tanh(z)h = \tanh(z), elementwise; h=1tanh2(z)=1h2h' = 1 - \tanh^2(z) = 1 - h^2 (2)
  • y^=wh\hat{y} = w^\top h, L=12(y^y)2L = \tfrac12(\hat y - y)^2

Let δLy^=(y^y)\delta \equiv \dfrac{\partial L}{\partial \hat y} = (\hat y - y). (1)

w: y^=whLw=δh\hat y = w^\top h \Rightarrow \dfrac{\partial L}{\partial w} = \delta\, h. (2)

Backprop to z: Lh=δw\dfrac{\partial L}{\partial h} = \delta\, w, and Lz=Lh(1h2)=δ(w(1h2))\dfrac{\partial L}{\partial z} = \dfrac{\partial L}{\partial h}\odot (1-h^2) = \delta\,(w \odot (1-h^2)). Call this gzR2g_z \in \mathbb{R}^2. (2)

b: z=Wx+bLb=gzz = Wx+b \Rightarrow \dfrac{\partial L}{\partial b} = g_z. (0.5)

W: LW=gzx\dfrac{\partial L}{\partial W} = g_z\, x^\top (outer product, 2×22\times2). (0.5)

(b) Numerical (6 marks)

z=Wx=[0.5(1)+(0.5)(1)1(1)+0(1)]=[11]z = Wx = \begin{bmatrix}0.5(1)+(-0.5)(-1)\\ 1(1)+0(-1)\end{bmatrix} = \begin{bmatrix}1\\1\end{bmatrix}. (1)

h=tanh(1)=0.761594h = \tanh(1) = 0.761594 in both components → h=[0.761594,0.761594]h=[0.761594,\,0.761594]. (1)

y^=wh=1(0.761594)+2(0.761594)=3×0.761594=2.284782\hat y = w^\top h = 1(0.761594)+2(0.761594)=3\times0.761594 = 2.284782. (2)

L=12(y^0)2=12(2.284782)2=2.610135L = \tfrac12(\hat y - 0)^2 = \tfrac12(2.284782)^2 = 2.610135. (1)

δ=y^y=2.284782\delta = \hat y - y = 2.284782; Lw=δh=2.284782×[0.761594,0.761594]=[1.740094,1.740094]\dfrac{\partial L}{\partial w} = \delta h = 2.284782 \times [0.761594,0.761594] = [1.740094,\,1.740094]. (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 — hence zero_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 σ(z)\sigma(z) explicitly (which saturates to 0 or 1 causing log(0)=\log(0) = -\infty). (2)

Identity: BCE loss for target tt, logit zz:

=tlogσ(z)(1t)log(1σ(z))=max(z,0)zt+log(1+ez).\ell = -t\log\sigma(z) - (1-t)\log(1-\sigma(z)) = \max(z,0) - z t + \log(1+e^{-|z|}).

The z|z| form keeps the exponent 0\le 0, so ez(0,1]e^{-|z|}\in(0,1] 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 SS before backward. By linearity, every gradient is multiplied by SS: (SL)=SL\nabla(S\cdot L) = S\,\nabla L. Before the optimizer step, gradients are unscaled (divided by SS). (2)

Condition: a true gradient component gg survives if Sg6.10×105S|g| \ge 6.10\times10^{-5}, i.e.

g6.10×105S.|g| \ge \frac{6.10\times10^{-5}}{S}.

Choosing large SS pushes the underflow threshold down; dynamic scaling raises SS 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 factor

Marks: 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 = KBKB samples. Per-GPU kk mean loss over its BB samples:

Lk=1Bishard ki,gk=Lk=1Biki.L_k = \frac{1}{B}\sum_{i\in \text{shard }k} \ell_i,\qquad g_k = \nabla L_k = \frac{1}{B}\sum_{i\in k}\nabla\ell_i.

All-reduce averages:

gˉ=1Kk=1Kgk=1Kk1Biki=1KBi=1KBi=Lglobal.\bar g = \frac{1}{K}\sum_{k=1}^K g_k = \frac{1}{K}\sum_k \frac{1}{B}\sum_{i\in k}\nabla\ell_i = \frac{1}{KB}\sum_{i=1}^{KB}\nabla\ell_i = \nabla L_{\text{global}}.

Hence averaged gradient = gradient of the mean loss over the whole global batch. (4)

Learning-rate scaling: with global batch KBKB vs. base batch BB, scale LR by KK (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"}
]