Lesson 11 / 33

nn.Module: the shape of every model

Write an nn.Module subclass with __init__ and forward, count its parameters, and avoid the plain-list registration bug.

By · 15 years in web development

What nn.Module does for you

Every PyTorch model, from a two-layer network to a large language model, is a subclass of nn.Module. The pattern has two parts. In __init__, call super().__init__() and then create layers as attributes (self.fc1 = nn.Linear(...)). Assigning a module or an nn.Parameter to an attribute registers it, so its weights are found automatically. In forward(self, x), describe how input flows through those layers.

Call the model as model(x), not model.forward(x). The __call__ method runs any registered hooks and then forward, and some tools rely on those hooks.

Registration powers everything else: model.parameters() yields every learnable tensor for the optimizer, model.to(device) moves them all, model.state_dict() saves them, and model.train() or model.eval() reaches every sub-layer. The catch: layers stored in a plain Python list or dict are not registered. Use nn.ModuleList or nn.ModuleDict instead, or the optimizer will silently never update those layers.

From input to loss

An input batch flows through the layers defined in the module, producing raw output scores called logits. A loss function compares the logits with the targets to produce the number that training minimises.

Diagram of 4 connected stages from left to right: input batch, layers (Linear, activation), logits, loss function.
Figure 4.1: input batch -> layers (Linear, activation) -> logits -> loss function

A small multilayer perceptron

The parameter count is easy to verify by hand: 784 x 128 weights + 128 biases + 128 x 10 weights + 10 biases = 101770.

import torch
from torch import nn

class MLP(nn.Module):
    def __init__(self, in_features: int, hidden: int, num_classes: int):
        super().__init__()
        self.fc1 = nn.Linear(in_features, hidden)
        self.act = nn.ReLU()
        self.fc2 = nn.Linear(hidden, num_classes)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.fc2(self.act(self.fc1(x)))

model = MLP(784, 128, 10)
x = torch.randn(32, 784)          # a batch of 32 flattened 28x28 images
logits = model(x)
print(logits.shape)               # torch.Size([32, 10])
print(sum(p.numel() for p in model.parameters()))   # 101770

The plain-list bug

self.layers = [nn.Linear(64, 64) for _ in range(4)] looks fine and even runs, but those layers are invisible to parameters(), to(device) and state_dict(). Write self.layers = nn.ModuleList([...]). If your parameter count looks too small, this is the first thing to check.

Quick check: Why should you call `model(x)` rather than `model.forward(x)`?

  • `model(x)` runs registered hooks around `forward`, which calling `forward` directly skips
  • `model.forward(x)` does not compute gradients
  • `model(x)` automatically moves `x` to the GPU
  • `model.forward(x)` always runs in evaluation mode
Answer

`model(x)` runs registered hooks around `forward`, which calling `forward` directly skips — `nn.Module.__call__` handles hooks and then calls `forward`. Calling `forward` directly bypasses that machinery; gradients and devices are unaffected.