Day 11 · PyTorch
Suggested pace: 1–1.5 hrs learn · 2–3 hrs code · rest to debug and document What you should know first ☐ Days 9–10: forward pass, backward pass, training loop ☐ Day 1: Python classes ( init and methods) ☐ Day 2: array sh
Suggested pace: 1–1.5 hrs learn · 2–3 hrs code · rest to debug and document
What you should know first ☐ Days 9–10: forward pass, backward pass, training loop ☐ Day 1: Python classes (__init__ and methods) ☐ Day 2: array shapes (tensors work the same way) If one of these feels shaky, spend 15 minutes revisiting it before starting. That is normal and saves time later.
Goal of today Learn PyTorch by rebuilding your Days 9–10 network. Everything you did by hand (forward, loss, backprop, update) now takes a few lines, because PyTorch automates it.
Big picture — read this first The PyTorch mental model: model.train() enables training behavior such as dropout. model.eval() switches to evaluation behavior. torch.no_grad() avoids tracking gradients during inference. A checkpoint lets you recover a useful model state rather than losing the best model when later epochs perform worse. Keep training, validation, and test responsibilities separate.
(1) Tensor → (2) Dataset → (3) DataLoader → (4) Model → (5) Loss → (6) Optimizer → (7) Training loop → (8) Validation → (9) Test → (10) Checkpoint → (11) Inference
Key idea Key idea: PyTorch = NumPy + automatic gradients + GPU support + ready-made layers.
0. Setup
pip install torch torchvision scikit-learn matplotlibimport torch
import torch.nn as nn
print(torch.__version__, torch.cuda.is_available())
# True if you have an NVIDIA GPUNo GPU? Use Google Colab (free GPU) — but today’s tiny models run fine on CPU.
1. Learning order for today
Tensor → Dataset → DataLoader → Model → Loss → Optimizer → Training Loop → Validation
Follow this order every time you build anything in PyTorch.
2. Tensors
A tensor is PyTorch’s array. It looks and behaves like a NumPy array, but it can (1) live on a GPU and (2) remember how it was computed so gradients can be calculated.
x = torch.tensor([[1., 2.], [3., 4.]]) # note the dots → floats
print(x.shape, x.dtype) # torch.Size([2, 2]) torch.float32
torch.zeros(2, 3) ; torch.ones(2, 3) ; torch.randn(2, 3) ; torch.arange(6)
x + x ; x * x ; x @ x ; x.T ; x.sum() ; x.mean(dim = 0) # same ideas as NumPy
x.reshape(4) ; x.view(4) # reshape| NumPy | PyTorch |
|---|---|
| axis=0 | dim=0 |
| np.float64 (default) | torch.float32 (default) — neural nets use float32 |
| arr.reshape | t.reshape / t.view |
Converting:
t = torch.from_numpy(np_array) # NumPy → tensor
n = t.numpy() # tensor → NumPy (CPU only)
t = t.to("cuda") # move to GPU (must move model AND data)Dtypes to remember: features → float32; classification labels for CrossEntropyLoss → long (int64); binary labels for BCEWithLogitsLoss → float32.
How these problems were checked. The numbers in the worked problems below were calculated and verified with NumPy when this book was built. The PyTorch calls named in the steps do the same math. The PyTorch code in the lessons was not run in the build environment, so run it in your own notebook.
Worked Problem · Shape Arithmetic For Tensors
Problem. A batch of 32 color images has shape (32, 3, 28, 28). Flatten each image into one row, then turn a vector of 5 numbers into a column. Give the new shapes.
- One image has 3 × 28 × 28 = 2352 values.
- Keep the batch of 32 and merge the rest:
x.view(32, -1)has shape (32, 2352). - A vector of shape (5,) becomes a column (5, 1) with
a.unsqueeze(1). NumPy'sa[:,None]does the same. - Check the arithmetic with NumPy.
import numpy as np
x = np.zeros((32, 3, 28, 28))
print(x.reshape(32, -1).shape)
print(np.arange(5)[:, None].shape)(32, 2352)
(5, 1)Answer. (32, 2352) and (5, 1).
Your Turn · Add A Bias
Problem. Logits have shape (32, 10) and a bias has shape (10,). What is the shape of logits + bias, and why does it work?
Try it on paper or in a notebook first. Then check the solution.
Solution — Step By Step
- Compare from the right: 10 and 10 match.
- The bias has no row dimension, so it is stretched across all 32 rows (broadcasting).
- The result keeps the shape (32, 10).
import numpy as np
print((np.zeros((32, 10)) + np.zeros(10)).shape)(32, 10)Answer. (32, 10). The same broadcasting rule works for PyTorch tensors.
3. Autograd — automatic backpropagation
Yesterday you wrote dW1, dW2 by hand. PyTorch does it for you.
w = torch.tensor(2.0, requires_grad = True) # "track gradients for this"
x = torch.tensor(3.0)
loss = (w * x - 1) ** 2 # = (6 - 1)^2 = 25
loss.backward() # computes d loss / d w
print(w.grad) # 2*(w*x-1)*x = 2*5*3 = 30.What happened: PyTorch recorded every operation in a computation graph; backward() walks it backward using the chain rule — exactly your Days 9–10 backprop, automated.
Important rules:
requires_grad=Trueon parameters (nn layers set this automatically)- Gradients accumulate (add up) in
.gradon eachbackward()call → you must clear them each step (zero_grad()) - Use
with torch.no_grad():when you don’t need gradients (validation/prediction) → faster, less memory
Worked Problem · What Autograd Computes
Problem. For y = x² + 3x at x = 2, find dy/dx by hand. This is the value x.grad holds after y.backward().
- The derivative of x² is 2x, and of 3x is 3. So dy/dx = 2x + 3.
- At x = 2: 2×2 + 3 = 7.
- Autograd records every operation and applies the chain rule backwards, so it gets the same 7.
- Verify with a tiny nudge.
f = lambda x: x ** 2 + 3 * x
e = 1e-6
print(round((f(2 + e) - f(2 - e)) / (2 * e), 4))7.0Answer. 7: the slope of y at x = 2.
Your Turn · Gradient Of A Loss
Problem. For loss = (w·x − y)² with w = 1, x = 3, y = 2, find d(loss)/dw by hand and check it numerically.
Try it on paper or in a notebook first. Then check the solution.
Solution — Step By Step
- The prediction error is w·x − y = 3 − 2 = 1.
- d(loss)/dw = 2 × error × x = 2 × 1 × 3 = 6.
- A numerical nudge of w confirms it.
L = lambda w: (w * 3 - 2) ** 2
e = 1e-6
print(round((L(1 + e) - L(1 - e)) / (2 * e), 3))6.0Answer. 6. After loss.backward(), w.grad would hold this value.
4. Dataset and DataLoader
Why? Real data doesn’t fit in memory at once, and we want mini-batches (e.g., 32 samples per update) and shuffling. PyTorch splits the work:
| Class | Job |
|---|---|
| Dataset | “Give me sample number i” (one x, one y) |
| DataLoade r | Groups samples into batches, shuffles, loads in parallel |
from torch.utils.data import Dataset, DataLoader
class MoonsDataset(Dataset):
def __init__ (self, X, y):
self.X = torch.tensor(X, dtype = torch.float32)
self.y = torch.tensor(y, dtype = torch.float32).view( - 1, 1)
def __len__ (self): # how many samples
return len(self.X)
def __getitem__ (self, idx): # one sample
return self.X[idx], self.y[idx]Custom Dataset (3 required methods)
train_loader = DataLoader(MoonsDataset(X_train, y_train), batch_size = 32, shuffle
= True)
val_loader = DataLoader(MoonsDataset(X_val, y_val), batch_size = 64, shuffle
= False)
xb, yb = next(iter(train_loader))
print(xb.shape, yb.shape) # torch.Size([32, 2]) torch.Size([32, 1])DataLoader
- Shuffle training data (avoids learning order); don’t shuffle validation.
- One epoch = looping through the whole
train_loaderonce. - For 800 samples with batch_size 32 → 25 batches per epoch → 25 weight updates per epoch.
Shortcut for tensors: TensorDataset(X_tensor, y_tensor).
Worked Problem · Batching By Hand
Problem. A dataset has 10 samples and the batch size is 4. List the batch sizes, and explain what the two Dataset methods do.
- A
Datasetneeds__len__(how many samples) and__getitem__(i)(give sample i). - A
DataLoaderrepeatedly asks the Dataset for samples and groups them into batches. - 10 samples in batches of 4: 4, 4 and a last batch of 2.
- Shuffling changes the order of samples each epoch, not the batch sizes.
n, bs = 10, 4
print([min(bs, n - i) for i in range(0, n, bs)])[4, 4, 2]Answer. Batch sizes [4, 4, 2]. (This is plain Python; the PyTorch DataLoader does the same grouping.)
5. Model with nn.Module
5.1 Ready-made layers
nn.Linear(in, out)=X @ W + b(creates W and b for you, with good initialization)nn.ReLU(),nn.Sigmoid(),nn.Tanh()nn.Dropout(p),nn.BatchNorm1d(n)
class Net(nn.Module):
def __init__ (self, n_in = 2, n_hidden = 16):
super(). __init__ () # ALWAYS call this first
self.fc1 = nn.Linear(n_in, n_hidden)
self.act = nn.ReLU()
self.fc2 = nn.Linear(n_hidden, 1)
def forward(self, x): # how data flows
x = self.fc1(x)
x = self.act(x)
x = self.fc2(x)
return x
# raw scores ("logits"), NOT probabilities
model = Net()
print(model)5.2 Building a model
Same thing shorter:
model = nn.Sequential(nn.Linear(2, 16), nn.ReLU(), nn.Linear(16, 1))5.3 Understand forward()
- Defines the computation from input to output — your Days 9–10 forward pass
- Call it via
model(x)(notmodel.forward(x)) — this also triggers hooks PyTorch needs - There’s no
backward()method to write — autograd derives it
for name, p in model.named_parameters():
print(name, p.shape)
# fc1.weight torch.Size([16, 2]) ← note: PyTorch stores (out, in), transposed vs.
# our Days 9–10
# fc1.bias torch.Size([16])
# fc2.weight torch.Size([1, 16])
# fc2.bias torch.Size([1])
print(sum(p.numel() for p in model.parameters())) # total: 32+16+16+1 = 655.4 Inspect parameters
Worked Problem · Count The Parameters
Problem. A model is Linear(784, 128), then ReLU, then Linear(128, 10). How many learnable parameters does it have?
- A
Linear(in, out)layer has in × out weights plus out biases. - First layer: 784 × 128 + 128 = 100,352 + 128 = 100,480.
- ReLU has no parameters.
- Second layer: 128 × 10 + 10 = 1,290.
- Total = 100,480 + 1,290 = 101,770.
lin = lambda i, o: i * o + o
print(lin(784, 128), lin(128, 10), lin(784, 128) + lin(128, 10))100480 1290 101770Answer. 101,770 parameters.
Your Turn · A Small Network
Problem. How many parameters does Linear(4, 8) → ReLU → Linear(8, 3) have?
Try it on paper or in a notebook first. Then check the solution.
Solution — Step By Step
- First layer: 4 × 8 + 8 = 40.
- Second layer: 8 × 3 + 3 = 27.
- Total = 40 + 27 = 67.
lin = lambda i, o: i * o + o
print(lin(4, 8) + lin(8, 3))67Answer. 67.
6. Loss Functions
| Task | Loss | Model output | Labels |
|---|---|---|---|
| Binary classification | nn.BCEWithLogitsLoss() | 1 raw logit | float 0/1, shape (B,1) |
| Multi-class | nn.CrossEntropyLoss() | C raw logits | class index (long), shape (B,) |
| Regression includes | nn.MSELoss() the sigmoid inside (more | number numerically | float stable). So your |
- model should output raw logits — no sigmoid at the end. Likewise,
CrossEntropyLossincludes softmax — don’t add softmax in the model.
To get probabilities at prediction time:
prob = torch.sigmoid(model(x)) # binary
pred = (prob > 0.5).float()Worked Problem · Cross-Entropy From Logits
Problem. A 3-class model outputs raw logits [0.5, 2.5, 0.1] and the true class is 1. Compute the cross-entropy loss.
CrossEntropyLossapplies softmax internally, so you give it raw logits.- e^0.5 = 1.649, e^2.5 = 12.183, e^0.1 = 1.105. Total = 14.936.
- Probability of the true class 1: 12.183 ÷ 14.936 ≈ 0.816.
- Loss = −log(0.816) ≈ 0.204.
import numpy as np
z = np.array([0.5, 2.5, 0.1]); p = np.exp(z) / np.exp(z).sum()
print(round(p[1], 3), round(-np.log(p[1]), 3))0.816 0.204Answer. Probability 0.816, loss 0.204.
Your Turn · The Double-Softmax Mistake
Problem. For logits [2, 1, 0] and true class 0, compare the correct loss with the loss you get if you apply softmax first and then feed the probabilities to the loss as if they were logits.
Try it on paper or in a notebook first. Then check the solution.
Solution — Step By Step
- Correct: softmax of [2, 1, 0] gives [0.665, 0.245, 0.090]. Loss = −log(0.665) ≈ 0.408.
- Mistake: the probabilities [0.665, 0.245, 0.090] go through softmax again. The values are now much closer together, so the true class gets only about 0.45.
- Loss = −log(0.45) ≈ 0.80, so the loss is distorted and the gradients are weaker.
import numpy as np
sm = lambda z: np.exp(z) / np.exp(z).sum()
z = np.array([2., 1., 0.])
print(round(-np.log(sm(z)[0]), 3), round(-np.log(sm(sm(z))[0]), 3))0.408 0.797Answer. Correct 0.408, double softmax 0.797. Give CrossEntropyLoss raw logits.
7. Optimizer
The optimizer applies the gradient-descent update: param -= lr × grad (and smarter variants).
optimizer = torch.optim.SGD(model.parameters(), lr = 0.1)
optimizer = torch.optim.SGD(model.parameters(), lr = 0.1, momentum = 0.9)
optimizer = torch.optim.Adam(model.parameters(), lr = 1e-3)| Optimizer | Idea | When |
|---|---|---|
| SGD | Plain gradient descent on mini-batches | Simple; needs careful lr; often best final generalization for CNNs |
| SGD + m omentu m | Keeps “velocity” like a rolling ball → smoother, faster | Common in vision |
| Adam | Adapts the step size per parameter using gradient history | * Best default (lr = 1e-3) |
| AdamW | Adam with better weight decay | Transformers / LLMs |
Worked Problem · Sgd With Momentum
Problem. A weight is w = 5 and the gradient is 2 on two steps in a row. Learning rate 0.1. Compare plain SGD with momentum 0.9 (velocity v = 0.9·v + grad, then w = w − lr·v).
- Plain SGD, step 1: w = 5 − 0.1×2 = 4.8. Step 2: w = 4.8 − 0.2 = 4.6.
- Momentum, step 1: v = 0.9×0 + 2 = 2, so w = 5 − 0.1×2 = 4.8.
- Momentum, step 2: v = 0.9×2 + 2 = 3.8, so w = 4.8 − 0.1×3.8 = 4.42.
- Momentum builds up speed when the gradient keeps the same sign.
w1 = w2 = 5.0; v = 0.0
for g in (2, 2):
w1 -= 0.1 * g
v = 0.9 * v + g
w2 -= 0.1 * v
print(round(w1, 2), round(w2, 2))4.8 4.8
4.6 4.42Answer. Plain SGD ends at 4.6; momentum ends at 4.42, further along the same direction.
Your Turn · Adam'S First Step
Problem. Adam with lr = 0.001, β1 = 0.9, β2 = 0.999 starts at w = 5 with gradient 2. Compute the first update.
Try it on paper or in a notebook first. Then check the solution.
Solution — Step By Step
- m = (1 − 0.9) × 2 = 0.2, and v = (1 − 0.999) × 2² = 0.004.
- Bias correction: m̂ = 0.2 ÷ (1 − 0.9) = 2 and v̂ = 0.004 ÷ (1 − 0.999) = 4.
- Step = lr × m̂ ÷ (√v̂ + ε) = 0.001 × 2 ÷ 2 = 0.001.
- New w = 5 − 0.001 = 4.999.
import math
g, lr = 2.0, 0.001
m = 0.1 * g; v = 0.001 * g * g
mh = m / (1 - 0.9); vh = v / (1 - 0.999)
print(round(5 - lr * mh / (math.sqrt(vh) + 1e-8), 6))4.999Answer. 4.999. The first Adam step is about the learning rate in size, whatever the gradient's scale.
8. The Training Loop ★★★ (memorize this!)
for epoch in range(epochs):
model.train()
# training mode (enables dropout etc.)
for xb, yb in train_loader:
optimizer.zero_grad() # 1. clear old gradients
logits = model(xb) # 2. forward pass
loss = criterion(logits, yb) # 3. compute loss
loss.backward() # 4. backprop → fills .grad
optimizer.step() # 5. update weightsWhy each line?
| Line | Purpose | If you forget it |
|---|---|---|
| zero_grad() | Gradients accumulate; reset every step | Gradients from old batches pile up → training goes wrong |
| model(xb) | Forward pass | — |
| criterion(...) | Single loss number | — |
| loss.backward() | Compute gradients | Nothing learns |
| optimizer.step( ) | Update parameters | Nothing learns |
Mapping to Days 9–10
| Days 9–10 (NumPy) | Day 11 (PyTorch) |
|---|---|
| forward pass code | model(xb) |
| bce(y, a) | criterion |
| the 8 lines of dW , db | loss.backward() |
| W -= lr * dW | optimizer.step() |
Worked Problem · Count Batches And Steps
Problem. A training set has 1,000 samples and the batch size is 32. How many batches per epoch, and how many optimizer steps in 5 epochs?
- 1,000 ÷ 32 = 31.25, so there are 31 full batches plus one smaller batch of 8 samples.
- That makes 32 batches per epoch. (With
drop_last=Trueit would be 31.) - Each batch is one optimizer step, so 5 epochs give 5 × 32 = 160 steps.
import math
b = math.ceil(1000 / 32)
print(b, 5 * b)32 160Answer. 32 batches per epoch and 160 steps in 5 epochs.
Your Turn · Why Zero_Grad() Matters
Problem. A weight starts at 5, lr = 0.1, and every step's gradient is 2. Compare two steps with the gradient reset each step against two steps where it is never reset (gradients accumulate).
Try it on paper or in a notebook first. Then check the solution.
Solution — Step By Step
- Correct: step 1 w = 5 − 0.1×2 = 4.8, step 2 w = 4.8 − 0.2 = 4.6.
- No reset: PyTorch adds each new gradient to the stored one. Step 1 uses 2, giving w = 4.8. Step 2 uses 2 + 2 = 4, giving w = 4.8 − 0.4 = 4.4.
- The updates grow wrongly bigger every step.
w_ok = w_bad = 5.0; stored = 0.0
for g in (2, 2):
w_ok -= 0.1 * g
stored += g
w_bad -= 0.1 * stored
print(round(w_ok, 2), round(w_bad, 2))4.6 4.4Answer. Correct 4.6, without reset 4.4. Call optimizer.zero_grad() at the start of every step.
Code walkthrough: one PyTorch training step
This walkthrough explains PyTorch code that was not run in the build environment. The matching NumPy version above (Day 10) was run, so use it to check the logic.
for xb, yb in train_loader:
optimizer.zero_grad()
logits = model(xb)
loss = loss_fn(logits, yb)
loss.backward()
optimizer.step()| Line | What it does | What it holds |
|---|---|---|
for xb, yb in train_loader: | Take one batch at a time from the DataLoader | xb = inputs, shape (batch, features); yb = labels, shape (batch,) |
optimizer.zero_grad () | Clear the gradients left over from the previous batch | All .grad values become 0 |
logits = model(xb) | Forward pass: run the batch through the network | Raw scores, shape (batch, number of classes) |
loss = loss_fn(logits, yb) | Compare scores with true labels | One number: the average loss of the batch |
loss.backward() | Backward pass: autograd fills in a gradient for every parameter | Each parameter now has a .grad |
optimizer.step() | Update every parameter using its gradient | Weights changed a little |
Common error: shape mismatch such as mat1 and mat2 shapes cannot be multiplied. Fix: print xb.shape and check that the first nn.Linear expects exactly the number of features per row (for MNIST after flattening: 784).
9. Validation Loop
After each epoch, check performance on unseen (validation) data:
model.eval() # eval mode (turns off dropout)
val_loss, correct, total = 0.0, 0, 0
with torch.no_grad(): # no gradients → faster
for xb, yb in val_loader:
logits = model(xb)
val_loss += criterion(logits, yb).item() * len(xb)
pred = (torch.sigmoid(logits) > 0.5).float()
correct += (pred == yb).sum().item()
total += len(xb)
val_loss /= total ; val_acc = correct / total.item()converts a 1-element tensor into a normal Python number- Always
model.eval()+torch.no_grad()for validation/test, andmodel.train()again for the next epoch
Worked Problem · Average The Validation Loss Correctly
Problem. Validation batches have sizes 4, 4 and 2 with average losses 0.5, 0.4 and 0.9. What is the loss over all 10 samples?
- Each batch loss is an average over its own samples, so weight it by its size.
- Total = 4×0.5 + 4×0.4 + 2×0.9 = 2.0 + 1.6 + 1.8 = 5.4.
- Divide by 10 samples: 0.54.
- The simple mean of the three numbers (0.6) would over-weight the small last batch.
sz = [4, 4, 2]; ls = [0.5, 0.4, 0.9]
print(round(sum(s * l for s, l in zip(sz, ls)) / sum(sz), 3), round(sum(ls) / 3, 3))0.54 0.6Answer. 0.54, not 0.6. In PyTorch, wrap this loop in model.eval() and torch.no_grad() so nothing is updated.
10. ★ Complete Program (copy, run, understand)
import numpy as np, torch, torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
torch.manual_seed(42) ; np.random.seed(42)
# ---- data ----
X, y = make_moons(2000, noise = 0.25, random_state = 42)
X_tr, X_va, y_tr, y_va = train_test_split(X, y, test_size = 0.2, random_state = 42)
to_t = lambda a: torch.tensor(a, dtype = torch.float32)
train_loader = DataLoader(TensorDataset(to_t(X_tr), to_t(y_tr).view( - 1,1)),
batch_size = 32, shuffle = True)
val_loader = DataLoader(TensorDataset(to_t(X_va), to_t(y_va).view( - 1,1)),
batch_size = 256)
# ---- experiment function ----
def run(opt_name, lr, epochs = 60):
model = nn.Sequential(nn.Linear(2,16), nn.ReLU(), nn.Linear(16,1))
criterion = nn.BCEWithLogitsLoss()
opt = (torch.optim.SGD(model.parameters(), lr = lr) if opt_name == "SGD"
else torch.optim.Adam(model.parameters(), lr = lr))
hist = {"train_loss": [], "val_loss": [], "val_acc": []}
for ep in range(epochs):
model.train() ; tl = 0
for xb, yb in train_loader:
opt.zero_grad()
loss = criterion(model(xb), yb)
loss.backward() ; opt.step()
tl += loss.item() * len(xb)
hist["train_loss"].append(tl / len(train_loader.dataset))
model.eval() ; vl = c = 0
with torch.no_grad():
for xb, yb in val_loader:
out = model(xb)
vl += criterion(out, yb).item() * len(xb)
c += ((torch.sigmoid(out) > 0.5).float() == yb).sum().item()
n = len(val_loader.dataset)
hist["val_loss"].append(vl / n) ; hist["val_acc"].append(c / n)
return hist
# ---- experiment: 2 optimizers × 3 learning rates ----
results = {}
for opt_name, lrs in [("SGD", [0.001, 0.01, 0.1]), ("Adam", [0.0001, 0.001, 0.01])]:
for lr in lrs:
results[(opt_name, lr)] = run(opt_name, lr)
h = results[(opt_name, lr)]
print(f" { opt_name :5s} lr= { lr :<7} final val_loss= "
f"{ h['val_loss'][ - 1] :.3f} val_acc= { h['val_acc'][ - 1] :.3f} ")Plot results:
import matplotlib.pyplot as plt
fig, ax = plt.subplots(1, 2, figsize = (12, 4))
for (o, lr), h in results.items():
ax[0].plot(h["train_loss"], label = f" { o } { lr } ")
ax[1].plot(h["val_loss"], label = f" { o } { lr } ")
ax[0].set_title("Train loss") ; ax[1].set_title("Val loss") ; ax[0].legend() ; \
plt.show()What you should observe (write in README)
- SGD with tiny lr (0.001): barely learns in 60 epochs
- SGD lr 0.1: learns well
- Adam lr 0.001–0.01: converges quickly, robust
- Adam lr 0.0001: slow
- Lesson: Adam is forgiving; SGD is sensitive to learning rate.
Record your numbers in a table:
Optimizer LR Final train loss Final val loss Val acc
SGD → Adam
11. Saving and Loading Models
torch.save(model.state_dict(), "model.pt")
# save weights only (recommended)
model = Net() ; model.load_state_dict(torch.load("model.pt")) ; model.eval()Worked Problem · What A Saved Model Contains
Problem. A model's “state” is just a dictionary from names to numbers. Save a tiny state to a file and load it back.
- PyTorch saves
model.state_dict(), a dictionary of parameter names to tensors. - To save:
torch.save(model.state_dict(), "model.pt"). To load: create the same model, thenmodel.load_state_dict(torch.load("model.pt")). - The idea is the same as saving any dictionary. Here is the idea with plain Python.
import json
state = {"w": 2.0, "b": 1.0}
json.dump(state, open("model.json", "w"))
print(json.load(open("model.json")) == state)TrueAnswer. True: the loaded state equals the saved one. (The torch lines above were not run in the build; try them in your notebook.)
12. Running on GPU (pattern to memorize)
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device) # move data each batchModel and data must be on the same device, or you get “Expected all tensors to be on the same device”.
13. Common Errors
| Error | Cause / fix |
|---|---|
| mat1 and mat2 shapes cannot be multiplied | Layer input size doesn’t match data features |
| Expected scalar type Float but found Double/Long | .float() .long() Convert: or |
| Target size (B) must be the same as input size (B,1) | y.view(-1,1) for BCE loss |
| Loss doesn’t change | Forgot optimizer.step() or optimizer built on wrong params |
| Loss explodes / NaN | lr too high |
| Training gets weird over time | Forgot zero_grad() |
| Val results random each epoch | Forgot model.eval() (dropout on) |
| CrossEntropyLoss bad results | Added softmax in the model or labels long not |
| RuntimeError: element 0 of tensors does not | Tensor was computed under no_grad or detached |
require grad
14. Practice Questions
- What does
zero_grad()do and why is it needed? - Difference between
model.train()andmodel.eval()? - Why does
BCEWithLogitsLosswant raw logits? - 1000 samples, batch_size=50: how many weight updates per epoch? → 20
- Why shuffle only the training loader?
- What does
torch.no_grad()do? - Match:
loss.backward()↔ which Days 9–10 code?
15. Quick Summary
- Tensors = GPU-capable arrays with autograd
- Dataset (one sample) + DataLoader (batches/shuffle)
nn.Modulewith__init__(layers) andforward(flow)- Loss + optimizer (Adam default)
- Loop:
zero_grad → forward → loss → backward → step - Validation:
eval()+no_grad()
Next: Day 12 — MNIST with MLP and CNN
⚡ Quick Recap — Day 11
7.1 Learning Order
Tensor → Dataset → DataLoader → Model → Loss → Optimizer → Training Loop → Validation
7.2 Tensor
A tensor = NumPy array that can run on GPU and track gradients.
import torch
x = torch.tensor([[1., 2.], [3., 4.]])
x.shape, x.dtype, x.device
torch.zeros(2,3) ; torch.randn(2,3)
x @ x.T
x.to("cuda") # move to GPU
torch.from_numpy(arr) ; t.numpy() # NumPy bridge
w = torch.tensor(2.0, requires_grad = True)
loss = (w * 3 - 1) ** 2
loss.backward() # computes gradient
w.grad # dL/dwAutograd = PyTorch automatically does backpropagation for you.
7.3 Dataset & DataLoader
- Dataset: knows how to get one sample.
- DataLoader: batches, shuffles, loads in parallel.
from torch.utils.data import Dataset, DataLoader
class MoonsDataset(Dataset):
def __init__ (self, X, y):
self.X = torch.tensor(X, dtype = torch.float32)
self.y = torch.tensor(y, dtype = torch.float32).view( - 1, 1)
def __len__ (self): return len(self.X)
def __getitem__ (self, i): return self.X[i], self.y[i]
train_loader = DataLoader(MoonsDataset(X_tr, y_tr), batch_size = 32, shuffle = True)
val_loader = DataLoader(MoonsDataset(X_val, y_val), batch_size = 64)Shuffle train only.
7.4 Model — nn.Module
import torch.nn as nn
class Net(nn.Module):
def __init__ (self):
super(). __init__ ()
self.layers = nn.Sequential(
nn.Linear(2, 16), nn.ReLU(),
nn.Linear(16, 1) # raw logits (no sigmoid)
)
def forward(self, x): # defines forward pass
return self.layers(x)
model = Net()forward()= how data flows (callmodel(x), nevermodel.forward(x)).- Backward is automatic via autograd.
7.5 Loss & Optimizer
criterion = nn.BCEWithLogitsLoss() # sigmoid + BCE, numerically stable
# Multi-class: nn.CrossEntropyLoss() (takes raw logits, integer labels)
# Regression: nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr = 1e-3)| Optimizer | Notes |
|---|---|
| SGD | Simple; add momentum=0.9 ; needs lr tuning |
| Adam | Adaptive per-parameter lr; great default (lr 1e-3) |
7.6 Training Loop ★
for epoch in range(50):
model.train()
for xb, yb in train_loader:
optimizer.zero_grad() # 1. clear old gradients
logits = model(xb) # 2. forward
loss = criterion(logits, yb) # 3. loss
loss.backward() # 4. backprop
optimizer.step() # 5. update weights
# validation
model.eval()
with torch.no_grad(): # no gradients needed
val_loss = sum(criterion(model(xb), yb).item() for xb, yb in val_loader) / \
len(val_loader)
print(epoch, loss.item(), val_loss)Why each call matters
| Call | Purpose |
|---|---|
| zero_grad() | PyTorch accumulates gradients — must reset each step |
| backward() | Compute gradients |
| step() | Apply the update |
| model.train()/eval() | Toggles dropout/batchnorm behavior |
| torch.no_grad() | Saves memory/speed in validation |
7.7 Assignment Tips
- Rebuild Days 9–10 NN in PyTorch → results should match roughly.
- Experiment grid: {SGD, Adam} × {lr = 0.1, 0.01, 0.001} = 6 runs. Log train/val loss for each; plot together.
- Typical finding: Adam converges faster; SGD needs higher lr; too-high lr is unstable.
- Save/load:
torch.save(model.state_dict(), "m.pt"). - Device pattern:
device = "cuda" if torch.cuda.is_available() else "cpu".
Beginner checkpoint — Day 11 ■ Explain the day’s main idea without using the textbook wording. ■ Write one tiny example from memory. ■ Identify one common failure mode. ■ Explain how today’s topic connects to the next day. ■ Write one question you still cannot answer.
Move on when you can… ☐ create tensors and read their shapes ☐ explain what autograd computes ☐ write a small nn.Module ☐ put the steps of a training step in order ☐ explain train() versus eval() and why zero_grad() is needed
If you cannot tick most of these, repeat the worked problems for this day. Taking an extra day is fine; it is not falling behind.