Skip to main content
PyTorch · lesson 3 of 12
PyTorch beginner Lesson 3 of 12

Also part of: Machine Learning

PyTorch Neural Networks with nn.Module

Build networks with nn.Module and nn.Sequential, pick the right loss and optimizer, write the standard training loop, and save and restore checkpoints.

By SkillByExample · Updated

Real-World Scenario

A computer vision engineer at a retail company needs to build a product classification model. They use PyTorch’s nn.Module to define a multi-layer network, DataLoader to batch their product images, and the standard training loop to minimize cross-entropy loss. This is the workflow behind every production PyTorch model, and each piece of it fits in a small example.

The nn.Module API

Subclass nn.Module, create layers in __init__, and describe how data flows through them in forward.

import torch
import torch.nn as nn
import torch.nn.functional as F

class TwoLayerNet(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)   # assigning a layer registers it
        self.fc2 = nn.Linear(hidden_dim, output_dim)
        self.dropout = nn.Dropout(p=0.3)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = self.dropout(x)
        return self.fc2(x)            # raw logits: the loss applies softmax

model = TwoLayerNet(784, 256, 10)
print(model)
print(f"parameters: {sum(p.numel() for p in model.parameters()):,}")
print(model(torch.randn(32, 784)).shape)
$ python module.py
TwoLayerNet(
  (fc1): Linear(in_features=784, out_features=256, bias=True)
  (fc2): Linear(in_features=256, out_features=10, bias=True)
  (dropout): Dropout(p=0.3, inplace=False)
)
parameters: 203,530
torch.Size([32, 10])

Printing a module lists every layer assigned as an attribute, in the order they were assigned — not the order forward uses them. The parameter count is 784·256 + 256 for fc1 plus 256·10 + 10 for fc2: 203,530. Calling model(x) rather than model.forward(x) runs forward through PyTorch’s hooks, and the output has one row of 10 logits per input.

Only Registered Layers Are Parameters

nn.Module finds parameters by looking at attributes. A layer it cannot see is a layer the optimizer never trains.

import torch.nn as nn

class PlainList(nn.Module):
    def __init__(self):
        super().__init__()
        self.layers = [nn.Linear(4, 4) for _ in range(3)]      # plain Python list

class Registered(nn.Module):
    def __init__(self):
        super().__init__()
        self.layers = nn.ModuleList(nn.Linear(4, 4) for _ in range(3))

for cls in (PlainList, Registered):
    params = list(cls().parameters())
    print(f"{cls.__name__:10s} {len(params)} parameter tensors")

for name, p in Registered().named_parameters():
    print(name, tuple(p.shape))
$ python registration.py
PlainList  0 parameter tensors
Registered 6 parameter tensors
layers.0.weight (4, 4)
layers.0.bias (4,)
layers.1.weight (4, 4)
layers.1.bias (4,)
layers.2.weight (4, 4)
layers.2.bias (4,)

The three nn.Linear layers inside a plain Python list are invisible: parameters() returns nothing, so an optimizer built from it would update nothing, and no error would say so. nn.ModuleList registers each layer under an indexed name (layers.0.weight), which is also the key used in the saved state_dict.

train() and eval() Change What Layers Do

import torch
import torch.nn as nn

torch.manual_seed(0)
layer = nn.Dropout(p=0.5)
x = torch.ones(8)

print(layer.training)
print(layer(x))       # train mode: random zeros, survivors scaled by 1/(1-p)
print(layer(x))       # a different mask on every call

layer.eval()
print(layer(x))       # eval mode: identity
$ python dropout_modes.py
True
tensor([0., 0., 2., 0., 0., 0., 2., 2.])
tensor([0., 2., 2., 2., 2., 0., 2., 2.])
tensor([1., 1., 1., 1., 1., 1., 1., 1.])

A new module starts in training mode. There, Dropout(p=0.5) zeroes each value with probability 0.5 and multiplies the survivors by 1 / (1 - 0.5) = 2, so the expected sum stays the same — and draws a new mask on every call. After eval() it passes input through untouched. Forgetting model.eval() before evaluation gives noisy, pessimistic numbers.

nn.Sequential — Layers Without Boilerplate

When data flows straight through a stack of layers, nn.Sequential saves you writing forward.

import torch
import torch.nn as nn

model = nn.Sequential(
    nn.Linear(784, 128), nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(0.3),
    nn.Linear(128, 10),
)
print(model(torch.randn(16, 784)).shape)

single = torch.randn(1, 784)
try:
    model(single)
except ValueError as e:
    print("train mode:", e)

model.eval()
print("eval mode:", model(single).shape)
$ python batchnorm_one.py
torch.Size([16, 10])
train mode: Expected more than 1 value per channel when training, got input size torch.Size([1, 128])
eval mode: torch.Size([1, 10])

A batch of 16 works. A single example fails in training mode, because BatchNorm1d normalizes each feature using the batch’s mean and variance, and one value has no variance. In eval() mode BatchNorm uses running statistics instead, so single-example inference works — another reason to always switch modes before predicting.

Loss Functions

import torch
import torch.nn as nn

logits = torch.tensor([[2.0, 0.5, -1.0],
                       [0.1, 0.2, 3.0]])
targets = torch.tensor([0, 2])          # class indices, not one-hot

ce = nn.CrossEntropyLoss()
print(f"logits into CE:  {ce(logits, targets).item():.4f}")
print(f"softmax into CE: {ce(logits.softmax(dim=1), targets).item():.4f}")

bce = nn.BCEWithLogitsLoss()
print(f"BCE with logits: {bce(torch.tensor([3.0, -2.0]), torch.tensor([1.0, 0.0])).item():.4f}")

mse = nn.MSELoss()
print(f"MSE:             {mse(torch.tensor([2.5, 0.0]), torch.tensor([3.0, -0.5])).item():.4f}")
$ python losses.py
logits into CE:  0.1755
softmax into CE: 0.6611
BCE with logits: 0.0878
MSE:             0.2500

CrossEntropyLoss takes raw logits and integer class indices. Feeding it softmax probabilities doesn’t raise an error; it applies softmax a second time and reports 0.6611 instead of 0.1755 for the same predictions — a flatter loss with weaker gradients. BCEWithLogitsLoss is the binary equivalent and also wants raw scores; MSELoss averages squared errors: (0.5² + 0.5²) / 2 = 0.25.

What One Optimizer Step Does

Every training step is the same three calls: clear gradients, backpropagate, step.

import torch
import torch.nn as nn

torch.manual_seed(0)
model = nn.Linear(3, 1)
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

x = torch.tensor([[1.0, 2.0, 3.0]])
target = torch.tensor([[1.0]])

before = model.weight.detach().clone()
optimizer.zero_grad()
loss = nn.functional.mse_loss(model(x), target)
loss.backward()
optimizer.step()

print("loss:   ", round(loss.item(), 4))
print("grad:   ", model.weight.grad)
print("change: ", model.weight.detach() - before)
print("-lr*grad", -0.1 * model.weight.grad)
$ python one_step.py
loss:    4.9968
grad:    tensor([[ -4.4707,  -8.9414, -13.4120]])
change:  tensor([[0.4471, 0.8941, 1.3412]])
-lr*grad tensor([[0.4471, 0.8941, 1.3412]])

For plain SGD, the weight change is exactly -lr × grad, as the last two lines show. The gradient is proportional to the input [1, 2, 3], so the weight attached to the largest input moved furthest. Adam and AdamW scale each step using running averages of past gradients, but the three calls are identical.

Learning Rate Schedulers

import torch
import torch.nn as nn

model = nn.Linear(10, 1)
opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
step = torch.optim.lr_scheduler.StepLR(opt, step_size=3, gamma=0.5)

for epoch in range(8):
    # ... one epoch of training would run here ...
    opt.step()
    print(f"epoch {epoch}: lr {step.get_last_lr()[0]:.6f}")
    step.step()
$ python schedulers.py
epoch 0: lr 0.001000
epoch 1: lr 0.001000
epoch 2: lr 0.001000
epoch 3: lr 0.000500
epoch 4: lr 0.000500
epoch 5: lr 0.000500
epoch 6: lr 0.000250
epoch 7: lr 0.000250

StepLR(step_size=3, gamma=0.5) halves the learning rate every three scheduler.step() calls. Call it once per epoch, after the optimizer has stepped — calling it before optimizer.step() makes PyTorch warn that the two calls are in the wrong order, and the first value of the schedule is skipped.

The Standard Training Loop

Putting it together: a DataLoader for batches, a training phase, and an evaluation phase on held-out data.

import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(42)
X = torch.randn(2000, 20)
y = (X[:, 0] + X[:, 1] > 0).long()           # label depends on two features
train = DataLoader(TensorDataset(X[:1600], y[:1600]), batch_size=32, shuffle=True)
val_X, val_y = X[1600:], y[1600:]

model = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, 2))
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2)
criterion = nn.CrossEntropyLoss()

for epoch in range(1, 16):
    model.train()
    total = 0.0
    for xb, yb in train:
        optimizer.zero_grad()                  # 1. clear old gradients
        loss = criterion(model(xb), yb)        # 2. forward
        loss.backward()                        # 3. backward
        optimizer.step()                       # 4. update
        total += loss.item() * len(yb)
    model.eval()
    with torch.no_grad():
        val_logits = model(val_X)
        val_loss = criterion(val_logits, val_y).item()
        val_acc = (val_logits.argmax(1) == val_y).float().mean().item()
    if epoch % 3 == 0:
        print(f"epoch {epoch:2d}  train_loss {total / 1600:.4f}  val_loss {val_loss:.4f}  val_acc {val_acc:.3f}")
$ python train_loop.py
epoch  3  train_loss 0.3544  val_loss 0.3116  val_acc 0.928
epoch  6  train_loss 0.1738  val_loss 0.1695  val_acc 0.957
epoch  9  train_loss 0.1134  val_loss 0.1200  val_acc 0.978
epoch 12  train_loss 0.0897  val_loss 0.1016  val_acc 0.970
epoch 15  train_loss 0.0636  val_loss 0.0887  val_acc 0.973

The four numbered lines are the whole training step. The evaluation phase switches to model.eval() and wraps the forward pass in torch.no_grad(): the first turns dropout off, the second skips building a graph. Validation accuracy climbs to about 97% because the label is a simple function of two of the twenty features. Validation loss sits below training loss early on for two reasons: dropout is active while the training loss is measured, and the training loss is averaged over an epoch during which the model was still improving.

Saving and Loading Weights

Save the state_dict — a dictionary of named tensors — rather than the model object.

import torch
import torch.nn as nn

def make_model():
    return nn.Sequential(nn.Linear(10, 32), nn.ReLU(), nn.Linear(32, 1))

torch.manual_seed(0)
model = make_model()
print(list(model.state_dict().keys()))

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

restored = make_model()                      # same architecture, fresh random weights
restored.load_state_dict(torch.load("weights.pt", weights_only=True))
restored.eval()

x = torch.randn(2, 10)
print(torch.equal(model(x), restored(x)))

wrong = nn.Sequential(nn.Linear(10, 64), nn.ReLU(), nn.Linear(64, 1))
try:
    wrong.load_state_dict(torch.load("weights.pt", weights_only=True))
except RuntimeError as e:
    print(str(e).splitlines()[1].strip())
$ python save_load.py
['0.weight', '0.bias', '2.weight', '2.bias']
True
size mismatch for 0.weight: copying a param with shape torch.Size([32, 10]) from checkpoint, the shape in current model is torch.Size([64, 10]).

The keys come from the layer positions inside nn.Sequential (0, 2; the ReLU at 1 has no parameters). Loading them into a freshly built model of the same architecture reproduces the original’s outputs exactly. Loading into a different architecture fails with a precise size mismatch — the state_dict holds only tensors, so you must rebuild the same model in code first. weights_only=True makes torch.load refuse arbitrary pickled objects, which is the safe default for files you did not create.

Checkpoints for Resuming Training

import torch
import torch.nn as nn

model = nn.Linear(4, 1)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
nn.functional.mse_loss(model(torch.randn(8, 4)), torch.randn(8, 1)).backward()
optimizer.step()                              # Adam now holds moment estimates

torch.save({"epoch": 7,
            "model": model.state_dict(),
            "optimizer": optimizer.state_dict()}, "checkpoint.pt")

ckpt = torch.load("checkpoint.pt", weights_only=True)
model.load_state_dict(ckpt["model"])
optimizer.load_state_dict(ckpt["optimizer"])
print("resume at epoch", ckpt["epoch"] + 1)
print(sorted(ckpt["optimizer"]["state"][0].keys()))
$ python checkpoint.py
resume at epoch 8
['exp_avg', 'exp_avg_sq', 'step']

To resume training rather than just predict, save the optimizer too. Adam keeps two running averages (exp_avg, exp_avg_sq) and a step count for every parameter; restarting without them resets its adaptive step sizes, so the first steps after a resume behave like the start of training rather than a continuation.

Practice

1. Freeze the first layer of a 784 → 256 → 10 network and report how many parameters are still trainable.
import torch.nn as nn

model = nn.Sequential(nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10))
for p in model[0].parameters():
    p.requires_grad = False              # freeze the first layer

trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"trainable {trainable:,} of {total:,}")
$ python p1_freeze.py
trainable 2,570 of 203,530

Setting requires_grad = False on model[0]’s weight and bias leaves only the second layer’s 256·10 + 10 = 2,570 values to train. This is the mechanism behind fine-tuning a pretrained network’s head.

2. Show that torch.no_grad() alone does not make a model's predictions deterministic.
import torch
import torch.nn as nn

torch.manual_seed(0)
model = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Dropout(0.5), nn.Linear(64, 2))
x = torch.randn(1, 20)

with torch.no_grad():                       # no_grad does NOT switch dropout off
    for _ in range(2):
        print("train mode:", [round(v, 4) for v in model(x).squeeze().tolist()])
    model.eval()
    for _ in range(2):
        print("eval mode: ", [round(v, 4) for v in model(x).squeeze().tolist()])
$ python p2_forgot_eval.py
train mode: [0.1892, 0.163]
train mode: [0.6342, -0.5095]
eval mode:  [0.57, -0.1991]
eval mode:  [0.57, -0.1991]

no_grad only stops graph building; dropout is still active, so two identical calls give different logits. Only model.eval() makes repeated calls agree. Evaluation needs both.

3. Replace StepLR in schedulers.py with CosineAnnealingLR(T_max=8).
import torch
import torch.nn as nn

opt = torch.optim.AdamW(nn.Linear(10, 1).parameters(), lr=1e-3)
cosine = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=8)

for epoch in range(9):
    opt.step()
    print(f"epoch {epoch}: lr {cosine.get_last_lr()[0]:.6f}")
    cosine.step()
$ python p3_cosine.py
epoch 0: lr 0.001000
epoch 1: lr 0.000962
epoch 2: lr 0.000854
epoch 3: lr 0.000691
epoch 4: lr 0.000500
epoch 5: lr 0.000309
epoch 6: lr 0.000146
epoch 7: lr 0.000038
epoch 8: lr 0.000000

The learning rate follows half a cosine wave from 1e-3 down to zero over eight steps — halfway, at step 4, it is exactly half. Instead of sudden drops, it decreases smoothly.

4. Load old weights into a model that has an extra layer at the end, keeping the layers that match.
import torch
import torch.nn as nn

old = nn.Sequential(nn.Linear(10, 32), nn.ReLU(), nn.Linear(32, 1))
torch.save(old.state_dict(), "old.pt")

new = nn.Sequential(nn.Linear(10, 32), nn.ReLU(), nn.Linear(32, 1), nn.Sigmoid(),
                    nn.Linear(1, 1))
result = new.load_state_dict(torch.load("old.pt", weights_only=True), strict=False)
print("missing:   ", result.missing_keys)
print("unexpected:", result.unexpected_keys)
print(torch.equal(new[0].weight, old[0].weight))
$ python p4_strict.py
missing:    ['4.weight', '4.bias']
unexpected: []
True

strict=False loads every key that matches and reports the rest instead of raising. The new layer at index 4 is listed as missing and keeps its random initialization, while layer 0 now holds the old weights. Always inspect the returned lists — a typo in a key name also shows up here rather than as an error.

Frequently Asked Questions

Why use nn.Module instead of raw tensors?
nn.Module automatically tracks every parameter of the layers you assign as attributes. It provides parameters(), state_dict(), to(device), train(), and eval() — the standard interface expected by optimizers, loss functions, and deployment tools. Layers kept in a plain Python list are not tracked; use nn.ModuleList for that.
What is the difference between model.train() and model.eval()?
model.train() enables Dropout (randomly zeroes activations) and makes BatchNorm use batch statistics — both are training behaviors. model.eval() disables Dropout and makes BatchNorm use the running statistics accumulated during training. Always call model.eval() before inference and model.train() before resuming training; torch.no_grad() does not change either behavior.
Should I apply softmax before nn.CrossEntropyLoss?
No. nn.CrossEntropyLoss expects raw logits and applies log-softmax internally. Passing probabilities applies softmax twice, which produces a different, flatter loss and weaker gradients without raising any error. Apply softmax only when you need probabilities for display or decisions, after the model's forward pass.
What should a PyTorch checkpoint contain?
For inference, the model's state_dict is enough. To resume training, also save the optimizer's state_dict, because optimizers such as Adam keep per-parameter running averages, plus the epoch number and any scheduler state. Save them together in one dictionary with torch.save and restore each with load_state_dict.