Session S08
Turn tensors, modules, data, and an optimizer into one reliable program.
Deep Learning · CS 4715/5715 · University of Idaho
| Canvas assignment | Submit | Points |
|---|---|---|
| HW1 Code: Micro-autograd | Only hw1_autograd.py | 85 |
| HW1 Write-up: Micro-autograd | Paste the write-up into Text Entry | 15 |
python -m pytest tests -v before uploading the code file.No new homework today. HW2 is released in S12.
| Question | PyTorch object that answers it |
|---|---|
| What numbers move through the model? | Tensor |
| Which tensors are trainable parameters? | nn.Module |
| Which examples form this update? | Dataset + DataLoader |
| When are gradients cleared and weights changed? | The training loop |
Each object owns one responsibility; the loop connects them.
| Property | Example | Why the program needs it |
|---|---|---|
| Shape | (N, D) | Determines which operations fit |
| Dtype | float32, long | Selects numeric representation or label indexing |
| Device | cpu, cuda, mps | All operands of an operation must be on a compatible device |
| Gradient flag | requires_grad=True | Records operations needed by backward |
For class labels, use torch.long. For model inputs and weights, float32 is the usual default.
Let \(X\in\mathbb R^{N\times D}\), \(W\in\mathbb R^{D\times C}\), and \(b\in\mathbb R^C\).
\[Z=XW+b\in\mathbb R^{N\times C}\]
torch.manual_seed(0)
x = torch.randn(4, 3) # N=4, D=3
w = torch.randn(3, 2) # D=3, C=2
b = torch.randn(2) # broadcast over N
z = x @ w + b # (4,3) @ (3,2) + (2,)
print(x.shape, w.shape, b.shape, z.shape)
torch.Size([4, 3]) torch.Size([3, 2]) torch.Size([2]) torch.Size([4, 2])
@ contracts the shared \(D\) axis; adding \(b\) broadcasts it across the \(N\) rows.
Given: \(X:(4,3)\), trainable \(W:(3,2)\). Define \(M=XW\), square elementwise, then average all eight values.
\[\texttt{backward()}: \quad L\;\longrightarrow\;Q\;\longrightarrow\;M\;\longrightarrow\;W\]
The forward pass records how the scalar \(L\) depends on \(W\); backward follows that route in reverse and produces \(\nabla_W L:(3,2)\).
import torch
torch.manual_seed(0)
x = torch.randn(4, 3)
w = torch.randn(3, 2, requires_grad=True)
loss = ((x @ w) ** 2).mean()
loss.backward()
print(f"loss={loss.item():.4f}; grad={tuple(w.grad.shape)}")
loss=0.5846; grad=(3, 2)
| Line | What becomes true |
|---|---|
requires_grad=True | Operations involving \(W\) are recorded. |
loss.backward() | Every entry of \(W\) receives one derivative. |
w.grad.shape == w.shape: one gradient value for every trainable value.
Start with: graph-connected tensor x. Ask two questions: “Does the result share its memory?” and “Does backward still reach x?”
| Result | Same storage as x? | Backward reaches x? |
|---|---|---|
x.view(...) | Yes | Yes |
x.reshape(...) | Maybe | Yes |
x.clone() | No | Yes, through CloneBackward |
x.detach() | Yes | No |
x.detach().clone() | No | No |
For a stable snapshot used only for logging or analysis, detach().clone() breaks both connections.
nn.Moduleparameters() recurses through the tree.to(device) moves registered state.state_dict() provides stable names.Registration is bookkeeping with consequences: the optimizer, device transfer, and checkpoint all use the same inventory.
__init__ registers structure; forward uses it
import torch
from torch import nn
class TinyMLP(nn.Module):
def __init__(self, d_in, d_hidden, d_out):
super().__init__()
self.fc1 = nn.Linear(d_in, d_hidden)
self.fc2 = nn.Linear(d_hidden, d_out)
def forward(self, x):
h = torch.relu(self.fc1(x))
return self.fc2(h)
fc1 + ReLUfc2Define forward(), but call model(x) so module hooks and framework bookkeeping run.
nn.Linear stores the transpose of our S05 convention| Context | Stored weight | Forward computation |
|---|---|---|
| S05 NumPy layer | \(W:(D,H)\) | \(XW+b\) |
nn.Linear(D,H) | weight: \((H,D)\) | \(X\,\text{weight}^{\top}+b\) |
For TinyMLP(3,16,2):
\[X:(N,3)\quad\xrightarrow{\;\text{fc1.weight}^{\top}:(3,16)\;}\quad H:(N,16)\quad\xrightarrow{\;\text{fc2.weight}^{\top}:(16,2)\;}\quad Z:(N,2)\]
The mathematical map is unchanged. Only the stored orientation differs.
model = TinyMLP(3, 16, 2): input width 3, hidden width 16, output width 2.
model
├─ fc1: Linear(3, 16)
├─ weight \((16,3)\)
└─ bias \((16,)\)
└─ fc2: Linear(16, 2)
├─ weight \((2,16)\)
└─ bias \((2,)\)
Count read off the tree
\[16\cdot3+16+2\cdot16+2=98\]
Registration fixes each parameter's name and shape before any code prints them.
named_parameters prints the predicted inventoryModel as on the previous page: model = TinyMLP(3, 16, 2).
for name, p in model.named_parameters():
print(name, tuple(p.shape))
fc1.weight (16, 3)
fc1.bias (16,)
fc2.weight (2, 16)
fc2.bias (2,)
The optimizer receives these same four tensors from model.parameters().
| Object | Shape / dtype | Meaning |
|---|---|---|
Minibatch input xb | (B, D), float | \(B\) examples |
Model output logits | (B, C), float | One unnormalized score per class |
Targets yb | (B,), long | Class indices in \(0,\ldots,C-1\) |
CrossEntropyLoss | scalar | Mean negative log-likelihood for the batch |
Pass raw logits to CrossEntropyLoss. It applies log-softmax internally; adding Softmax first changes the objective.
Dataset selects examples; DataLoader forms batcheslen(ds)ds[i] → (x_i, y_i)xb: (B,D)yb: (B,)TensorDataset adapts tensors already held in memory.__getitem__ performs real I/O or augmentation, not for every small in-memory dataset.Dataset returns one example at a time
from torch.utils.data import TensorDataset
X = torch.randn(10, 3) # 10 examples, 3 features
y = torch.tensor([0, 1] * 5, dtype=torch.long)
ds = TensorDataset(X, y)
print(len(ds), ds[0][0].shape, ds[0][1])
10 torch.Size([3]) tensor(0)
| Interface method | Returns | Used by |
|---|---|---|
__len__(self) | Total number of examples | Sampler generates indices in \(0\ldots\text{len}-1\) |
__getitem__(self, i) | One (x, y) pair | Loader calls this for each chosen index |
shuffle=True for training (SGD assumes a fresh random order each epoch); shuffle=False for evaluation (stable and reproducible).
import torch
from torch.utils.data import TensorDataset, DataLoader
X = torch.randn(10, 3)
y = torch.tensor([0, 1] * 5, dtype=torch.long)
ds = TensorDataset(X, y)
loader = DataLoader(ds, batch_size=4, shuffle=False)
xb, yb = next(iter(loader))
print(xb.shape, yb.shape, yb.dtype, len(loader))
torch.Size([4, 3]) torch.Size([4]) torch.int64 3
| Why three batches? | Batch sizes |
|---|---|
| \(\lceil 10/4\rceil=3\) | 4, 4, 2 |
The last batch may be smaller. Write the model and loss in terms of \(B\), not a fixed batch size.
| State | Owner | Created how often? |
|---|---|---|
| Weights and buffers | model | Once |
| Momentum / Adam moments | opt | Once |
| Minibatch order | train_loader | Iterator each epoch |
| Forward graph | Current tensor operations | Rebuilt each iteration |
| Parameter gradients | Each parameter's .grad | Cleared, then accumulated each iteration |
Create the model and optimizer outside the loop. Recreating either one discards learned state.
Created once before this loop: model, opt, loss_fn, and train_loader. The loader yields xb:(B,D), yb:(B,).
for epoch in range(epochs):
model.train()
for xb, yb in train_loader:
opt.zero_grad()
logits = model(xb)
loss = loss_fn(logits, yb)
loss.backward()
opt.step()
One pass of the inner body is one parameter update: clear, forward, backward, step.
The inner body from the previous page: opt.zero_grad() · logits = model(xb) · loss = loss_fn(logits, yb) · loss.backward() · opt.step()
.gradThe order is part of the algorithm. step() must use gradients from the current forward pass.
Setup: import torch · import torch.nn as nn · torch.manual_seed(0). A single-weight model, SGD with \(\eta=0.1\).
net = nn.Linear(1, 1, bias=False) # one scalar weight
opt = torch.optim.SGD(net.parameters(), lr=0.1)
x, y = torch.tensor([[2.0]]), torch.tensor([[1.0]])
w = net.weight
opt.zero_grad(); print(f"zero_grad w={w.item():+.4f} grad={w.grad}")
loss = nn.MSELoss()(net(x), y)
print(f"forward w={w.item():+.4f} grad={w.grad}")
loss.backward(); print(f"backward w={w.item():+.4f} grad={w.grad.item():+.4f}")
opt.step(); print(f"step w={w.item():+.4f} grad={w.grad.item():+.4f}")
zero_grad w=-0.0075 grad=None
forward w=-0.0075 grad=None
backward w=-0.0075 grad=-4.0599
step w=+0.3985 grad=-4.0599
\(w_{\text{new}}=w-\eta\,g=-0.0075-0.1\times(-4.0599)=+0.3985\). The weight changes only at step(); the gradient stays until zero_grad().
| Switch | What it changes | What it does not change |
|---|---|---|
model.eval() | Dropout and BatchNorm behavior | Autograd recording |
torch.no_grad() | Whether operations create an autograd graph | Module training/evaluation behavior |
Use both: evaluation behavior and no graph.
Use eval(), but leave gradients enabled.
eval() and no_grad() solve different problems.
CrossEntropyLoss returns the mean loss inside each batch. Suppose three batches have sizes \(4,4,2\) and mean losses \(0.4,0.2,0.8\).
\[\frac{0.4+0.2+0.8}{3}=0.467\]
Treats the 2-example batch like a 4-example batch.
\[\frac{4(0.4)+4(0.2)+2(0.8)}{4+4+2}=0.400\]
Every example receives equal weight.
Accumulate batch_mean * batch_size, accumulate the example count, then divide once.
def evaluate(model, loader, loss_fn):
model.eval()
total_loss = correct = n = 0
with torch.no_grad():
for xb, yb in loader:
logits = model(xb)
total_loss += loss_fn(logits, yb).item() * len(xb)
correct += (logits.argmax(1) == yb).sum().item()
n += len(xb)
return total_loss / n, correct / n
len(xb) recovers that batch's total.model.train().Input: \(X:(800,2)\)
Targets: \(y:(800)\), two classes
Split: 640 train / 160 validation
Why an MLP? No single straight line separates the two colors.
Output contract: two raw logits per point.
The data makes the model choice visible: classification requires a nonlinear boundary, while the training loop itself stays unchanged.
| Component | Choice | Reason it is visible here |
|---|---|---|
| Data | Two synthetic spirals | A linear decision boundary is insufficient |
| Model | 2×64 ReLU MLP, dropout 0.1 | Requires nonlinear layers and correct train/eval modes |
| Loss | CrossEntropyLoss | Consumes raw class logits |
| Optimizer | AdamW | Carries moment state between batches |
| Measurement | Train loss; validation loss and accuracy | Separates updates from held-out evaluation |
The program is not a list of APIs: every object fills one slot in the same training system.
Fixed setup: 640 train / 160 validation · 2×64 ReLU MLP · dropout 0.1 · AdamW · seed 0.
epoch 30: train loss 0.2941 | val loss 0.2381 | val acc 0.956
epoch 60: train loss 0.1175 | val loss 0.0597 | val acc 1.000
epoch 90: train loss 0.0586 | val loss 0.0265 | val acc 1.000
epoch 120: train loss 0.0549 | val loss 0.0175 | val acc 1.000
epoch 150: train loss 0.0502 | val loss 0.0120 | val acc 1.000
One synthetic run verifies the pipeline end to end. The full experiment is in the s08_pytorch_in_practice notebook.
The log from the previous page (640/160 split · 2×64 ReLU MLP · dropout 0.1 · AdamW · seed 0):
epoch 30: train loss 0.2941 | val loss 0.2381 | val acc 0.956
epoch 60: train loss 0.1175 | val loss 0.0597 | val acc 1.000
epoch 90: train loss 0.0586 | val loss 0.0265 | val acc 1.000
epoch 120: train loss 0.0549 | val loss 0.0175 | val acc 1.000
epoch 150: train loss 0.0502 | val loss 0.0120 | val acc 1.000
The claim stops at this seed and this split. No general optimizer or architecture ranking follows.
| Goal | Store | Avoid |
|---|---|---|
| Record a scalar loss | losses.append(loss.item()) | losses.append(loss) |
| Keep tensor values for analysis | tensor.detach().clone() | Keeping a graph-connected tensor |
| Compute ordinary validation metrics | with torch.no_grad(): | Building unused evaluation graphs |
A Python reference to a graph-connected tensor can keep the entire graph reachable and make memory grow across iterations.
| Goal | Save | Why |
|---|---|---|
| Inference | model.state_dict() | Parameters plus persistent buffers |
| Resume training | Model state, optimizer state, epoch | Adam moments and progress affect the next update |
| Reproduce preprocessing | Configuration and label mapping | Weights do not describe the data contract |
# Inference only # Resume training
torch.save(model.state_dict(), torch.save({"model": model.state_dict(),
"spiralnet.pt") "opt": opt.state_dict(),
"epoch": epoch}, "resume.pt")
A checkpoint is state, not architecture code. Recreate the model class, then load the named tensors.
modelstate_dict()SpiralNet()load_state_dict| The file contains | Your Python code must still provide |
|---|---|
| Named parameter and buffer tensors | The model class and matching names/shapes |
| Values learned during training | Preprocessing, label mapping, and evaluation data |
loaded = SpiralNet() # fresh random weights
state = torch.load("spiralnet.pt", weights_only=True)
loaded.load_state_dict(state) # state transfer
A successful load checks structure; a held-out metric checks that the restored system behaves correctly.
Available from the experiment: trained model, class SpiralNet, evaluate, val_loader, and loss_fn.
import torch
torch.save(model.state_dict(), "spiralnet.pt")
loaded = SpiralNet()
state = torch.load("spiralnet.pt", weights_only=True)
loaded.load_state_dict(state)
loaded.eval()
_, acc_loaded = evaluate(loaded, val_loader, loss_fn)
print(f"loaded validation accuracy: {acc_loaded:.3f}")
loaded validation accuracy: 1.000
From the previous page: loaded = SpiralNet() filled via load_state_dict, then evaluated to accuracy 1.000.
load_state_dict is strict by default: missing or unexpected keys raise immediately.
# What strict=True catches (do not silence with strict=False):
Error(s) in loading state_dict for TinyMLP:
Missing key(s) in state_dict: "fc2.bias".
Unexpected key(s) in state_dict: "typo.weight".
A checkpoint that loads without an exception has not yet been shown to work.
Setup: import torch · import torch.nn as nn · torch.manual_seed(0). The model emits pred:(8,); the loader column arrives as target:(8, 1).
pred = torch.randn(8)
target = torch.randn(8, 1)
wrong = nn.MSELoss()(pred, target) # runs without error
right = nn.MSELoss()(pred, target.squeeze(1))
print((pred - target).shape)
print(f"wrong: {wrong.item():.4f} right: {right.item():.4f}")
torch.Size([8, 8])
wrong: 1.8507 right: 2.0946
Subtraction broadcasts \((8,)\) against \((8,1)\) to \((8,8)\): the mean runs over 64 pairwise differences instead of 8 matched ones.
From the previous page: pred:(8,) vs target:(8, 1) gave loss 1.8507 where the matched loss is 2.0946.
UserWarning naming the two sizes, but warnings scroll past in a training log, and gradient descent optimizes the wrong number without complaint.assert pred.shape == target.shape next to the loss turns this silent bug into a loud one.
| Symptom | First checks |
|---|---|
Shape error in Linear | Print xb.shape, stored weight shape, and expected feature axis |
expected Long but found Float | Targets for classification must contain class indices with dtype long |
| Loss much slower than expected | Check gradient clearing and whether raw logits enter cross-entropy |
| Evaluation changes across calls | Check model.eval(); then inspect randomness and data order |
| Memory grows each iteration | Check stored graph-connected tensors and missing no_grad() |
| Loaded model predicts at chance | Check keys, architecture, preprocessing, and a held-out metric |
| Prompt | A complete answer identifies |
|---|---|
Why call model(x)? | The registered module call path, including hooks |
| Why clear gradients before backward? | Backward accumulates into parameter .grad |
Why use both eval() and no_grad()? | Module behavior and graph recording are independent |
| Why save optimizer state to resume? | The next update depends on stored moments |
A model's validation result changes after loading. Name three different contracts you would verify.
Model architecture and keys; preprocessing / label mapping; evaluation mode and the held-out dataset.
| # | Convention |
|---|---|
| 1 | Default dtype is float32, not NumPy's float64. |
| 2 | Class labels use torch.long — CrossEntropyLoss treats them as indices. |
| 3 | Batch dimension first, everywhere: (B, D), (B, C), (B,). |
| 4 | nn.Linear(D, H) stores weight as \((H, D)\) and computes \(x\,W^{\!\top}\!+b\) — transposed from S05. |
| 5 | shuffle=True for training; shuffle=False for evaluation. |
| 6 | eval() switches behavior; no_grad() stops recording — independent, need both. |
These six rules recur in every PyTorch program this course builds.