PyTorch Convolutional Neural Networks
Build CNNs for image classification using PyTorch — convolutional layers, pooling, batch norm, and training on real image datasets.
Real-World Scenario
A manufacturing company runs visual quality inspection on an assembly line. A CNN trained on 50,000 defect images classifies parts as pass/fail at 120ms per image — fast enough for real-time production line decisions. CNNs are the workhorse behind every image recognition system in production.
How Convolutions Work
import torch
import torch.nn as nn
# A 2D convolution layer
# in_channels=1 (grayscale), out_channels=32 (learn 32 filters), kernel_size=3
conv = nn.Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1)
# Input: batch of 8 grayscale 28x28 images
x = torch.randn(8, 1, 28, 28)
output = conv(x)
print(f"Input: {x.shape}") # (8, 1, 28, 28)
print(f"Output: {output.shape}") # (8, 32, 28, 28) — padding=1 preserves spatial dims
# Pooling — reduces spatial dimensions, extracts dominant features
pool = nn.MaxPool2d(kernel_size=2, stride=2)
pooled = pool(output)
print(f"After pooling: {pooled.shape}") # (8, 32, 14, 14)
Building a CNN for MNIST
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
class MNISTConvNet(nn.Module):
def __init__(self):
super().__init__()
# Conv block 1: 1 → 32 feature maps, 28x28 → 14x14
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
self.bn1 = nn.BatchNorm2d(32)
# Conv block 2: 32 → 64 feature maps, 14x14 → 7x7
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.bn2 = nn.BatchNorm2d(64)
# Classifier head
self.dropout = nn.Dropout(0.5)
self.fc1 = nn.Linear(64 * 7 * 7, 256)
self.fc2 = nn.Linear(256, 10)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Block 1
x = F.relu(self.bn1(self.conv1(x))) # (B, 32, 28, 28)
x = F.max_pool2d(x, 2) # (B, 32, 14, 14)
# Block 2
x = F.relu(self.bn2(self.conv2(x))) # (B, 64, 14, 14)
x = F.max_pool2d(x, 2) # (B, 64, 7, 7)
# Flatten and classify
x = x.view(x.size(0), -1) # (B, 64*7*7 = 3136)
x = F.relu(self.fc1(self.dropout(x)))
return self.fc2(x) # logits: (B, 10)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = MNISTConvNet().to(device)
# Count parameters
params = sum(p.numel() for p in model.parameters())
print(f"Parameters: {params:,}") # ~820k
# Data loading with augmentation
transform_train = transforms.Compose([
transforms.RandomRotation(10),
transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)),
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)),
])
transform_test = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)),
])
train_ds = datasets.MNIST("./data", train=True, download=True, transform=transform_train)
test_ds = datasets.MNIST("./data", train=False, download=True, transform=transform_test)
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=2, pin_memory=True)
test_loader = DataLoader(test_ds, batch_size=256, shuffle=False, num_workers=2, pin_memory=True)
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2)
scheduler = optim.lr_scheduler.OneCycleLR(
optimizer, max_lr=1e-2, steps_per_epoch=len(train_loader), epochs=10
)
criterion = nn.CrossEntropyLoss()
def epoch_pass(model, loader, optimizer=None, criterion=None):
training = optimizer is not None
model.train() if training else model.eval()
total_loss, correct, total = 0.0, 0, 0
with torch.set_grad_enabled(training):
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
logits = model(images)
if training:
loss = criterion(logits, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
scheduler.step()
total_loss += loss.item() * len(labels)
correct += (logits.argmax(1) == labels).sum().item()
total += len(labels)
return total_loss / total if training else None, correct / total
for epoch in range(10):
_, train_acc = epoch_pass(model, train_loader, optimizer, criterion)
_, test_acc = epoch_pass(model, test_loader)
print(f"Epoch {epoch+1:2d}: Train={train_acc:.3f} Test={test_acc:.3f}")
Transfer Learning with Pretrained Models
import torch
import torch.nn as nn
import torchvision.models as models
from torchvision import transforms
from torch.utils.data import DataLoader
from torchvision.datasets import ImageFolder
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Load ResNet18 pretrained on ImageNet — 11M parameters already trained
model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
# Freeze all layers — we only want to train the final classifier
for param in model.parameters():
param.requires_grad = False
# Replace the final layer for our task (e.g., 5-class defect classification)
num_classes = 5
model.fc = nn.Linear(model.fc.in_features, num_classes) # new layer has requires_grad=True
model = model.to(device)
# Only optimize the new final layer — much faster, needs less data
optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)
# ImageNet normalization — required for pretrained ResNet
transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
# After training the head, unfreeze and fine-tune the whole network
def fine_tune_all(model, new_lr=1e-5):
"""Unfreeze all layers for end-to-end fine-tuning."""
for param in model.parameters():
param.requires_grad = True
return torch.optim.Adam(model.parameters(), lr=new_lr)
# Verify only the new layer has gradients initially
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:,} / {total:,} parameters ({100*trainable/total:.1f}%)") Frequently Asked Questions
What does a convolutional layer actually do?
A convolutional layer slides a small learnable filter (kernel) across the input, computing dot products at each position. This detects local patterns — edges, textures, shapes — while sharing weights across all spatial positions. Stacking convolutions learns increasingly abstract features: edges → corners → shapes → objects.
What is the difference between padding='same' and padding='valid'?
padding='valid' (no padding) reduces spatial dimensions after each convolution — a 32x32 input with a 3x3 kernel becomes 30x30. padding='same' (zero-padding) preserves spatial dimensions. Use same to control where you reduce resolution (at pooling layers), not at convolution layers.