Chapter 4: Building Neural Networks

Design models with nn.Module and implement training loops

📖 Reading time: 25-30 min 📊 Difficulty: Intermediate 💻 Code examples: 14 📝 Exercises: 3

In the previous chapters, you learned how to manipulate Tensors and how automatic differentiation (autograd) works. In this chapter, we'll build on that foundation and finally construct a Neural Network. From defining models with nn.Module, to choosing loss functions and optimizers, implementing training loops, mini-batch processing with DataLoader, and saving/loading trained models — let's work through the entire workflow by actually running code.

Learning Objectives

1. nn.Module Basics

The basic building block for constructing a neural network in PyTorch is torch.nn.Module. nn.Module is a class that bundles together the functionality needed for neural networks — managing parameters (weights and biases), transferring to GPU, saving and loading models, and more. When you define your own model, you build it by inheriting from this class.

How to Create a Class that Inherits from nn.Module

To define your own model, you implement the following two methods.

import torch
import torch.nn as nn

class SimpleNet(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        super().__init__()
        self.fc1 = nn.Linear(input_size, hidden_size)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(hidden_size, output_size)

    def forward(self, x):
        x = self.fc1(x)
        x = self.relu(x)
        x = self.fc2(x)
        return x

# Instantiate the model: input 10 dims → hidden layer 32 dims → output 3 dims
model = SimpleNet(input_size=10, hidden_size=32, output_size=3)
print(model)

Output:

SimpleNet(
  (fc1): Linear(in_features=10, out_features=32, bias=True)
  (relu): ReLU()
  (fc2): Linear(in_features=32, out_features=3, bias=True)
)

💡 Don't Forget super().__init__()

You must call super().__init__() first inside __init__(). This initializes nn.Module's internal parameter-management mechanism, so that attributes like self.fc1 and self.fc2 are automatically registered as the model's parameters.

Running the Model and Checking Its Parameters

Once defined, the model can be called like a function to run forward propagation. Internally, model(x) calls model.forward(x).

import torch

# Dummy data with batch size 5, input dimension 10
x = torch.randn(5, 10)

# Run forward propagation
output = model(x)
print(f"Input shape: {x.shape}")
print(f"Output shape: {output.shape}")

# Check the parameters held by the model
for name, param in model.named_parameters():
    print(f"{name}: {param.shape}")

Output:

Input shape: torch.Size([5, 10])
Output shape: torch.Size([5, 3])
fc1.weight: torch.Size([32, 10])
fc1.bias: torch.Size([32])
fc2.weight: torch.Size([3, 32])
fc2.bias: torch.Size([3])

As you can see from named_parameters(), when you assign a subclass of nn.Module (such as nn.Linear) to an attribute — as in self.fc1 = nn.Linear(...) — its parameters are automatically registered as trainable parameters of the parent model. This is one of the major benefits of using nn.Module.

2. Defining and Combining Layers

nn.Linear and nn.ReLU, used in the model above, represent a Fully Connected Layer and an Activation Function, respectively. PyTorch provides many other layers to suit various purposes.

Commonly Used Layers and Activation Functions

Class Role Typical Use
nn.Linear(in, out) Fully connected layer. Performs the linear transformation $y = xW^T + b$ MLPs, classifier output layers, etc.
nn.ReLU() Activation function. Sets negative values to 0 The most common choice for hidden layers
nn.Sigmoid() Activation function that squashes output to 0–1 Output layer for binary classification
nn.Softmax(dim=-1) Converts output into a probability distribution that sums to 1 Multi-class classification output (usually built into the loss function instead)
nn.Dropout(p) Disables neurons with probability p during training Suppressing overfitting (regularization)
nn.BatchNorm1d(num_features) Normalizes per mini-batch Stabilizing and speeding up training

Layers designed for image and sequence data, such as convolutional layers (nn.Conv2d) and recurrent layers (nn.LSTM, etc.), will be covered in detail in their own dedicated series. For this chapter, let's focus on building models centered around fully connected layers.

Building Quickly with nn.Sequential

For simple models that just apply layers in sequence inside forward(), you can write more concisely using nn.Sequential.

import torch.nn as nn

model_seq = nn.Sequential(
    nn.Linear(10, 32),
    nn.ReLU(),
    nn.Linear(32, 16),
    nn.ReLU(),
    nn.Linear(16, 3)
)
print(model_seq)

Output:

Sequential(
  (0): Linear(in_features=10, out_features=32, bias=True)
  (1): ReLU()
  (2): Linear(in_features=32, out_features=16, bias=True)
  (3): ReLU()
  (4): Linear(in_features=16, out_features=3, bias=True)
)

An MLP Class that Supports a Flexible Number of Layers

In practice, you'll often want to define a model whose number and size of hidden layers can be freely changed via arguments. By combining nn.Module and nn.Sequential, you can build a general-purpose Multi-Layer Perceptron (MLP) class like the one below.

import torch.nn as nn

class MLP(nn.Module):
    def __init__(self, input_size, hidden_sizes, output_size):
        super().__init__()
        layers = []
        prev_size = input_size
        for hidden_size in hidden_sizes:
            layers.append(nn.Linear(prev_size, hidden_size))
            layers.append(nn.ReLU())
            prev_size = hidden_size
        layers.append(nn.Linear(prev_size, output_size))
        self.network = nn.Sequential(*layers)

    def forward(self, x):
        return self.network(x)

# Input 20 dims → hidden layers [64, 32] → output 4 dims
mlp = MLP(input_size=20, hidden_sizes=[64, 32], output_size=4)
print(mlp)

Output:

MLP(
  (network): Sequential(
    (0): Linear(in_features=20, out_features=64, bias=True)
    (1): ReLU()
    (2): Linear(in_features=64, out_features=32, bias=True)
    (3): ReLU()
    (4): Linear(in_features=32, out_features=4, bias=True)
  )
)

By designing the class this way, so that it dynamically assembles layers from a list, you no longer need to rewrite the model class itself when experimenting with different hidden-layer configurations. In the second half of this chapter, we'll use this MLP class to actually train a model.

3. Loss Functions and Optimizers

Simply defining a model doesn't train anything yet. Training requires two things: a Loss Function that quantifies how wrong the model's predictions are, and an Optimizer that updates the parameters in the direction that reduces that loss.

Common Loss Functions

Class Use Case Notes
nn.CrossEntropyLoss() Multi-class classification Applies Softmax internally, so you must not add a Softmax to the model's output layer
nn.BCEWithLogitsLoss() Binary classification Applies Sigmoid internally. Numerically stable
nn.MSELoss() Regression Mean Squared Error
nn.L1Loss() Regression Mean Absolute Error. Robust to outliers

⚠️ Watch the Input Format for CrossEntropyLoss

Pass logits — the raw output before applying Softmax — directly to nn.CrossEntropyLoss(). Also note that the correct labels should be passed not as one-hot vectors but as a Tensor of integers (dtype=torch.long) representing the class number.

Common Optimizers

Class Characteristics
optim.SGD(params, lr=0.01) Stochastic Gradient Descent. Simple and theoretically easy to reason about
optim.SGD(params, lr=0.01, momentum=0.9) SGD with momentum. Dampens oscillation and speeds up convergence
optim.Adam(params, lr=0.001) An adaptive method that automatically adjusts the learning rate based on gradient statistics. The default choice in many situations

When in doubt, a good starting point is nn.CrossEntropyLoss() (classification) or nn.MSELoss() (regression) combined with optim.Adam(model.parameters(), lr=0.001). If training isn't stable, the common approach is to adjust the learning rate or try a different optimizer.

import torch
import torch.nn as nn
import torch.optim as optim

# Prepare a loss function and an optimizer
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(mlp.parameters(), lr=0.001)

# Compute the loss once with dummy data
x = torch.randn(8, 20)              # Batch size 8, input dimension 20
y = torch.randint(0, 4, (8,))       # Class labels 0-3 (integers)

logits = mlp(x)                     # Logits (before Softmax)
loss = criterion(logits, y)
print(f"Output shape: {logits.shape}")
print(f"Loss: {loss.item():.4f}")

Example output:

Output shape: torch.Size([8, 4])
Loss: 1.4290

As in optim.Adam(mlp.parameters(), lr=0.001), you pass the optimizer the model's full set of parameters (model.parameters()) and the learning rate. This is all that's needed so that, in the training loop we implement in the next section, simply calling optimizer.step() will automatically update these parameters.

4. Implementing the Training Loop

With the model, loss function, and optimizer all in place, it's finally time for the Training Loop. In PyTorch, the training loop for essentially any model repeats the following five steps.

graph LR A[1: optimizer.zero_grad] --> B[2: forward propagation] B --> C[3: compute loss] C --> D[4: backward propagation loss.backward] D --> E[5: update parameters optimizer.step] E --> A
  1. optimizer.zero_grad(): Resets the gradients accumulated from the previous iteration
  2. Forward propagation: Passes the input to the model to obtain predictions
  3. Compute loss: Computes the loss from the predictions and the correct labels
  4. Backward propagation (loss.backward()): Uses automatic differentiation to compute the gradient of each parameter with respect to the loss
  5. optimizer.step(): Updates the parameters using the computed gradients

Let's first look at a minimal training loop that treats the entire dataset as a single batch. We'll use a synthetic dataset generated with make_classification, which will appear again in the next section.

import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.datasets import make_classification

torch.manual_seed(42)

# Synthetic data: 4-class classification, 20 feature dimensions, 1000 samples
X, y = make_classification(
    n_samples=1000,
    n_features=20,
    n_informative=15,
    n_redundant=5,
    n_classes=4,
    random_state=42,
)
X_train = torch.tensor(X, dtype=torch.float32)
y_train = torch.tensor(y, dtype=torch.long)

model = MLP(input_size=20, hidden_sizes=[64, 32], output_size=4)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

# Minimal training loop (uses the entire dataset every time)
for epoch in range(5):
    optimizer.zero_grad()               # 1. Reset gradients
    outputs = model(X_train)            # 2. Forward propagation
    loss = criterion(outputs, y_train)  # 3. Compute loss
    loss.backward()                     # 4. Backward propagation
    optimizer.step()                    # 5. Update parameters

    print(f"Epoch {epoch+1}: loss = {loss.item():.4f}")

Example output:

Epoch 1: loss = 1.3877
Epoch 2: loss = 1.3762
Epoch 3: loss = 1.3651
Epoch 4: loss = 1.3542
Epoch 5: loss = 1.3435

This approach works, but it requires loading the entire dataset into GPU/CPU memory as one giant batch every time, which becomes impractical as the data grows. That's where Mini-batch Learning using DataLoader, covered in the next section, comes in.

5. Using DataLoader

In practice, it's common to split the dataset into small chunks (mini-batches) and run the training loop one batch at a time. In PyTorch, two classes handle this role: torch.utils.data.Dataset and torch.utils.data.DataLoader.

Building Quickly with TensorDataset

If the inputs and labels are already available as Tensors, the easiest option is TensorDataset.

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

# Split into training and test data
from sklearn.model_selection import train_test_split

X_train_np, X_test_np, y_train_np, y_test_np = train_test_split(
    X, y, test_size=0.2, random_state=42
)

X_train = torch.tensor(X_train_np, dtype=torch.float32)
y_train = torch.tensor(y_train_np, dtype=torch.long)
X_test = torch.tensor(X_test_np, dtype=torch.float32)
y_test = torch.tensor(y_test_np, dtype=torch.long)

train_dataset = TensorDataset(X_train, y_train)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)

# Pull out a single batch and check its shape
batch_X, batch_y = next(iter(train_loader))
print(f"Input shape in batch: {batch_X.shape}")
print(f"Label shape in batch: {batch_y.shape}")
print(f"Dataset size: {len(train_dataset)} samples, number of batches: {len(train_loader)}")

Output:

Input shape in batch: torch.Size([32, 20])
Label shape in batch: torch.Size([32])
Dataset size: 800 samples, number of batches: 25

Setting shuffle=True shuffles the sample order at every epoch. To prevent the model from depending on the ordering of the training data, you should generally set shuffle=True for the training DataLoader (for evaluation/testing, shuffle=False is usually fine).

Creating a Custom Dataset Class

When you need more than a simple slice of a Tensor — for example, loading and preprocessing image files — you define a custom class by inheriting from Dataset. At minimum, you implement the following three methods.

import torch
from torch.utils.data import Dataset, DataLoader

class MyDataset(Dataset):
    def __init__(self, X, y):
        self.X = X
        self.y = y

    def __len__(self):
        # Return the total number of samples in the dataset
        return len(self.X)

    def __getitem__(self, idx):
        # Return the sample at index idx as an (input, label) pair
        return self.X[idx], self.y[idx]

custom_dataset = MyDataset(X_train, y_train)
custom_loader = DataLoader(custom_dataset, batch_size=16, shuffle=True)

batch_X, batch_y = next(iter(custom_loader))
print(f"Custom Dataset batch shape: {batch_X.shape}, {batch_y.shape}")

Output:

Custom Dataset batch shape: torch.Size([16, 20]), torch.Size([16])

A Full Training Loop Using DataLoader

Now let's implement a more practical training loop that trains in mini-batches using DataLoader. It runs through the entire training set once per epoch and also records accuracy.

import torch.nn as nn
import torch.optim as optim

model = MLP(input_size=20, hidden_sizes=[64, 32], output_size=4)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

n_epochs = 30
for epoch in range(n_epochs):
    model.train()  # Set to training mode (enables Dropout, etc.)
    total_loss = 0.0
    correct = 0
    total = 0

    for batch_X, batch_y in train_loader:
        optimizer.zero_grad()
        outputs = model(batch_X)
        loss = criterion(outputs, batch_y)
        loss.backward()
        optimizer.step()

        total_loss += loss.item() * batch_X.size(0)
        preds = outputs.argmax(dim=1)
        correct += (preds == batch_y).sum().item()
        total += batch_y.size(0)

    if (epoch + 1) % 5 == 0:
        avg_loss = total_loss / total
        train_acc = correct / total
        print(f"Epoch {epoch+1:3d}/{n_epochs}: loss={avg_loss:.4f}, train_acc={train_acc:.4f}")

# Evaluate on the test data
model.eval()  # Set to evaluation mode
with torch.no_grad():
    test_outputs = model(X_test)
    test_preds = test_outputs.argmax(dim=1)
    test_acc = (test_preds == y_test).float().mean().item()
print(f"Test accuracy: {test_acc:.4f}")

Example output (execution result):

Epoch   5/30: loss=0.7789, train_acc=0.7113
Epoch  10/30: loss=0.4871, train_acc=0.8287
Epoch  15/30: loss=0.3546, train_acc=0.8788
Epoch  20/30: loss=0.2659, train_acc=0.9187
Epoch  25/30: loss=0.1975, train_acc=0.9475
Epoch  30/30: loss=0.1468, train_acc=0.9650
Test accuracy: 0.7350

💡 The Difference Between train() and eval()

model.train() and model.eval() are switches that toggle the behavior of layers such as Dropout and BatchNorm, which behave differently during training and inference. Our current model doesn't include any of these, but it's a good habit to always call model.eval() before evaluation, since you'll frequently work with models that do include them. It also helps to disable gradient computation with torch.no_grad() during evaluation, which reduces memory usage.

While training accuracy (train_acc) has climbed to 96.5%, test accuracy remains at 73.5%. This is a classic sign of Overfitting, where the model learns the fine details of the training data too well. We'll cover countermeasures for overfitting (Dropout, regularization, early stopping, and so on) in a future series, but keep in mind that "observing both training and test accuracy" is itself an essential habit when implementing a training loop.

6. Saving and Loading Models

A model that took time to train needs to be saved so it can be reused even after the process ends. In PyTorch, the standard approach is to combine torch.save() with load_state_dict().

What is state_dict?

state_dict is a dictionary (OrderedDict) that maps the names of all parameters (weights and biases) the model holds to their values. Its key feature is that it saves and restores only the trained numerical values, not the model's structure itself.

import torch

# Save only the model's weights
torch.save(model.state_dict(), "mlp_model.pth")
print("Model saved: mlp_model.pth")

# Create a new model with the same structure and load the saved weights
loaded_model = MLP(input_size=20, hidden_sizes=[64, 32], output_size=4)
loaded_model.load_state_dict(torch.load("mlp_model.pth"))
loaded_model.eval()

# Verify that the original model and the loaded model produce the same output
with torch.no_grad():
    original_output = model(X_test)
    loaded_output = loaded_model(X_test)
    print("Outputs match:", torch.allclose(original_output, loaded_output))

Output:

Model saved: mlp_model.pth
Outputs match: True

⚠️ You Must Reconstruct the Model Structure Yourself

state_dict contains only the weight values. On the loading side, you must first create a model with the same structure (the same hidden_sizes, etc.) as when it was saved, and only then call load_state_dict(). If the structures don't match, you'll get an error or unintended results.

Saving Checkpoints to Resume Training

If you want to resume training partway through, it's useful to save not just the model's weights but also the optimizer's state (optimizer state_dict), the epoch number, the loss, and so on. This kind of save is called a Checkpoint.

import torch
import torch.optim as optim

# Save multiple pieces of information together as a checkpoint
checkpoint = {
    'epoch': n_epochs,
    'model_state_dict': model.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'loss': avg_loss,
}
torch.save(checkpoint, "checkpoint.pth")
print(f"Checkpoint saved (epoch={n_epochs}, loss={avg_loss:.4f})")

# Prepare to resume training from the checkpoint
checkpoint_loaded = torch.load("checkpoint.pth")

resumed_model = MLP(input_size=20, hidden_sizes=[64, 32], output_size=4)
resumed_model.load_state_dict(checkpoint_loaded['model_state_dict'])

resumed_optimizer = optim.Adam(resumed_model.parameters(), lr=0.001)
resumed_optimizer.load_state_dict(checkpoint_loaded['optimizer_state_dict'])

start_epoch = checkpoint_loaded['epoch']
print(f"You can resume training from epoch {start_epoch} (previous loss: {checkpoint_loaded['loss']:.4f})")

Example output:

Checkpoint saved (epoch=30, loss=0.1468)
You can resume training from epoch 30 (previous loss: 0.1468)

Restoring the optimizer's state also carries over statistics such as Adam's internal "moving averages of the gradients," letting you resume training seamlessly from where it left off. If you're only distributing a model for inference, saving the state_dict alone is sufficient — but for real-world work involving long training runs, it's worth getting comfortable with the checkpoint approach.

Exercises

Exercise 1: Define a 3-Layer MLP

Define a class ThreeLayerNet that inherits from nn.Module. The input dimension should be 8, with two hidden layers (16 and 8 dimensions, respectively) each using ReLU as the activation function, and an output dimension of 2. After defining it, pass a dummy input with batch size 4 and check the shape of the output.

Sample solution:

import torch
import torch.nn as nn

class ThreeLayerNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(8, 16)
        self.relu1 = nn.ReLU()
        self.fc2 = nn.Linear(16, 8)
        self.relu2 = nn.ReLU()
        self.fc3 = nn.Linear(8, 2)

    def forward(self, x):
        x = self.relu1(self.fc1(x))
        x = self.relu2(self.fc2(x))
        x = self.fc3(x)
        return x

model = ThreeLayerNet()
x = torch.randn(4, 8)
output = model(x)
print(f"Output shape: {output.shape}")  # torch.Size([4, 2])
Exercise 2: Choosing a Loss Function and Optimizer

For each of the following two problem settings, show in code an appropriate combination of loss function and optimizer.

  1. A regression problem predicting house prices
  2. A problem classifying handwritten digits (10 classes, 0-9)

Sample solution:

import torch.nn as nn
import torch.optim as optim

# 1. Regression: output is a continuous value, so MSELoss is appropriate
regression_model = nn.Linear(13, 1)
regression_criterion = nn.MSELoss()
regression_optimizer = optim.Adam(regression_model.parameters(), lr=0.001)

# 2. 10-class classification: CrossEntropyLoss is appropriate (no Softmax on the output layer)
classification_model = nn.Linear(784, 10)
classification_criterion = nn.CrossEntropyLoss()
classification_optimizer = optim.Adam(classification_model.parameters(), lr=0.001)

print("Regression: MSELoss + Adam")
print("Classification: CrossEntropyLoss + Adam")
Exercise 3: Training and Evaluating with DataLoader

Create synthetic data for binary classification (300 samples, 5 feature dimensions) with sklearn.datasets.make_classification, and using TensorDataset and DataLoader (batch_size=16), train an MLP with input dimension 5, hidden layer [16], and output dimension 2 for 10 epochs. Finally, display the accuracy on the test data.

Sample solution:

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import TensorDataset, DataLoader
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split

torch.manual_seed(0)

X, y = make_classification(n_samples=300, n_features=5, n_classes=2, random_state=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0)

X_train = torch.tensor(X_train, dtype=torch.float32)
y_train = torch.tensor(y_train, dtype=torch.long)
X_test = torch.tensor(X_test, dtype=torch.float32)
y_test = torch.tensor(y_test, dtype=torch.long)

train_loader = DataLoader(TensorDataset(X_train, y_train), batch_size=16, shuffle=True)

class MLP(nn.Module):
    def __init__(self, input_size, hidden_sizes, output_size):
        super().__init__()
        layers = []
        prev_size = input_size
        for h in hidden_sizes:
            layers += [nn.Linear(prev_size, h), nn.ReLU()]
            prev_size = h
        layers.append(nn.Linear(prev_size, output_size))
        self.network = nn.Sequential(*layers)

    def forward(self, x):
        return self.network(x)

model = MLP(5, [16], 2)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)

for epoch in range(10):
    model.train()
    for batch_X, batch_y in train_loader:
        optimizer.zero_grad()
        loss = criterion(model(batch_X), batch_y)
        loss.backward()
        optimizer.step()

model.eval()
with torch.no_grad():
    test_acc = (model(X_test).argmax(dim=1) == y_test).float().mean().item()
print(f"Test accuracy: {test_acc:.4f}")

Summary

In this chapter, you learned the full workflow for building and training a neural network in PyTorch.

🎉 Next Steps

You've now experienced the full workflow of building a neural network from scratch, training it, saving it, and reusing it. In the upcoming Chapter 5, we'll move on to more practical model development techniques, including using GPUs, visualizing training, and countering overfitting.


Reference Resources

Disclaimer