DataLens

Building a model with nn.Module

Define networks as nn.Module classes with layers and a forward method, understand what a Linear layer computes, add activations, and count parameters.

Level: Intermediate · 8 min read

Practise this module

The questions for this lesson run in your browser and check your answer straight away.

The idea

In the autograd module you managed w and b by hand. A real network has many layers and thousands of parameters, so PyTorch provides building blocks in torch.nn:

It's the classes module from the Python track, put to work.

A Linear layer is X @ W.T + b

The basic building block of a neural network is the linear layer. It's the same "multiply and add" as a linear regression, just with several outputs at once. Here's one that takes 3 features and produces 2 numbers:

import torch
from torch import nn

torch.manual_seed(0)
layer = nn.Linear(in_features=3, out_features=2)
print(layer.weight.shape, layer.bias.shape)

x = torch.tensor([[1.0, 0.0, 2.0]])          # a batch of 1 example with 3 features
print(layer(x))
print(x @ layer.weight.T + layer.bias)       # the same calculation by hand
torch.Size([2, 3]) torch.Size([2])
tensor([[-0.9661,  0.3425]], grad_fn=<AddmmBackward0>)
tensor([[-0.9661,  0.3425]], grad_fn=<AddBackward0>)

The grad_fn in the output shows the result is part of the autograd graph, ready for backward(). nn.Linear(3, 2) holds a weight matrix of shape (2, 3) and a bias of shape (2,), both created randomly and marked requires_grad=True. Calling the layer on a batch computes x @ W.T + b: the "layer by hand" exercise from the operations module.

Activations: the non-linear part

Stacking linear layers alone gives just another linear function (a matrix times a matrix is a matrix). Between layers you put a non-linear activation, which lets the network bend:

z = torch.tensor([-2.0, -0.5, 0.0, 1.5])
print(torch.relu(z))         # max(0, z): the default choice
print(torch.sigmoid(z))      # squashes to (0, 1): for probabilities
print(torch.tanh(z))         # squashes to (-1, 1)
tensor([0.0000, 0.0000, 0.0000, 1.5000])
tensor([0.1192, 0.3775, 0.5000, 0.8176])
tensor([-0.9640, -0.4621,  0.0000,  0.9051])

ReLU (rectified linear unit) is the standard between hidden layers: simple, fast, and it trains well.

Your first model class

A churn model with 5 inputs, one hidden layer of 16 units, and one output:

class ChurnNet(nn.Module):
    def __init__(self, n_features, hidden=16):
        super().__init__()
        self.hidden = nn.Linear(n_features, hidden)
        self.output = nn.Linear(hidden, 1)

    def forward(self, x):
        x = torch.relu(self.hidden(x))
        return self.output(x)          # a raw score ("logit"); sigmoid turns it into a probability


torch.manual_seed(0)
model = ChurnNet(n_features=5)
batch = torch.randn(4, 5)              # 4 customers, 5 features each
logits = model(batch)
print(logits.shape)
print(torch.sigmoid(logits).squeeze().detach().round(decimals=3))
torch.Size([4, 1])
tensor([0.3920, 0.4280, 0.3520, 0.4480])

The untrained model's probabilities sit between 0.35 and 0.45: random weights, no knowledge yet. (.detach() drops the autograd bookkeeping before printing.)

nn.Sequential for simple stacks

When data just flows through layers in order, nn.Sequential saves writing a class:

torch.manual_seed(0)
model = nn.Sequential(
    nn.Linear(5, 16),
    nn.ReLU(),
    nn.Linear(16, 1),
)
print(model)
print(model(batch).shape)
Sequential(
  (0): Linear(in_features=5, out_features=16, bias=True)
  (1): ReLU()
  (2): Linear(in_features=16, out_features=1, bias=True)
)
torch.Size([4, 1])

Use nn.Sequential for straight stacks; write an nn.Module subclass when forward needs logic (several inputs, skip connections, branching).

Parameters

Every layer registered as an attribute is tracked automatically:

for name, p in model.named_parameters():
    print(name, tuple(p.shape))
print("total:", sum(p.numel() for p in model.parameters()))
0.weight (16, 5)
0.bias (16,)
2.weight (1, 16)
2.bias (1,)
total: 113

5×16 + 16 + 16×1 + 1 = 113 parameters. model.parameters() is what you hand to an optimizer, so it can update them all. This works because the layers were assigned to self.something in __init__; a layer stored in a plain Python list wouldn't be found (use nn.ModuleList for lists of layers).

Key idea

An nn.Module = layers created in __init__ + data flow in forward. Layers own their parameters; model.parameters() gathers them all for the optimizer.

Why this matters for clean code

Model classes are where deep-learning code stays readable or turns into spaghetti. Keep __init__ declarative (just the layers, with clear names like hidden and output), keep forward short and linear, pass sizes as arguments (n_features, hidden) instead of hard-coding them, and return logits rather than mixing probability logic into the model. Then the same class can be tested, reused and reconfigured.

Recap

Practise this module

The questions for this lesson run in your browser and check your answer straight away.

Next: Loss functions and optimizers →