Python Lesson 50 of 71

Your First Deep Learning Models with PyTorch (and Keras)

In the previous lesson you did something most people who use deep learning have never done: you built a neural network from nothing but NumPy arrays. You wrote the forward pass, and then — the hard part — you wrote the backward pass by hand, deriving ∂loss/∂w with the chain rule, one layer at a time, and updating the weights yourself. It worked. It also took a page of careful calculus that would become unmaintainable the moment the network grew past two layers or you swapped the loss function.

That hand-written backprop is the exact thing a deep learning framework exists to delete. This lesson takes the same job — classify data by training a small network — and hands it to PyTorch, the framework that runs most of modern AI research and a large share of production. You will see that PyTorch does not do anything magic: it does the specific, tedious, error-prone thing you just did by hand — computing gradients — automatically, correctly, for any network you can dream up, on a GPU if you have one. Everything else it gives you (prebuilt layers, optimizers, data loaders) is convenience on top of that one superpower.

Everything below was executed on CPython 3.12.3 with PyTorch 2.13.0, scikit-learn 1.9.0, and NumPy 2.5.1; the Keras half ran on TensorFlow 2.21.0 / Keras 3.15.0. Every loss curve, accuracy number, and traceback on this page is copied from a real run — including the deliberately broken one where the loss climbs to 3.4 million. If you run the code, you will get these numbers (or numbers a hair away, since some randomness survives even a fixed seed).


Why this matters: what a framework actually automates

Here is the mental model that makes PyTorch click. Your hand-built net had three moving parts: a forward pass (turn inputs into a prediction), a loss (score how wrong the prediction is), and a backward pass (compute how to nudge every weight to make the loss smaller). The forward pass and the loss are easy — they are just arithmetic you write once. The backward pass is where people drown: every new layer, activation, or loss means re-deriving gradients, and one sign error silently ruins training with no traceback to tell you.

A framework’s headline feature is automatic differentiation (autograd): you write only the forward pass, and the framework watches every operation you perform, builds a graph of them, and can then compute the exact gradient of the output with respect to every input — automatically, by mechanically applying the chain rule you applied by hand. You get correct backprop for free, for arbitrary networks. That single feature is why frameworks won.

Autograd is the reason to switch, but not the only thing you get. Four more come in the box:

What you did by hand in NumPy What the framework gives you Why it matters
Derived ∂loss/∂w with the chain rule Autogradloss.backward() fills every gradient No manual calculus; correct for any network
Ran on CPU, one core, float64 GPU/accelerator.to("cuda")/"mps" 10–100× faster training on real data
Wrote w @ x + b, ReLU, softmax yourself Prebuilt layersnn.Linear, nn.ReLU, … Tested, fast, composable building blocks
Coded the weight update w -= lr * grad OptimizersSGD, Adam, AdamW Adaptive learning rates, momentum, decay — one line
Wrote your own MSE / cross-entropy LossesCrossEntropyLoss, MSELoss Numerically stable, edge-cases handled

It is worth being precise about which line of your hand-built net disappears, because that is the whole pitch. Your NumPy net had a forward pass — matrix multiplies, a ReLU, a softmax — and you kept every one of those. What you delete is the mirror-image block underneath it: the manual d_loss, d_w2, d_b2, d_hidden, d_w1 derivations, each a hand-applied chain-rule step that had to be re-derived the instant you changed a layer or a loss. In PyTorch that entire block becomes the single call loss.backward(). You describe only the forward computation — the thing you actually understand — and the gradients come for free, correct, for any architecture. That is not a convenience; it is the difference between networks you can hand-differentiate (two or three layers) and the ones that run the world (dozens to hundreds).

The second reason is scale through hardware. Your NumPy net ran on the CPU in float64, one operation at a time. A framework tensor moves to a GPU with .to("cuda") and the same matrix multiplies run across thousands of GPU cores in float32, the format the hardware is built for — routinely 10–100× faster on real networks, which is the difference between an experiment that takes an hour and one that takes a week. We force CPU in this lesson because the model is tiny and reproducibility matters more than speed, but the code is GPU-ready by design: write it once, move a device, and it scales.

There is also the ecosystem, which is half the reason a framework is worth learning: pretrained models, datasets, tutorials, and higher-level libraries (PyTorch Lightning, Hugging Face, fastai) all assume you speak PyTorch or Keras. Learn the framework and the entire modern-AI toolbox opens; stay in hand-rolled NumPy and you are alone.

One honest caveat before you fall in love, in the spirit of the ML fundamentals lesson’s “when not to use ML”: a neural network is not the default answer to a prediction problem. On small-to-medium tabular data — the spreadsheets most businesses actually have — a gradient-boosted tree (XGBoost, LightGBM) or even a plain scikit-learn RandomForest usually matches or beats a neural net, trains in seconds instead of minutes, needs no GPU, and requires almost no tuning. Deep learning earns its complexity on unstructured, high-dimensional data where features must be learned rather than engineered — images, audio, text, and other signals with spatial or sequential structure. Reach for PyTorch when the problem is genuinely that shape (or when you are learning, as here); reach for a tree when someone hands you a CSV of 5,000 rows and 20 columns. Using the biggest hammer in the shed on every nail is the most common beginner mistake in applied ML.

This lesson assumes you are comfortable with the ideas underneath: that a model learns a function from data (ML Fundamentals), that a fair score comes only from held-out data (Train/Test Splits & Metrics), and that everything numeric is a NumPy array under the hood. If fit/predict on a scikit-learn estimator is already reflex, you are perfectly placed — a framework is what you reach for when fit/predict is not flexible enough and you need to define the model yourself.

Set up a virtual environment — never install these heavy packages into the system Python:

python3 -m venv .venv
source .venv/bin/activate                 # Windows: .venv\Scripts\activate
python -m pip install --upgrade pip

# scikit-learn ships the digits dataset (no download) + preprocessing
pip install scikit-learn numpy

# PyTorch CPU-only build (~150 MB; the GPU builds are much larger).
# The CPU wheel runs everywhere and is all you need for this lesson.
pip install torch --index-url https://download.pytorch.org/whl/cpu

# Optional: the Keras half at the end needs TensorFlow
pip install tensorflow

python -c "import torch; print('torch', torch.__version__)"
# => torch 2.13.0

⚠️ The full CUDA PyTorch build is several gigabytes and only helps if you own an NVIDIA GPU. For learning — and for the tiny model in this lesson — the CPU wheel above is the right choice and trains in seconds. On Apple Silicon the standard wheel also gives you the mps accelerator for free.


Tensors: NumPy arrays that carry a gradient and a device

The atom of PyTorch is the tensor. If you know NumPy’s ndarray, you already know 90% of it: a tensor is an n-dimensional array of numbers of one dtype, with a shape and strides, supporting the same broadcasting and indexing. The 10% that is new — and the entire reason tensors exist rather than reusing NumPy — is two extra powers bolted onto that array:

  1. A tensor can track gradients (requires_grad=True), so autograd can differentiate through it.
  2. A tensor lives on a device (cpu, cuda, mps), so the same code runs on a GPU by moving the data.
NumPy ndarray PyTorch Tensor
n-D array, one dtype, broadcasting Yes Yes (nearly identical API)
Default float dtype float64 float32 (what GPUs like)
Runs on GPU No Yes — .to("cuda") / "mps"
Tracks gradients for autograd No Yes — requires_grad=True
Element-wise add a + b a + b
Matrix multiply a @ b a @ b
Reshape / transpose .reshape / .T .reshape / .T (same)
Convert to the other .numpy()torch.from_numpy()
import torch
import numpy as np

t = torch.tensor([[1.0, 2.0],
                  [3.0, 4.0]])
print(t)
# tensor([[1., 2.],
#         [3., 4.]])
print(t.shape, t.dtype, t.device)   # torch.Size([2, 2]) torch.float32 cpu

Two dtype facts bite beginners immediately. First, PyTorch defaults to float32, not NumPy’s float64 — GPUs are built for 32-bit floats, and using 64-bit doubles your memory for no accuracy the model needs. Second, integer labels for classification must be int64 (called long), because the loss function indexes into a probability table with them. Get a dtype wrong and you meet a RuntimeError (we trigger the real ones later).

The NumPy ⇄ tensor bridge (and the shared-memory trap)

You constantly cross between NumPy (for loading, sklearn, plotting) and tensors (for the model). Two functions do it, and there is one sharp edge: torch.from_numpy and .numpy() share the same memory buffer — they do not copy. Mutating one mutates the other.

a = np.array([1.0, 2.0, 3.0])
tb = torch.from_numpy(a)     # tensor VIEWS the same buffer as `a`
print(tb)                    # tensor([1., 2., 3.], dtype=torch.float64)

a[0] = 99.0                  # mutate the NumPy array...
print(tb)                    # ...and the tensor sees it:
                             # tensor([99.,  2.,  3.], dtype=torch.float64)

back = tb.numpy()            # back to NumPy — also a shared view
print(type(back))            # <class 'numpy.ndarray'>
Task Call Copies? Note
NumPy → tensor torch.from_numpy(a) No — shared buffer Keeps a’s dtype (often float64)
NumPy → tensor (fresh) torch.tensor(a) Yes — safe copy Use when you don’t want aliasing
Tensor → NumPy t.numpy() No — shared buffer Fails if t requires grad (see autograd)
Tensor → NumPy (safe) t.detach().cpu().numpy() Yes The idiom for grabbing model outputs
Set dtype on the way in torch.tensor(a, dtype=torch.float32) Yes The usual fix for the float64 surprise

Notice from_numpy preserved float64. When you feed data to a model you almost always want torch.tensor(a, dtype=torch.float32) — building a fresh float32 copy in one step and dodging both the dtype mismatch and the aliasing surprise.

Tensor operations you will use constantly

Beyond arithmetic, a handful of shape-and-reduce operations show up in every model. They mirror NumPy, with one PyTorch-specific pair — view and reshape — worth distinguishing.

t = torch.arange(6.)                 # tensor([0., 1., 2., 3., 4., 5.])
print(t.reshape(2, 3))               # [[0,1,2],[3,4,5]]
print(t.view(3, 2))                  # same data, shape (3,2)  — view needs contiguous memory
print(t.unsqueeze(0).shape)          # torch.Size([1, 6])  — add a batch axis
print(torch.zeros(1, 6).squeeze().shape)  # torch.Size([6]) — drop size-1 axes

a = torch.tensor([[1., 2.], [3., 4.]])
print(a.mean().item(), a.mean(dim=0).tolist(), a.sum(dim=1).tolist())
# 2.5 [2.0, 3.0] [3.0, 7.0]
print(a.argmax(dim=1).tolist())      # [1, 1] — index of the max per row
Operation Call Result / use
Reshape t.reshape(2, 3) New shape, copies if needed
Reshape (no copy) t.view(3, 2) Same buffer; requires contiguous memory
Add an axis t.unsqueeze(0) (6,)(1, 6) — make a single row a “batch of 1”
Drop size-1 axes t.squeeze() (1, 6)(6,)
Reduce all t.mean(), t.sum() Scalar
Reduce one axis t.mean(dim=0) Collapse rows → per-column mean
Concatenate torch.cat([a, b], dim=0) Join along an existing axis
Stack torch.stack([a, b]) Join along a new axis
Top class logits.argmax(dim=1) Index of the max per row — this is prediction

unsqueeze(0) earns special mention: models expect a batch dimension, so a single 64-pixel image (shape (64,)) must become (1, 64) before you can pass it to the model. Forgetting the batch axis is a frequent shape error at inference time.


Autograd: the framework does your backprop for you

This is the heart of the lesson, so we prove it rather than assert it. In the previous lesson you differentiated a loss by hand. Here PyTorch does the identical derivation automatically, and we check that the number it produces equals the one you would compute by hand.

Set requires_grad=True on a tensor and PyTorch starts recording every operation you perform on it into a dynamic computational graph — a record of “this tensor came from multiplying those two.” Call .backward() on the final scalar and autograd walks that graph in reverse, applying the chain rule, depositing ∂output/∂x into each input’s .grad.

Start with the simplest possible check: the derivative of f(x) = x² is 2x, which is 6 at x = 3.

import torch

x = torch.tensor(3.0, requires_grad=True)   # "track gradients for x"
f = x ** 2                                    # graph: f = x*x
f.backward()                                  # autograd walks it backward
print(x.grad)      # tensor(6.)   <- exactly 2*x at x=3. No calculus written.

Now the version that matters — a single linear neuron with a squared-error loss, the exact shape of the backprop you hand-derived. With weight w, bias b, input x, target t:

w = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(0.5, requires_grad=True)
x = torch.tensor(1.5)
target = torch.tensor(5.0)

pred = w * x + b            # 2*1.5 + 0.5 = 3.5
loss = (pred - target) ** 2 # (3.5 - 5.0)**2 = 2.25
loss.backward()             # fill w.grad and b.grad automatically

print(pred.item(), loss.item())   # 3.5 2.25
print(w.grad.item())              # -4.5   hand: 2*(3.5-5)*1.5 = -4.5  ✔
print(b.grad.item())              # -3.0   hand: 2*(3.5-5)     = -3.0  ✔

The gradients autograd computed (-4.5, -3.0) are identical to the hand-derived formulas. That is the whole promise made concrete: you wrote the forward pass (w * x + b and the squared error) and nothing else; PyTorch produced the exact gradients that, last lesson, cost you a page of chain-rule algebra. Scale this from one neuron to a million and the value is obvious — you could never hand-derive backprop for a real network, and you never have to.

You can even see the graph autograd built. Chain two operations — y = 3x, then z = y², so z = 9x² and dz/dx = 18x — and each intermediate tensor carries a grad_fn recording the operation that made it:

x = torch.tensor(2.0, requires_grad=True)
y = 3 * x                 # y.grad_fn -> <MulBackward0>
z = y ** 2                # z.grad_fn -> <PowBackward0>
print(z.grad_fn)          # <PowBackward0 object at 0x...>
z.backward()
print(x.grad.item())      # 36.0   <- 18 * x at x=2, via the chain rule

Autograd walked z → y → x backward, multiplying local derivatives (dz/dy = 2y = 12, then dy/dx = 3) to get 12 × 3 = 36 — exactly the chain rule, executed for you. That linked list of grad_fns is the computational graph, and it is rebuilt from scratch on every forward pass, which is why ordinary Python control flow inside a model just works.

Concept What it is Call / attribute
Track a tensor Ask autograd to record ops on it requires_grad=True
The graph Dynamic record of operations, rebuilt every forward pass (implicit) .grad_fn
Backprop Walk the graph in reverse, apply chain rule loss.backward()
The result Gradient of the loss w.r.t. each parameter param.grad
Turn it off Stop recording (inference, no training) with torch.no_grad():
Snapshot without grad Detach a tensor from the graph t.detach()
Gradients accumulate Each .backward() adds into .grad (this is the famous bug)

Two details you must internalize. First, the graph is dynamic — it is built fresh on every forward pass, so you can use normal Python if/for/while inside a model and the graph simply reflects whatever ran. (This is PyTorch’s “define-by-run” model, and the reason it feels like ordinary Python.) Second — burn this in — .backward() adds to .grad, it does not overwrite it. Call backward twice without clearing, and the second gradient piles on top of the first. That accumulation is occasionally useful, but forgetting to clear it is the single most common bug in all of PyTorch, and we will detonate it on purpose later.

For pure inference you do not want the graph at all — building it wastes memory and time when you will never call .backward(). Wrap inference in torch.no_grad():

with torch.no_grad():
    y = w * x + b
print(y.requires_grad)   # False  — no graph built, faster + leaner

This is not a micro-optimization. To be able to run backward(), the graph must keep every intermediate value from the forward pass in memory (it needs them to compute the local derivatives). On a large model over a large batch, those stored activations are often the biggest single consumer of GPU memory — and at inference you will never call backward(), so keeping them is pure waste. torch.no_grad() tells PyTorch “don’t record, don’t retain,” which is why every correct inference and evaluation path in this lesson is wrapped in it. During training you do need the graph, which is the honest reason training a given model needs far more memory than serving it.


Building a model: nn.Module

You could keep individual w and b tensors and wire them by hand, but real models have thousands of parameters across many layers. PyTorch packages a model as a subclass of nn.Module: you declare the layers in __init__, and you describe how data flows through them in forward. The base class then tracks every parameter for you (so the optimizer can find them), handles moving the whole model to a device, and toggles train/eval mode.

The layers themselves are also nn.Modules. The three you need for a basic classifier:

Layer What it does Key arguments
nn.Linear(in, out) Fully-connected layer: x @ Wᵀ + b in_features, out_features
nn.ReLU() Activation: max(0, x), adds non-linearity (none)
nn.Dropout(p) Randomly zeroes a fraction p of activations in training only p (drop probability)
nn.Sequential(...) Chains modules; its forward runs them in order the layers, in order
nn.Flatten() Collapses trailing dims (e.g. image → vector) (none)

Here is a small multilayer perceptron (MLP) for the 8×8 digit images we will classify: 64 input pixels → a hidden layer of 32 with ReLU → dropout → 10 output scores (one per digit).

import torch.nn as nn

class DigitMLP(nn.Module):
    def __init__(self, in_features=64, hidden=32, classes=10, p_drop=0.2):
        super().__init__()                      # ALWAYS call this first
        self.net = nn.Sequential(
            nn.Linear(in_features, hidden),     # 64 -> 32
            nn.ReLU(),
            nn.Dropout(p_drop),                 # regularization
            nn.Linear(hidden, classes),         # 32 -> 10  (raw scores)
        )

    def forward(self, x):
        return self.net(x)                      # define-by-run: just call it

model = DigitMLP()
print(model)
# DigitMLP(
#   (net): Sequential(
#     (0): Linear(in_features=64, out_features=32, bias=True)
#     (1): ReLU()
#     (2): Dropout(p=0.2, inplace=False)
#     (3): Linear(in_features=32, out_features=10, bias=True)
#   )
# )
print(sum(p.numel() for p in model.parameters()))   # 2410 trainable params

Three rules that save hours. One: call super().__init__() as the first line of your __init__, or PyTorch cannot register your parameters and the optimizer will silently train nothing. Two: the last layer outputs raw scores called logits — do not put a softmax there. The standard classification loss expects logits and applies softmax internally, in a numerically stable way (adding your own softmax makes the loss wrong, as we will demonstrate). Three: you never call model.forward(x) directly — you call model(x), which runs forward plus the framework’s bookkeeping (hooks, train/eval state). The 2,410 parameters are 64×32 + 32 (first layer) + 32×10 + 10 (second) — every one of which autograd will differentiate for you.

The nn.ReLU() between the two linear layers is the activation function, and it is not optional: two linear layers with nothing between them collapse mathematically into a single linear layer, so the network could only ever learn straight-line relationships. A non-linear activation is what lets a network bend to fit complex patterns. ReLU (max(0, x)) is the default because it is cheap and trains well; the others have their niches.

Activation Formula Range Use for x=[-2, 0, 1, 3]
ReLU max(0, x) [0, ∞) Hidden layers (default) [0, 0, 1, 3]
Sigmoid 1/(1+e⁻ˣ) (0, 1) Binary output probability [0.12, 0.5, 0.73, 0.95]
Tanh tanh(x) (−1, 1) Older hidden layers, RNNs [−0.96, 0, 0.76, 0.995]
Softmax eˣⁱ/Σeˣ sums to 1 Multi-class output (probabilities) [0.006, 0.041, 0.111, 0.818]
GELU smooth ReLU ≈[0, ∞) Transformers

You can inspect exactly what the base class registered. model.parameters() is what you hand the optimizer; named_parameters() shows you the tree, and state_dict() is what you save:

for name, p in model.named_parameters():
    print(name, tuple(p.shape), p.requires_grad)
# net.0.weight (32, 64) True     net.0.bias (32,) True
# net.3.weight (10, 32) True     net.3.bias (10,) True
Accessor Returns Used for
model.parameters() Iterator of weight tensors Handed to the optimizer
model.named_parameters() (name, tensor) pairs Inspection, freezing specific layers
model.state_dict() OrderedDict of all weights Saving / loading
p.numel() Element count of a tensor Counting total parameters
p.requires_grad = False Freeze a parameter Transfer learning / fine-tuning

Feeding data: Dataset and DataLoader

You almost never train on the whole dataset at once — you train on mini-batches (say 64 rows at a time), because batches fit in memory, give a smoother gradient than one row, and let the GPU parallelize. PyTorch splits this into two objects: a Dataset knows how to fetch one example (dataset[i] → (features, label)), and a DataLoader wraps a dataset to yield shuffled batches.

For in-memory arrays, TensorDataset is the shortcut — hand it feature and label tensors and it pairs them up:

from torch.utils.data import TensorDataset, DataLoader

# Xtr, ytr are tensors: features float32, labels int64 (long)
train_ds = TensorDataset(Xtr, ytr)
train_dl = DataLoader(train_ds, batch_size=64, shuffle=True)

print(len(train_dl))                 # 23   (1437 rows / 64 ≈ 23 batches)
xb, yb = next(iter(train_dl))
print(xb.shape, yb.shape)            # torch.Size([64, 64]) torch.Size([64])
DataLoader argument What it controls Typical value
batch_size Rows per batch 32 / 64 / 128
shuffle Reshuffle every epoch (train only!) True for train, False for val/test
num_workers Background loading processes 0 on CPU/small, 2–8 for big data
drop_last Drop the final undersized batch False usually
pin_memory Faster CPU→GPU transfer True when training on CUDA

shuffle=True matters more than it looks: if your data is sorted by label, un-shuffled batches show the model all the 0s, then all the 1s, and each batch’s gradient yanks the weights toward whichever class it contains. Shuffle for training, never for validation or test (there is no gradient there, and you want reproducible evaluation).

Batch size is a real knob, not a formality: it sets how many batches (and therefore how many weight updates) you get per epoch. Our 1,437 training rows split like this:

batch_size Batches / epoch Trade-off
32 45 More updates/epoch, noisier gradients — often generalizes well
64 23 The common default; a good balance
128 12 Smoother gradient, fewer updates, more memory
256 6 Fast per epoch but may need more epochs / higher LR

Smaller batches give noisier but more frequent gradient steps (the noise can even help escape bad minima); larger batches give a smoother gradient and use the hardware better but need more memory and sometimes a higher learning rate to converge in the same number of epochs. When in doubt, 32 or 64.


The canonical training loop

Everything so far converges here. The PyTorch training loop is five lines that every practitioner memorizes, run once per batch, wrapped in a loop over epochs. Learn these five and their order and you can train any PyTorch model on earth.

for epoch in range(EPOCHS):
    model.train()                        # training mode (dropout ON)
    for xb, yb in train_dl:              # one mini-batch
        optimizer.zero_grad()            # 1. clear last batch's gradients
        pred = model(xb)                 # 2. forward pass (builds the graph)
        loss = criterion(pred, yb)       # 3. how wrong are we?
        loss.backward()                  # 4. autograd fills every .grad
        optimizer.step()                 # 5. optimizer nudges the weights

Read them as a sentence: clear the old gradients, predict, score, back-propagate, step. The diagram below traces one trip through the loop and marks the exact point the classic bug bites.

PyTorch training loop as a left-to-right call order: a DataLoader yields an (x,y) mini-batch, then five ordered steps — zero_grad clears old gradients, model(x) runs the forward pass and builds the autograd graph, the criterion computes the loss, loss.backward fills every parameter's .grad by autograd, and optimizer.step updates the weights — then the strip repeats for the next batch, with a red marker showing where a missing zero_grad makes gradients accumulate and the loss diverge

Underneath those five lines is one idea: gradient descent. The loss is a landscape over the millions of weight values, and the gradient param.grad points in the direction of steepest increase of the loss. So to reduce the loss you step the opposite way — w = w - lr * grad — a little downhill, over and over. backward() computes which way is downhill for every weight at once; step() takes the step; the learning rate lr sets how big. Run that a few thousand times over shuffled batches and the weights settle into a valley where the loss is low — which is to say, the model has learned. Every framework, every optimizer, every architecture is a variation on this single loop; internalize it and deep learning stops being mysterious.

The one line beginners drop is optimizer.zero_grad(), and dropping it is catastrophic precisely because — as you saw with autograd — .backward() adds to .grad. Without the clear, batch 2 trains on grad(batch1) + grad(batch2), batch 3 on the sum of three, and the effective step size explodes. We will watch that happen for real in the lab.

Line Call What happens Skip it and…
1 optimizer.zero_grad() Reset every .grad to zero Gradients accumulate → loss diverges
2 pred = model(x) Forward pass; records the autograd graph Nothing to differentiate
3 loss = criterion(pred, y) Reduce the batch to one scalar loss No signal to minimize
4 loss.backward() Autograd fills param.grad for all params .grad stays None; step does nothing
5 optimizer.step() Update weights using .grad Weights never change; no learning

Losses and optimizers

The loss (criterion) measures wrongness; the optimizer decides how to move the weights to reduce it. You pick one of each off the shelf.

Loss Use for Feed it Gotcha
nn.CrossEntropyLoss Multi-class classification logits + integer class indices (long) Applies softmax internally — never pre-softmax
nn.BCEWithLogitsLoss Binary / multi-label logits + float 0/1 targets Also stable; don’t add sigmoid yourself
nn.MSELoss Regression prediction + float target Same shape both sides
nn.L1Loss Regression, robust to outliers prediction + float target Mean absolute error

Why these pairings? Classification asks “which of these classes,” and cross-entropy measures how much probability mass the model put on the right class — punishing confident wrong answers far more than hesitant ones, which is exactly the pressure you want. Regression asks “what number,” and MSE measures squared distance from the true value — punishing big misses quadratically. Matching the loss to the question is not a formality; a regression loss on a classification problem gives the optimizer the wrong thing to minimize.

CrossEntropyLoss deserves the emphasis it gets: it wants raw logits and integer class labels, and it fuses LogSoftmax + NLLLoss for numerical stability. Two mistakes flow from misunderstanding this — feeding it your own softmax output (it silently double-counts and gives a wrong number), or feeding float/one-hot labels where it wants integer indices (a RuntimeError). Both appear in the troubleshooting table with their real messages.

Optimizer Idea When to reach for it
torch.optim.SGD w -= lr * grad (+ optional momentum) Simple, well-understood; strong with a tuned LR + momentum
torch.optim.Adam Per-parameter adaptive learning rate The reliable default — works out of the box
torch.optim.AdamW Adam with correct weight decay Modern default for transformers / big nets
torch.optim.RMSprop Adaptive, older Occasionally in RL / RNNs

SGD vs Adam in one breath: SGD applies the raw gradient scaled by one global learning rate, so it is sensitive to that LR but converges to clean solutions; Adam keeps a per-parameter running estimate of the gradient’s size and normalizes by it, so it trains fast and forgives a badly-chosen LR. Start with Adam(model.parameters(), lr=1e-3) — it is the “just works” choice — and only reach for tuned SGD when you are chasing the last fraction of accuracy. (There is a subtle consequence we will see: Adam’s normalization partly masks the missing-zero_grad bug, which is why we demonstrate that bug with SGD, where it is unmistakable.)

Device: CPU, CUDA, or Apple’s MPS

A tensor and a model each live on a device, and they must be on the same one to interact. The portable idiom detects the best available accelerator and falls back to CPU:

device = torch.device(
    "cuda" if torch.cuda.is_available()
    else "mps" if torch.backends.mps.is_available()   # Apple Silicon
    else "cpu")

model = model.to(device)
# inside the loop, move each batch too:
xb, yb = xb.to(device), yb.to(device)
Device Hardware Select with
cpu Any machine always available
cuda NVIDIA GPU torch.cuda.is_available()
mps Apple Silicon GPU torch.backends.mps.is_available()

The rule that trips everyone: model and data must share a device. A model on the GPU fed a CPU tensor raises RuntimeError: Tensor for argument input is on cpu but expected on mps (or cuda). Move both. For this lesson’s numbers I forced CPU for reproducibility, but the detection code above is what you ship.

The knobs you will actually turn

A model has two kinds of numbers: parameters, which training learns (the weights), and hyperparameters, which you choose before training. These five are the ones you tune first.

Hyperparameter Controls Typical Symptom if wrong
Learning rate (lr) Step size per update 1e-3 (Adam) Too high → loss NaN; too low → crawls
Batch size Rows per update 32 / 64 Too big → out of memory; affects noise
Epochs Passes over the data 10–100 Too few → underfit; too many → overfit
Hidden size Model capacity problem-dependent Too small → underfit; too big → overfit/slow
Dropout p / weight_decay Regularization strength 0.1–0.5 / 1e-2 Too much → underfit; too little → overfit

The learning rate is the one that most often decides success or failure — it is worth trying 1e-2, 1e-3, 1e-4 and watching which makes the loss fall fastest without blowing up.

Finally, reproducibility: neural nets are full of randomness (weight initialization, dropout, shuffling), so two runs differ unless you seed the generators. torch.manual_seed(0) before you build the model makes initialization deterministic — same seed, same starting weights, verified:

torch.manual_seed(0); a = nn.Linear(3, 3).weight.clone()
torch.manual_seed(0); b = nn.Linear(3, 3).weight.clone()
print(torch.equal(a, b))    # True — identical init

Seed at the top of your script for repeatable experiments; expect tiny residual differences across machines or PyTorch versions (different math libraries round differently), which is why the numbers on this page may land a hair from yours.


Train vs eval mode, and saving your work

A model has two modes, and using the wrong one at inference is a silent, insidious bug. model.train() and model.eval() flip a flag that changes how dropout and batch-norm behave:

You can see the difference. Running the same input through the model five times in train() mode gives five different outputs (dropout fires differently each time); in eval() mode it is identical every time:

model.train()
# std of the logits across 5 forward passes of the SAME input:
#   0.1118   <- dropout randomizes each pass
model.eval()
#   0.000000 <- deterministic; dropout is off

For inference you want both eval() (correct layer behavior) and torch.no_grad() (skip the graph — faster, less memory). Forgetting eval() means dropout randomly corrupts your predictions; forgetting no_grad() just wastes resources. The inference idiom:

model.eval()
with torch.no_grad():
    logits = model(x_test)
    probs = torch.softmax(logits, dim=1)   # logits -> probabilities, if you want them
    preds = logits.argmax(dim=1)           # or just take the top class

Note you apply softmax here, at inference, if you want probabilities — not in the model, because the loss already handled it during training.

Controlling overfitting

The failure mode of any flexible model is memorizing the training set — training loss keeps falling while held-out loss turns and climbs. PyTorch gives you three standard brakes:

Technique How What it does
Dropout nn.Dropout(p) in the model Randomly drops activations so no neuron is load-bearing
Weight decay (L2) Adam(..., weight_decay=1e-2) Penalizes large weights → simpler function
Early stopping Watch val loss; stop when it rises Halts before memorization sets in

The intuition behind each: dropout forces the network not to rely on any single neuron (since any neuron might be zeroed on a given pass), so it learns redundant, robust features instead of brittle memorized ones — it is like training a committee and asking a random subset each time. Weight decay adds a penalty proportional to the size of the weights, so the optimizer keeps them small unless a large weight really earns its keep; smaller weights mean a smoother, less wiggly function that cannot contort itself around noise. Early stopping simply watches the validation loss and halts the moment it starts rising — catching the model at the sweet spot before it tips from learning into memorizing.

Weight decay’s mechanism is visible: training the same net with weight_decay=1e-2 shrank the first layer’s weight norm from 6.19 to 4.93 — literally smaller weights, a smoother function. (On the easy digits data both scored the same accuracy, because there was little overfitting to fix; on a harder dataset the regularized model wins on held-out data. Regularization is insurance, and it is cheapest to buy before you need it.)

Saving and loading: state_dict, not the whole model

Save the learned parameters (the state_dict — an ordered dict of every weight tensor), not the Python object. Saving the whole model pickles your class definition and file paths, which breaks the moment you refactor; a state_dict is just numbers and reloads into any matching architecture.

# SAVE — recommended
torch.save(model.state_dict(), "digit_mlp.pt")

# LOAD — rebuild the architecture, then pour the weights in
model = DigitMLP()                                   # same class
model.load_state_dict(torch.load("digit_mlp.pt", weights_only=True))
model.eval()                                         # always eval() after loading for inference
state_dict keys and shapes:
   net.0.weight (32, 64)      net.0.bias (32,)
   net.3.weight (10, 32)      net.3.bias (10,)
file size: 11.9 KB
Approach Call Pros Cons
state_dict (recommended) torch.save(model.state_dict(), p) Portable, small, refactor-proof Must recreate the model class to load
Whole model torch.save(model, p) One line to reload Brittle — pickles code/paths; breaks on refactor
Checkpoint (resume training) save state_dict + optimizer + epoch Resume mid-run You assemble the dict yourself

⚠️ Pass weights_only=True to torch.load. A pickled full model can execute arbitrary code on load, so loading someone else’s .pt file without weights_only=True is a genuine security risk. For a state_dict (just tensors) it is both safe and the modern default.


Hands-on lab: a digit classifier in PyTorch

⏱️ ~10 minutes. You will load the 8×8 digits dataset (bundled in scikit-learn — no download), build the DataLoader, define the MLP, run the canonical loop until you watch loss fall and accuracy rise, evaluate correctly, save and reload the weights, and finally break training on purpose by deleting one line. Everything runs on CPU in seconds.

The problem: each sample is an 8×8 grayscale image of a handwritten digit, flattened into 64 numbers (pixel intensities 0–16), and the task is to name the digit 0–9. Our network is a multilayer perceptron (MLP) — the plainest neural net there is: an input layer of 64, one hidden layer of 32 with a ReLU, and an output layer of 10. “Multilayer” because of that hidden layer; “perceptron” is the historical name for a single artificial neuron. It has no idea the 64 numbers form a grid (a convolutional net would exploit that) — it just learns which combinations of pixels predict which digit. That is deliberately humble, and it still reaches 95%, which makes the point that the loop, not the architecture, is the thing to master first.

Step 1 — Load and prepare the data

import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

digits = load_digits()
X, y = digits.data, digits.target
print(X.shape, y.shape, np.unique(y))
# (1797, 64) (1797,) [0 1 2 3 4 5 6 7 8 9]

# split, then scale (fit the scaler on TRAIN only — no leakage)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y)
scaler = StandardScaler().fit(X_train)
X_train = scaler.transform(X_train)
X_test  = scaler.transform(X_test)

# to tensors: features float32, labels int64 (long) for CrossEntropyLoss
Xtr = torch.tensor(X_train, dtype=torch.float32)
ytr = torch.tensor(y_train, dtype=torch.long)
Xte = torch.tensor(X_test,  dtype=torch.float32)
yte = torch.tensor(y_test,  dtype=torch.long)
print(Xtr.shape, Xtr.dtype, "|", ytr.shape, ytr.dtype)
# torch.Size([1437, 64]) torch.float32 | torch.Size([1437]) torch.int64

What just happened: 1,797 handwritten digits, each an 8×8 image flattened to 64 pixel values, split 80/20 and standardized. The dtypes are deliberate — float32 features, long labels — the two things CrossEntropyLoss demands.

Step 2 — DataLoader, model, loss, optimizer

train_dl = DataLoader(TensorDataset(Xtr, ytr), batch_size=64, shuffle=True)

class DigitMLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(64, 32), nn.ReLU(), nn.Dropout(0.2), nn.Linear(32, 10))
    def forward(self, x):
        return self.net(x)

device = torch.device("cpu")            # detection code from earlier; CPU for reproducibility
torch.manual_seed(0)
model = DigitMLP().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

What just happened: the four ingredients of any training run — data (train_dl), model, loss, optimizer — are now in hand. torch.manual_seed(0) makes the run reproducible.

Step 3 — The training loop (watch it learn)

def accuracy(model, X, y):
    model.eval()
    with torch.no_grad():
        preds = model(X.to(device)).argmax(dim=1)
    return (preds == y.to(device)).float().mean().item()

for epoch in range(1, 16):
    model.train()
    running = 0.0
    for xb, yb in train_dl:
        xb, yb = xb.to(device), yb.to(device)
        optimizer.zero_grad()             # 1
        pred = model(xb)                  # 2
        loss = criterion(pred, yb)        # 3
        loss.backward()                   # 4
        optimizer.step()                  # 5
        running += loss.item() * xb.size(0)
    if epoch == 1 or epoch % 3 == 0:
        print(f"epoch {epoch:2d} | train_loss {running/len(Xtr):.4f} "
              f"| train_acc {accuracy(model, Xtr, ytr):.3f} "
              f"| test_acc {accuracy(model, Xte, yte):.3f}")

Real output:

epoch  1 | train_loss 2.2138 | train_acc 0.477 | test_acc 0.506
epoch  3 | train_loss 1.5742 | train_acc 0.817 | test_acc 0.761
epoch  6 | train_loss 0.7410 | train_acc 0.902 | test_acc 0.883
epoch  9 | train_loss 0.4330 | train_acc 0.937 | test_acc 0.925
epoch 12 | train_loss 0.3141 | train_acc 0.954 | test_acc 0.936
epoch 15 | train_loss 0.2498 | train_acc 0.967 | test_acc 0.947

What just happened: this is the whole point of the lesson, on screen. The loss falls monotonically (2.21 → 0.25) and accuracy climbs (test 0.51 → 0.95) as the five-line loop repeatedly nudges 2,410 weights downhill — and you wrote no gradient code. Autograd derived every one of those 2,410 gradients on every batch. The small gap between train (0.967) and test (0.947) accuracy is healthy: the model generalizes, it did not memorize.

Epoch Train loss Train acc Test acc Reading
1 2.2138 0.477 0.506 Barely better than guessing (10 classes)
3 1.5742 0.817 0.761 Learning fast
6 0.7410 0.902 0.883 Loss halved again
9 0.4330 0.937 0.925 Closing in
12 0.3141 0.954 0.936 Diminishing returns
15 0.2498 0.967 0.947 Healthy small train–test gap

Step 4 — Evaluate correctly, and predict one image

model.eval()
with torch.no_grad():
    logits = model(Xte)
    test_acc = (logits.argmax(1) == yte).float().mean().item()
print(f"FINAL test accuracy: {test_acc:.4f}  ({int(test_acc*len(yte))}/{len(yte)})")
# FINAL test accuracy: 0.9472  (341/360)

# one image: predicted class + confidence
with torch.no_grad():
    probs = torch.softmax(model(Xte[:1]), dim=1)
print("true:", yte[0].item(),
      "| pred:", probs.argmax(1).item(),
      "| confidence:", round(probs.max().item(), 3))
# true: 5 | pred: 5 | confidence: 0.579

What just happened: 341 of 360 held-out digits classified correctly with a network you built and trained from scratch. The single-image call shows the inference idiom — eval() + no_grad(), softmax only now, at prediction time.

Step 5 — Save and reload

torch.save(model.state_dict(), "digit_mlp.pt")

fresh = DigitMLP().to(device)
fresh.load_state_dict(torch.load("digit_mlp.pt", weights_only=True))
fresh.eval()
with torch.no_grad():
    reload_acc = (fresh(Xte).argmax(1) == yte).float().mean().item()
print("reloaded test accuracy:", round(reload_acc, 4))   # 0.9472 — identical

What just happened: an 11.9 KB file holds everything the model learned. A freshly-constructed model with the same architecture loads those weights and scores byte-identically — this is how a trained model ships to production.

Step 6 — Break it on purpose: the zero_grad bug

Now delete one lineoptimizer.zero_grad() — and watch training self-destruct. We use plain SGD (lr=0.5) so the failure is unmistakable; the accumulated gradients make each step larger than the last.

def run(with_zero_grad, epochs=5, lr=0.5):
    torch.manual_seed(0)
    m = DigitMLP().to(device)
    opt = torch.optim.SGD(m.parameters(), lr=lr)
    for epoch in range(1, epochs + 1):
        m.train()
        for xb, yb in train_dl:
            xb, yb = xb.to(device), yb.to(device)
            if with_zero_grad:
                opt.zero_grad()           # the line we toggle
            loss = criterion(m(xb), yb)
            loss.backward()
            opt.step()
        with torch.no_grad():
            m.eval()
            tl = criterion(m(Xte), yte).item()
            ta = (m(Xte).argmax(1) == yte).float().mean().item()
        print(f"  epoch {epoch} | test_loss {tl:14.4f} | test_acc {ta:.3f}")

print("CORRECT (with zero_grad):");   run(True)
print("BUGGY (zero_grad removed):");  run(False)

Real output:

CORRECT (with zero_grad):
  epoch 1 | test_loss         0.3216 | test_acc 0.931
  epoch 2 | test_loss         0.2111 | test_acc 0.956
  epoch 3 | test_loss         0.1634 | test_acc 0.958
  epoch 4 | test_loss         0.1361 | test_acc 0.961
  epoch 5 | test_loss         0.1307 | test_acc 0.961
BUGGY (zero_grad removed):
  epoch 1 | test_loss         3.3363 | test_acc 0.831
  epoch 2 | test_loss       181.5244 | test_acc 0.853
  epoch 3 | test_loss      6966.4258 | test_acc 0.758
  epoch 4 | test_loss    469621.1562 | test_acc 0.672
  epoch 5 | test_loss   3372656.7500 | test_acc 0.842

What just happened: one missing line turned a model that reaches 96% with a loss of 0.13 into one whose loss explodes past 3.3 million. Because .backward() adds to .grad, every batch’s gradient piled onto all the previous ones; the optimizer took wilder and wilder steps and the loss diverged instead of converging. (Accuracy thrashes rather than cleanly collapsing only because digits is an easy dataset — but a loss climbing by factors of 50 per epoch is training that has unambiguously broken.) The tell is always the same: loss going up, not down. The fix is always the same: put zero_grad() back at the top of the loop. This is the most common bug in PyTorch; now you will recognize it on sight.


The same model in Keras

PyTorch made you write the loop. Keras (the high-level API of TensorFlow) hides it: you describe the model, compile it with a loss and optimizer, and call fit. The identical network — same 64→32→10 shape, same 2,410 parameters, same Adam and cross-entropy — is about five lines of real work:

import tensorflow as tf
from tensorflow import keras
from keras import layers

keras.utils.set_random_seed(0)

model = keras.Sequential([
    keras.Input(shape=(64,)),
    layers.Dense(32, activation="relu"),
    layers.Dropout(0.2),
    layers.Dense(10),                                  # logits, like PyTorch
])
model.compile(
    optimizer=keras.optimizers.Adam(learning_rate=1e-3),
    loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
    metrics=["accuracy"],
)
history = model.fit(X_train, y_train, validation_data=(X_test, y_test),
                    epochs=15, batch_size=64, verbose=0)
test_loss, test_acc = model.evaluate(X_test, y_test, verbose=0)
print(f"test_acc {test_acc:.4f} ({int(test_acc*len(y_test))}/{len(y_test)})")

This ran — the Keras half was executed on TensorFlow 2.21.0 / Keras 3.15.0. model.summary() confirms the architectures are twins:

 Layer (type)          Output Shape     Param #
 dense (Dense)         (None, 32)         2,080
 dropout (Dropout)     (None, 32)             0
 dense_1 (Dense)       (None, 10)           330
 Total params: 2,410

And the training curve tells the same story as PyTorch’s — loss down, accuracy up:

epoch  1 | loss 2.5433 | acc 0.150 | val_loss 2.2747 | val_acc 0.294
epoch  4 | loss 1.3284 | acc 0.624 | val_loss 1.2198 | val_acc 0.694
epoch  7 | loss 0.8104 | acc 0.800 | val_loss 0.7287 | val_acc 0.861
epoch 10 | loss 0.5452 | acc 0.863 | val_loss 0.4926 | val_acc 0.906
epoch 13 | loss 0.4110 | acc 0.890 | val_loss 0.3705 | val_acc 0.939
epoch 15 | loss 0.3548 | acc 0.909 | val_loss 0.3206 | val_acc 0.953

test_acc 0.9528 (343/360)

The punchline: PyTorch scored 341/360 (94.7%); Keras scored 343/360 (95.3%) on the identical network and data. The two-digit difference is pure luck of random initialization and dropout — the frameworks are doing the same thing. What differs is not the result but how much you write and how much you can see.

The deeper distinction is a philosophy of control. Keras is declarative: you declare what the model is and what to optimize, and fit owns the how — the loop, the device moves, the gradient bookkeeping all happen inside a method you never open. That is wonderful when the standard loop is exactly what you want (which, for most problems, it is). PyTorch is imperative: the loop is your own Python, so you can print a tensor mid-batch, branch on a condition, accumulate gradients over several batches on purpose, or compute two losses and blend them — because there is no hidden machinery to fight. The cost is the five lines of boilerplate; the payoff is that when you need to do something the framework authors did not anticipate, nothing is in your way. Research lives in that “something unanticipated,” which is most of why PyTorch dominates it.

Line-for-line PyTorch Keras
Define model nn.Module subclass + forward keras.Sequential([...])
Loss + optimizer Create objects, hold references model.compile(loss=, optimizer=)
The training loop You write the 5 lines model.fit(X, y, epochs=) — hidden
Evaluate You write the eval loop model.evaluate(X, y)
Predict model.eval() + no_grad() model.predict(X)
Save/load state_dict + load_state_dict model.save() / load_model() (.keras)

PyTorch or Keras — the honest tradeoff

Neither is “better”; they weight the same knobs differently.

Dimension Keras (TensorFlow) PyTorch
Lines to a standard model Fewest — compile + fit More — you own the loop
Control over the loop Hidden (override for custom) Total — it’s your Python
Debugging Step into fit is harder Plain Python; drop a print/breakpoint() anywhere
Learning curve Gentle — great first framework Steeper — but you see everything
Research mindshare Present Dominant — most new papers
Production TF Serving, TFLite, mature TorchServe, torch.compile, ONNX — now first-class
Custom / weird architectures Possible, more friction Natural — arbitrary control flow
Best when Standard nets, ship fast, batteries included Flexibility, research, you want to understand the machine

The two have converged: Keras 3 runs on a PyTorch backend too, and PyTorch added high-level helpers. A fair rule of thumb — reach for Keras when the network is standard and you want it training in ten lines; reach for PyTorch when you need to see and control every step, which is why it is the default for research and, increasingly, production. Learning PyTorch’s explicit loop also makes you a better Keras user, because you understand what fit is doing under the hood.

Where the field is: Lightning, Hugging Face, fastai

You will rarely hand-write the training loop at scale — higher-level libraries wrap PyTorch so you keep its flexibility without the boilerplate:

Library Sits on What it gives you
PyTorch Lightning PyTorch Removes loop boilerplate (device moves, checkpointing, multi-GPU, logging) while keeping full control
Hugging Face transformers PyTorch (+ TF/JAX) Thousands of pretrained models (BERT, GPT-style, ViT) + a Trainer; the center of gravity for NLP and beyond
fastai PyTorch Opinionated high-level API with strong defaults; excellent for learning and fast results
Keras 3 TF / PyTorch / JAX One high-level API over multiple backends

Your MLP is the simplest architecture; the field is mostly specialized layers arranged for a data shape. The five-line loop does not change — only the nn.Module inside it does — so knowing the loop means you can pick up any of these by learning just its layers:

Architecture Built from Shines on The idea
MLP (this lesson) nn.Linear + activations Tabular data, small problems Every input connects to every neuron
CNN nn.Conv2d, pooling Images Learns local patterns (edges → shapes) that repeat anywhere in the grid
RNN / LSTM nn.LSTM, nn.GRU Sequences (older) Carries a memory state along a sequence
Transformer nn.TransformerEncoder, attention Text, and now nearly everything Every token attends to every other; the basis of modern LLMs

The path from here: you now understand the primitives (tensors, autograd, the loop). Next you would not train most of these from scratch — you would fine-tune a pretrained model from Hugging Face, which is the same five-line loop applied to weights that already learned from billions of examples. Everything you learned today is the foundation those libraries stand on.


Reading the training curve: diagnosing what went wrong

Once you can train, the skill that separates working models from broken ones is reading the loss and accuracy curves. Print train loss, train accuracy, and validation accuracy every few epochs (as the lab does) and the shape of those three numbers tells you almost everything. You saw two extremes already: a healthy run where loss fell smoothly to 0.25 and test accuracy rose to 0.95, and the zero_grad catastrophe where loss climbed into the millions. Here is the full field guide.

What the curves do Diagnosis Fix
Train loss ↓, val acc ↑, small gap Healthy — this is the goal Nothing; maybe train longer
Train loss ↓ to ~0, val acc ↓ (big gap) Overfitting — memorizing Dropout, weight decay, early stop, more data
Both train and val loss high, flat Underfitting — too simple Bigger model, more epochs, higher LR
Loss ↑ each epoch, or → millions Diverging — accumulated grads or LR too high Add zero_grad(); lower LR
Loss → nan / inf Exploded — LR way too high, bad inputs Drop LR 10×; check/scale inputs
Loss flat from step 1, never moves Not learning — LR ~0, or step()/backward() missing Check the five lines are all present
Loss jumps around wildly, no trend LR too high (but not yet diverging) Lower LR; increase batch size

Two of these are subtle enough to explain. Overfitting versus underfitting is read from the gap: underfitting is bad scores on both train and validation (the model cannot even fit the data it saw); overfitting is a great train score and a poor validation score (it fit the training data too well, including its noise). This is exactly the bias–variance story from the train/test lesson, now visible epoch by epoch. A flat loss that never moves is usually a missing line in the loop — most often optimizer.step() (weights never update) or a learning rate so small nothing changes; add a print(loss.item()) inside the batch loop and confirm it is actually changing.


Common mistakes and troubleshooting

Every row below is a real traceback or behavior captured on PyTorch 2.13. These are the errors you will actually hit.

Symptom / traceback Cause Fix
Loss climbs instead of falling; grows by factors each epoch Missing optimizer.zero_grad() — gradients accumulate Add zero_grad() as line 1 of the batch loop
RuntimeError: mat1 and mat2 shapes cannot be multiplied (8x100 and 64x32) Input width ≠ layer’s in_features Match nn.Linear(in_features=...) to your data; check for a missing Flatten
RuntimeError: expected target dtype to be Long or Byte, but got Float CrossEntropyLoss fed float labels Cast labels: y = y.long() (integer class indices)
IndexError: Target 5 is out of bounds. Label value ≥ number of output classes Output layer needs num_classes units; labels must be 0..C-1
RuntimeError: mat1 and mat2 must have the same dtype, but got Long and Float Integer tensor fed into nn.Linear Cast features to float: x = x.float()
RuntimeError: Can't call numpy() on Tensor that requires grad. .numpy() on a graph-tracked tensor Use t.detach().cpu().numpy()
RuntimeError: Tensor for argument input is on cpu but expected on mps (or cuda) Model and data on different devices Move both: x = x.to(device); model.to(device)
Loss becomes inf then nan after a few steps Learning rate far too high Lower lr (try 1e-3); check for exploding inputs
Predictions randomly change / are worse than training suggested Forgot model.eval() at inference — dropout still on Call model.eval() (and torch.no_grad()) before predicting
Train acc ↑ but val/test acc ↓ (widening gap) Overfitting Add dropout / weight decay; early-stop; get more data
AttributeError/optimizer trains nothing Forgot super().__init__() in your nn.Module First line of __init__ must be super().__init__()

Three gotchas earn extra words because they waste the most time.

CrossEntropyLoss wants logits — and quietly accepts the wrong thing. It applies softmax internally, so your model’s last layer must output raw logits. If you add a softmax and pass the probabilities, PyTorch does not error — it computes a different, wrong loss (in one run, 1.12 on softmaxed input versus the correct 1.39 on logits). No crash, just a model that trains poorly for a reason you cannot see. Separately, if you pass a 2-D float one-hot target, modern PyTorch treats it as soft labels and also does not error — again silently doing something you did not intend. The rule is unbending: logits in, integer class indices as long labels. Only a 1-D float label array trips the loud expected target dtype to be Long error.

eval() and no_grad() are two different switches. eval() fixes behavior (dropout off, batch-norm uses running stats); no_grad() fixes performance (no graph, less memory). Inference wants both. Forgetting eval() is the nastier bug because it is silent — your accuracy is quietly worse because dropout is randomly zeroing activations at prediction time, and nothing warns you.

Detach before you leave the tensor world. The moment you want a model output as a NumPy array — for plotting, for sklearn metrics, for logging — a raw .numpy() on a grad-tracked tensor raises. The idiom is t.detach().cpu().numpy(): detach() drops it from the autograd graph, cpu() moves it off the GPU, numpy() converts. Skip a step and you get either the requires grad error or a device error.

When a model simply will not learn — the loss sits flat or wanders with no downward trend — resist the urge to change the architecture and instead walk a short checklist, in order. First, overfit a tiny sample: try to drive the loss near zero on just 4–8 examples; if you cannot, the bug is in your loop or data, not your model’s capacity. Second, confirm all five lines are present and ordered — a missing optimizer.step() (weights never move) or a missing loss.backward() (.grad stays None) both produce a flat loss with no error. Third, check the learning rate across a few orders of magnitude (1e-2, 1e-3, 1e-4) — too small looks identical to “not learning,” too large diverges. Fourth, verify shapes and dtypes at the model’s input and the loss’s two arguments, since a silent broadcast or a logits/labels mix-up corrupts the gradient. Fifth, look at the data: labels off by one, unshuffled batches, or unscaled features (a column in the thousands next to one in [0,1]) all sabotage training quietly. This checklist resolves the large majority of “it won’t train” problems without touching the network itself.


Cheat-sheet

Task Code
Make a tensor torch.tensor([1., 2.]), torch.zeros(3, 4), torch.randn(2, 2)
From / to NumPy torch.from_numpy(a) · t.detach().cpu().numpy()
Fresh float32 copy torch.tensor(a, dtype=torch.float32)
Track gradients x = torch.tensor(2.0, requires_grad=True)
Compute gradients loss.backward() → read x.grad
No graph (inference) with torch.no_grad(): ...
Detach a tensor t.detach()
Define a model class M(nn.Module): def __init__(self): super().__init__(); ... def forward(self, x): ...
Common layers nn.Linear(i, o), nn.ReLU(), nn.Dropout(p), nn.Sequential(...), nn.Flatten()
Count parameters sum(p.numel() for p in model.parameters())
Dataset / loader DataLoader(TensorDataset(X, y), batch_size=64, shuffle=True)
Loss (classify) nn.CrossEntropyLoss() — logits + long labels
Loss (regress) nn.MSELoss()
Optimizer torch.optim.Adam(model.parameters(), lr=1e-3)
Training step zero_grad()pred = model(x)loss = criterion(pred, y)loss.backward()step()
Train / eval mode model.train() · model.eval()
Pick device torch.device("cuda" if torch.cuda.is_available() else "cpu")
Move to device model.to(device) · x = x.to(device)
Save / load torch.save(model.state_dict(), p) · model.load_state_dict(torch.load(p, weights_only=True))
Predict class model(x).argmax(dim=1)
Probabilities torch.softmax(model(x), dim=1)
Keras twin Sequential([...]).compile(loss=, optimizer=).fit(X, y, epochs=).evaluate(X, y)

Interview and exam questions

Q: What does a deep learning framework automate that you otherwise do by hand? A: The backward pass — computing gradients. You write only the forward pass; autograd records the operations into a computational graph and, on loss.backward(), mechanically applies the chain rule to fill every parameter’s .grad. It also gives you GPU execution, prebuilt layers, optimizers, and losses, but automatic differentiation is the core superpower.

Q: What is a tensor, and how does it differ from a NumPy array? A: An n-dimensional array like NumPy’s, plus two additions: it can track gradients (requires_grad) so autograd can differentiate through it, and it lives on a device (cpu/cuda/mps) so the same code runs on a GPU. It also defaults to float32 rather than NumPy’s float64.

Q: Write the canonical PyTorch training loop from memory. A:

for xb, yb in loader:
    optimizer.zero_grad()        # 1. clear gradients
    pred = model(xb)             # 2. forward
    loss = criterion(pred, yb)   # 3. loss
    loss.backward()              # 4. backprop
    optimizer.step()             # 5. update

Q: Why is optimizer.zero_grad() necessary, and what happens without it? A: Because .backward() accumulates into .grad rather than overwriting it. Without the clear, each batch’s gradient adds to all previous ones, the effective step size explodes, and the loss diverges — in this lesson it climbed to over 3 million. The symptom is loss going up; the fix is zero_grad() at the top of the loop.

Q: Your model’s last layer outputs 10 numbers and you use CrossEntropyLoss. Should you add a softmax? Why or why not? A: No. CrossEntropyLoss applies softmax internally (fused with the log for numerical stability) and expects raw logits. Adding your own softmax makes the loss compute on already-normalized values — it does not error, it just trains a worse model silently.

Q: What’s the difference between model.train() and model.eval()? A: They flip a flag that changes dropout and batch-norm. In train(), dropout randomly zeroes activations and batch-norm uses batch statistics. In eval(), dropout is off (full deterministic network) and batch-norm uses stored running statistics. Use eval() for any inference — forgetting it silently corrupts predictions.

Q: What is the difference between model.eval() and torch.no_grad()? Do you need both? A: They are independent. eval() changes layer behavior (dropout/batch-norm); no_grad() changes performance by not building the autograd graph (faster, less memory). For inference you want botheval() for correctness, no_grad() for efficiency.

Q: Why save state_dict() instead of the whole model? A: A state_dict is just the learned weight tensors — small, portable, and refactor-proof. Saving the whole model pickles the class definition and file paths, which breaks when you reorganize code and is a security risk to load (arbitrary code execution). Save the state_dict, recreate the architecture, and load_state_dict with weights_only=True.

Q: SGD vs Adam — when would you pick each? A: Adam adapts the learning rate per parameter, trains fast, and forgives a poorly chosen LR — the reliable default (lr=1e-3). SGD applies the raw gradient with one global LR; with momentum and a tuned LR it can reach cleaner minima, so it is chosen when squeezing out the last accuracy. Adam’s normalization also partly hides the missing-zero_grad bug, which is why that bug is best demonstrated with SGD.

Q: You call output.numpy() on a model prediction and get Can't call numpy() on Tensor that requires grad. Fix it. A: The tensor is still attached to the autograd graph. Use output.detach().cpu().numpy()detach() removes it from the graph, cpu() handles the GPU case, numpy() converts.

Q: Keras vs PyTorch — give the one-sentence tradeoff. A: Keras hides the training loop behind compile/fit for the fewest lines on standard models; PyTorch makes you write the loop, giving total control and visibility — which is why research and increasingly production default to it. They converged on similar APIs and produce equivalent results (here 94.7% vs 95.3% on the same network).

Q (coding): You have X as a NumPy float array and y as integer labels. Turn them into a DataLoader of shuffled batches of 32. A:

Xt = torch.tensor(X, dtype=torch.float32)
yt = torch.tensor(y, dtype=torch.long)
loader = DataLoader(TensorDataset(Xt, yt), batch_size=32, shuffle=True)

Q (coding): Given a trained model and a test tensor Xte, compute accuracy against yte correctly. A:

model.eval()
with torch.no_grad():
    acc = (model(Xte).argmax(dim=1) == yte).float().mean().item()

Key takeaways

pythonpytorchkerastensorflowdeep-learningautogradtensorsnn-moduledataloadertraining-loopneural-networksgradient-descentcross-entropymachine-learning
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments