Building models with nn.Module
What is nn.Module?
Why Subclass Instead of Functions?
Function approach (naive):
def my_layer(x, w, b):
return x @ w + b❌ Problems:
- Parameters (w, b) must be passed explicitly everywhere
- No automatic gradient tracking
- Can't easily save/load models
- No way to switch between train/eval modes
Module approach:
class MyLayer(nn.Module):
def __init__(self, in_features, out_features):
super().__init__()
self.weight = nn.Parameter(torch.randn(in_features, out_features))
self.bias = nn.Parameter(torch.randn(out_features))
def forward(self, x):
return x @ self.weight + self.bias✅ Benefits:
- Parameters stored inside, gradient tracking automatic
- Can call
.parameters()to get all learnable tensors - Can
.to(device)to move everything - Composable with other modules
Core Architecture Pattern
The standard pattern for building a custom module:
import torch.nn as nn
class MyNetwork(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
# Step 1: Call parent constructor
super().__init__() # or super(MyNetwork, self).__init__()
# Step 2: Define submodules and parameters
self.layer1 = nn.Linear(input_dim, hidden_dim)
self.activation = nn.ReLU()
self.layer2 = nn.Linear(hidden_dim, output_dim)
# Step 3: (Optional) Custom parameters
self.custom_param = nn.Parameter(torch.randn(10))
def forward(self, x):
# Step 4: Define computation graph
x = self.layer1(x)
x = self.activation(x)
x = self.layer2(x)
return xWorked Example 1: Simple Feedforward Network
Task: Build a 3-layer feedforward network for binary classification.
import torch
import torch.nn as nn
class SimpleClassifier(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__()
# Why nn.Linear and not manual weight matrices?
# nn.Linear handles initialization (Kaiming/Xavier) and bias correctly
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.fc3 = nn.Linear(hidden_dim, output_dim)
# Why ReLU as an attribute?
# For modes like Dropout/BatchNorm, storing as attribute enables train/eval switching
# For ReLU (stateless), it's style preference, but consistent pattern
self.relu = nn.ReLU()
def forward(self, x):
# x: [batch_size, input_dim]
# Why this step? Linear transform + nonlinearity
x = self.relu(self.fc1(x)) # [batch_size, hidden_dim]
# Why second hidden layer? Increases model capacity for complex boundaries
x = self.relu(self.fc2(x)) # [batch_size, hidden_dim]
# Why no activation on output? Loss function (BCEWithLogitsLoss) expects logits
x = self.fc3(x) # [batch_size, output_dim]
return x
# Usage
model = SimpleClassifier(input_dim=784, hidden_dim=128, output_dim=1)
# Why this works: Forward is called automatically
input_data = torch.randn(32, 784) # Batch of 32 images (28×28 flattened)
output = model(input_data) # Calls model.forward(input_data) via __call__
print(f"Parameters: {sum(p.numel() for p in model.parameters())}")
# Output: Parameters: 117121
# Why? (784×128 + 128) + (128×128 + 128) + (128×1 + 1)
# = 100480 + 16512 + 129 = 117121Why this step? Breaking down parameter count:
- Layer 1 (fc1): Weight matrix parameters, bias parameters → subtotal
- Layer 2 (fc2): Weight matrix parameters, bias parameters → subtotal
- Layer 3 (fc3): Weight matrix parameters, bias parameter → subtotal
- Total: parameters ✅
Worked Example 2: Residual Block with Skip Connections
Task: Implement a ResNet-style residual block:
class ResidualBlock(nn.Module):
def __init__(self, dim):
super().__init__()
# Why two layers in F(x)? Standard residual block pattern
self.conv1 = nn.Conv2d(dim, dim, kernel_size=3, padding=1)
self.bn1 = nn.BatchNorm2d(dim)
self.conv2 = nn.Conv2d(dim, dim, kernel_size=3, padding=1)
self.bn2 = nn.BatchNorm2d(dim)
self.relu = nn.ReLU(inplace=True)
def forward(self, x):
# Save input for skip connection
identity = x # [batch, dim, height, width]
# Why this path? F(x) = BN(Conv(BN(Conv(x))))
out = self.conv1(x) # [batch, dim, H, W]
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out) # [batch, dim, H, W]
out = self.bn2(out)
# Why add before final ReLU? Allows gradient to flow through skip connection
# Derivation: ∂Loss/∂x = ∂Loss/∂out × (∂F(x)/∂x + ∂identity/∂x)
# = ∂Loss/∂out × (∂F(x)/∂x + I)
# The +I term prevents vanishing gradients!
out = out + identity
out = self.relu(out)
return outWhy skip connections mathematically?
Without skip:
With skip:
The "+1" ensures gradient magnitude≥ 1 even if , solving vanishing gradients in deep networks.
Worked Example 3: Variable-Length Module Lists
Task: Build a network with arbitrary depth specified at initialization.
class DeepNetwork(nn.Module):
def __init__(self, input_dim, hidden_dim, num_layers, output_dim):
super().__init__()
# Why nn.ModuleList and not Python list?
# Python list: layers = [nn.Linear(...), nn.Linear(...)]
# Problem: Parameters NOT registered, won't be trained!
# Why this pattern? First layer changes dimension
self.input_layer = nn.Linear(input_dim, hidden_dim)
# Why nn.ModuleList? Registers all contained modules automatically
self.hidden_layers = nn.ModuleList([
nn.Linear(hidden_dim, hidden_dim)
for _ in range(num_layers)
])
self.output_layer = nn.Linear(hidden_dim, output_dim)
self.relu = nn.ReLU()
def forward(self, x):
x = self.relu(self.input_layer(x))
# Why iterate through ModuleList? Apply each hidden layer sequentially
for layer in self.hidden_layers:
x = self.relu(layer(x))
x = self.output_layer(x)
return x
# Verify registration
model = DeepNetwork(input_dim=10, hidden_dim=64, num_layers=5, output_dim=2)
print(f"Registered modules: {len(list(model.modules()))}")
# Output: Registered modules: 10
# Why? 1 (root) + 1 (input_layer) + 1 (ModuleList container) + 5 (hidden layers)
# + 1 (output_layer) + 1 (ReLU) = 10Parameter vs. Buffer Registration
class NormalizationLayer(nn.Module):
def __init__(self, num_features):
super().__init__()
# Learnable parameters
self.gamma = nn.Parameter(torch.ones(num_features))
self.beta = nn.Parameter(torch.zeros(num_features))
# Non-learnable buffers (running statistics)
# Why register_buffer? These should move with .to(device) and save with state_dict
# but NOT be trained by optimizer
self.register_buffer('running_mean', torch.zeros(num_features))
self.register_buffer('running_var', torch.ones(num_features))
self.register_buffer('num_batches_tracked', torch.tensor(0))
def forward(self, x):
if self.training:
# Update running statistics (exponential moving average)
batch_mean = x.mean(dim=0)
batch_var = x.var(dim=0)
# Why 0.1 momentum? Balances responsiveness vs. stability
self.running_mean = 0.9 * self.running_mean + 0.1 * batch_mean
self.running_var = 0.9 * self.running_var + 0.1 * batch_var
# Normalize using batch statistics
x_norm = (x - batch_mean) / torch.sqrt(batch_var + 1e-5)
else:
# Why use running stats in eval? No batch statistics available at inference
x_norm = (x - self.running_mean) / torch.sqrt(self.running_var + 1e-5)
# Why affine transform? Allows network to undo normalization if needed
return self.gamma * x_norm + self.betaWhy buffers matter:
.parameters()→ only[gamma, beta](2 tensors).state_dict()→ includesgamma, beta, running_mean, running_var, num_batches_tracked(5 tensors)- Without
register_buffer, running statistics would be lost when saving the model!
The Module Lifecycle
model = SimpleClassifier(784, 128, 10)
# Training loop
model.train() # Sets self.training = True for all submodules
for batch in train_loader:
optimizer.zero_grad()
output = model(batch) # Dropout active, BatchNorm updates running stats
loss = criterion(output, targets)
loss.backward()
optimizer.step()
# Evaluation
model.eval() # Sets self.training = False for all submodules
with torch.no_grad(): # Disables gradient computation (saves memory)
for batch in test_loader:
output = model(batch) # Dropout off, BatchNorm uses running stats
# No backward passAdvanced Pattern: Custom Initialization
class CustomInitNetwork(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, output_dim)
# Why custom initialization? Default may not suit your problem
self._initialize_weights()
def _initialize_weights(self):
# Why iterate modules()? Gets all submodules recursively
for m in self.modules():
if isinstance(m, nn.Linear):
# Xavier initialization for linear layers
# Why Xavier? Maintains variance across layers
# Derivation: For linear layer Y = WX, Var(Y) = n_in × Var(W) × Var(X)
# To keep Var(Y) = Var(X), need Var(W) = 1/n_in
nn.init.xavier_uniform_(m.weight)
# Why constant bias? Start with no bias shift
nn.init.constant_(m.bias, 0)
elif isinstance(m, nn.Conv2d):
# He initialization for ReLU activations
# Why He? ReLU zeros half the activations, needs √2 scaling
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
if m.bias is not None:
nn.init.constant_(m.bias, 0)
def forward(self, x):
x = torch.relu(self.fc1(x))
x = self.fc2(x)
return xInitialization derivation:
For Xavier: Given where
To maintain , need .
Uniform distribution: ,
Set
Therefore:
Recall Explain to a 12-year-old
Imagine you're building a LEGO castle. Each LEGO piece is an nn.Module:
- Tiny bricks (like
nn.Linear) are simple pieces that do one thing - Wall sections (like a ResidualBlock) are made by combining tiny bricks
- Towers (like a CNN
Concept Map
Hinglish (regional understanding)
Intuition Hinglish mein samjho
Hinglish (regional understanding)
Intuition Hinglish mein samjho
chalo, isko simple tareeke se samajhte hain. Dekho, PyTorch mein neural network banane ka jo fundamental building block hai, usko bolte hain nn.Module. Ise samajhne ka sabse aasaan tareeka hai Lego bricks wala example. Har ek module ek chota sa self-contained piece hai jo teen kaam karna jaanta hai: apne learnable parameters (jaise weights aur biases) store karna, input data pe ek transformation apply karna jise forward pass bolte hain, aur dusre modules ke saath jud kar bade complex networks banana. Core insight yeh hai ki neural network basically ek nested function hai jaise f(x) = f3(f2(f1(x))) — har chota function ek module hai, aur pura network bhi ek module hai. Isi composability wali baat mein saari khoobsurti chhupi hai.
Ab sawaal aata hai ki hum plain functions ki jagah nn.Module ko subclass kyun karte hain? Agar tum simple function likho jaise my_layer(x, w, b), toh problem yeh hai ki tumhe weights aur biases har baar manually pass karne padte hain, gradient tracking automatic nahi hoti, model save/load karna mushkil ho jaata hai, aur train vs eval mode switch karna toh bilkul aasaan nahi rehta. Lekin jab tum nn.Module use karte ho, toh saare parameters module ke andar hi store ho jaate hain, gradient tracking apne aap ho jaati hai, tum .parameters() call karke saare learnable tensors nikaal sakte ho, aur .to(device) se ek hi baar mein sab kuch GPU ya CPU pe move kar sakte ho. Yeh sab magic tab shuru hota hai jab tum super().__init__() call karte ho — yeh ek special __setattr__ mechanism set karta hai jo tumhare har attribute assignment ko track karta hai.
Toh yeh baat matter kyun karti hai? Kyunki jab tum __init__ ke andar self.layer1 = nn.Linear(...) likhte ho, PyTorch automatically samajh jaata hai ki yeh ek submodule hai aur ise register kar leta hai. Baad mein jab .parameters() call hota hai, toh yeh recursively pure submodule tree pe chal kar saare parameters collect kar leta hai. Iska matlab tumhe manually kabhi bhi yaad nahi rakhna padta ki kaun se weights train karne hain — framework khud handle kar leta hai. Yehi reason hai ki har real-world deep learning model, chahe simple classifier ho ya massive transformer, yeh sab nn.Module se hi inherit karte hain. Ek baar yeh pattern samajh gaye, toh koi bhi complex architecture banana bas Lego bricks jodne jitna aasaan lagega.