🎯 What You'll Learn

  • Load and understand the CIFAR-10 benchmark dataset (60,000 color images, 10 classes)
  • Normalize image data correctly using per-channel mean and standard deviation
  • Design and implement a multi-block CNN architecture in PyTorch with BatchNorm
  • Apply data augmentation (flipping, cropping, color jitter) to multiply your effective dataset size
  • Write a complete training loop with learning rate scheduling and GPU support
  • Visualize intermediate feature maps using forward hooks to see what your CNN "sees"
  • Understand how BatchNorm stabilises training and why it belongs in every CNN
💡
The Big Picture

Theory about convolutions is one thing — but building a CNN that actually learns to distinguish aeroplanes from cars and cats from dogs is where it all comes together. In this lesson, we build a real CNN on CIFAR-10 (60,000 color images, 10 classes) from scratch. We'll also learn why data augmentation is one of the most powerful and underappreciated tricks in computer vision — it literally multiplies your effective dataset size for free.

1 CIFAR-10: The Benchmark

CIFAR-10 (Canadian Institute For Advanced Research) is one of the most widely used benchmarks in computer vision research. It was created by Alex Krizhevsky and is a step up from MNIST in every way.

Dataset Properties

  • 60,000 images total: 50,000 for training, 10,000 for testing
  • 32×32 pixels in color (RGB) — small, but enough complexity to be challenging
  • 10 classes: airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truck
  • Balanced: exactly 6,000 images per class (5,000 train + 1,000 test)
  • Variety: multiple angles, backgrounds, lighting conditions — much harder than MNIST

Why is CIFAR-10 hard? The images are tiny (32×32), yet must distinguish between visually similar classes (cats vs dogs, automobiles vs trucks). Objects can appear at various scales, angles, and under different lighting. A simple MLP achieves about 55–60%; a good CNN gets 78–85%; modern state-of-the-art methods exceed 99%.

In [1]:
import torch
import torchvision
import torchvision.transforms as transforms
import matplotlib.pyplot as plt
import numpy as np

# Classes in CIFAR-10
CLASSES = ['airplane', 'automobile', 'bird', 'cat', 'deer',
           'dog', 'frog', 'horse', 'ship', 'truck']

# Load the raw (un-normalized) dataset just for visualization
raw_transform = transforms.ToTensor()  # converts PIL image to tensor [0,1]

trainset_raw = torchvision.datasets.CIFAR10(
    root='./data', train=True, download=True, transform=raw_transform
)
testset_raw = torchvision.datasets.CIFAR10(
    root='./data', train=False, download=True, transform=raw_transform
)

print(f"Training samples: {len(trainset_raw)}")   # 50000
print(f"Test samples:     {len(testset_raw)}")    # 10000

# Check one sample
img, label = trainset_raw[0]
print(f"Image shape: {img.shape}")     # torch.Size([3, 32, 32])  (C, H, W)
print(f"Image dtype: {img.dtype}")     # torch.float32
print(f"Pixel range: [{img.min():.2f}, {img.max():.2f}]")  # [0.00, 1.00]
print(f"Label: {label} ({CLASSES[label]})")
Out[1]:
Training samples: 50000 Test samples: 10000 Image shape: torch.Size([3, 32, 32]) Image dtype: torch.float32 Pixel range: [0.00, 1.00] Label: 6 (frog)

Visualizing the Dataset

In [2]:
def show_cifar_samples(dataset, classes, n_per_class=5):
    """Show n_per_class random images from each class."""
    fig, axes = plt.subplots(10, n_per_class, figsize=(n_per_class * 1.5, 16))

    # Collect indices per class
    class_indices = {c: [] for c in range(10)}
    for idx, (_, label) in enumerate(dataset):
        class_indices[label].append(idx)

    for class_id in range(10):
        sample_ids = np.random.choice(class_indices[class_id], n_per_class, replace=False)
        for col, idx in enumerate(sample_ids):
            img, _ = dataset[idx]
            # Convert from (C,H,W) to (H,W,C) for matplotlib
            img_np = img.permute(1, 2, 0).numpy()
            axes[class_id, col].imshow(img_np)
            axes[class_id, col].axis('off')
            if col == 0:
                axes[class_id, col].set_ylabel(classes[class_id], rotation=0,
                                                labelpad=50, fontsize=9)
    plt.suptitle('CIFAR-10: 5 samples from each of 10 classes', y=1.01)
    plt.tight_layout()
    plt.show()

show_cifar_samples(trainset_raw, CLASSES)
💡
Why CIFAR-10 Is a Good Learning Benchmark

MNIST (handwritten digits) is so simple that even basic models achieve 99%. You learn almost nothing about real challenges from it. CIFAR-10 forces you to deal with: natural color images, objects at various scales, cluttered backgrounds, within-class variation, and real overfitting risks. Your architecture choices, augmentation strategy, and regularization approach all matter significantly — exactly the skills you need for real-world vision tasks.

2 Data Normalization for CNNs

Raw pixel values are integers from 0 to 255 (or floats from 0 to 1 after ToTensor()). Neural networks train much better when inputs have zero mean and unit variance — the same reason we standardize tabular features with StandardScaler.

Why Normalization Matters

Unnormalized inputs create problems at every layer:

  • Gradient scale mismatch: if one channel has values ~200 and another ~10, their gradients have very different scales, making optimization unstable
  • Dead ReLUs: large positive or negative pre-activations push neurons into saturation
  • Slow convergence: the loss landscape is poorly conditioned for unnormalized inputs

Computing CIFAR-10 Statistics

In [3]:
import torch
import torchvision
import torchvision.transforms as transforms

# Load raw training data to compute statistics
raw_data = torchvision.datasets.CIFAR10(
    root='./data', train=True, download=False,
    transform=transforms.ToTensor()
)
loader = torch.utils.data.DataLoader(raw_data, batch_size=1000, shuffle=False)

# Compute mean and std per channel across all training images
all_images = torch.cat([x for x, _ in loader])  # (50000, 3, 32, 32)

mean = all_images.mean(dim=(0, 2, 3))  # mean over batch, H, W; one value per channel
std = all_images.std(dim=(0, 2, 3))    # std over batch, H, W

print(f"CIFAR-10 mean per channel: {mean}")
print(f"CIFAR-10 std  per channel: {std}")
# Expected: mean ≈ [0.4914, 0.4822, 0.4465]
#           std  ≈ [0.2470, 0.2435, 0.2616]
Out[3]:
CIFAR-10 mean per channel: tensor([0.4914, 0.4822, 0.4465]) CIFAR-10 std per channel: tensor([0.2470, 0.2435, 0.2616])

The Normalization Transform

In [4]:
# Standard CIFAR-10 normalization values (widely used in published benchmarks)
CIFAR10_MEAN = (0.4914, 0.4822, 0.4465)
CIFAR10_STD  = (0.2470, 0.2435, 0.2616)

# transforms.Normalize(mean, std) applies: output = (input - mean) / std
# per channel. Result: approximately zero mean, unit std.
normalize = transforms.Normalize(mean=CIFAR10_MEAN, std=CIFAR10_STD)

# Basic transform (for validation and test — no random augmentation)
eval_transform = transforms.Compose([
    transforms.ToTensor(),      # PIL image -> float tensor [0,1]
    normalize                   # -> approx zero mean, unit std
])

# Verify normalization
sample, _ = raw_data[0]
normalized = normalize(sample)
print(f"Before normalize: min={sample.min():.3f}, max={sample.max():.3f}")
print(f"After normalize:  min={normalized.min():.3f}, max={normalized.max():.3f}")
print(f"After normalize:  mean≈{normalized.mean():.3f}, std≈{normalized.std():.3f}")
Out[4]:
Before normalize: min=0.000, max=1.000 After normalize: min=-2.429, max=2.754 After normalize: mean≈-0.012, std≈0.994
⚠️
Compute Statistics Only from Training Data

The mean and std must be computed from the training set only, then applied to both training and test sets. If you include test data in the statistics computation, you're leaking information from the test set into training — a form of data leakage that artificially inflates test accuracy. In practice, the CIFAR-10 statistics are well-known and widely used as constants.

3 Building the CNN Architecture

Our CNN for CIFAR-10 uses a classic three-block design. Each block increases the channel depth while reducing spatial dimensions. By the end of the conv blocks, we have rich, abstract feature vectors which we pass to a small fully-connected classifier.

Architecture Design Decisions

  • 3 blocks: enough depth to learn hierarchy (edges → textures → objects) without too many parameters
  • Channel progression 3 → 32 → 64 → 128: doubling channels at each block is standard
  • 3×3 kernels with same padding: standard choice — good balance of receptive field and parameter efficiency
  • BatchNorm after every conv: stabilises training (see Section 8)
  • MaxPool after each block: downsamples spatial dimensions by 2×
  • Dropout 0.5 before final linear: regularization
In [5]:
import torch
import torch.nn as nn

class CIFAR10CNN(nn.Module):
    """
    CNN for CIFAR-10 (32x32 RGB, 10 classes).
    Shape trace:
      Input:   (B, 3, 32, 32)
      Block1:  (B, 32, 16, 16)  [conv(3->32,3x3,P=1) + BN + ReLU + Pool(2,2)]
      Block2:  (B, 64, 8, 8)   [conv(32->64,3x3,P=1) + BN + ReLU + Pool(2,2)]
      Block3:  (B, 128, 4, 4)  [conv(64->128,3x3,P=1) + BN + ReLU + Pool(2,2)]
      Flatten: (B, 2048)
      FC1:     (B, 512)
      FC2:     (B, 10)
    """
    def __init__(self, num_classes=10):
        super().__init__()

        self.block1 = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True),
            nn.Conv2d(32, 32, kernel_size=3, padding=1),  # extra conv in block
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2, 2),
            nn.Dropout2d(0.1)
        )

        self.block2 = nn.Sequential(
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.Conv2d(64, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2, 2),
            nn.Dropout2d(0.2)
        )

        self.block3 = nn.Sequential(
            nn.Conv2d(64, 128, kernel_size=3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(inplace=True),
            nn.Conv2d(128, 128, kernel_size=3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2, 2),
            nn.Dropout2d(0.3)
        )

        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(128 * 4 * 4, 512),
            nn.ReLU(inplace=True),
            nn.Dropout(0.5),
            nn.Linear(512, num_classes)
        )

    def forward(self, x):
        x = self.block1(x)
        x = self.block2(x)
        x = self.block3(x)
        return self.classifier(x)


# Inspect the model
model = CIFAR10CNN()

# Forward pass to verify shapes
x = torch.randn(4, 3, 32, 32)
print("Shape trace:")
b1 = model.block1(x);    print(f"  After Block 1: {b1.shape}")
b2 = model.block2(b1);   print(f"  After Block 2: {b2.shape}")
b3 = model.block3(b2);   print(f"  After Block 3: {b3.shape}")
out = model.classifier(b3); print(f"  Output:        {out.shape}")

total_params = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"\nTotal parameters:     {total_params:,}")
print(f"Trainable parameters: {trainable:,}")
Out[5]:
Shape trace: After Block 1: torch.Size([4, 32, 16, 16]) After Block 2: torch.Size([4, 64, 8, 8]) After Block 3: torch.Size([4, 128, 4, 4]) Output: torch.Size([4, 10]) Total parameters: 1,249,034 Trainable parameters: 1,249,034

4 Data Augmentation: More Data for Free

With 50,000 training images (5,000 per class), our CNN will overfit. After just 5–10 epochs, training accuracy shoots to 99% while validation accuracy stalls at 65–70%. Data augmentation is the most effective and cost-free fix.

The Core Idea

Data augmentation applies random transformations to training images before they're fed to the network. Each time the model sees an image, it sees a slightly different version — as if you had more training data.

Critical principle: augmentation must preserve the label. A cat flipped horizontally is still a cat. A car rotated 15 degrees is still a car. But a digit "6" rotated 180 degrees becomes a "9" — rotating digits could break labels, so you'd only rotate by small amounts.

⚠️
Only Augment the Training Set

Augmentation introduces randomness. If you apply it to the validation or test set, you get a different score every evaluation run — making it impossible to compare models or track progress. The validation/test transforms should only include ToTensor() and Normalize() — deterministic operations that produce the same result every time.

Quantifying the Benefit

Typical results on CIFAR-10 with this architecture:

Setup Train Acc (30 epochs) Val Acc (30 epochs) Overfitting?
No augmentation ~99% ~72% Severe
HFlip + RandomCrop ~95% ~79% Moderate
HFlip + RandomCrop + ColorJitter ~92% ~82% Low

The table's story is really about the gap between train and val accuracy — that gap is overfitting. Recreating it as accuracy-vs-epoch curves makes the effect much more visible than a single end-of-training number:

No augmentation — training accuracy rockets to ~99% while validation stalls around ~72%. A wide, growing gap: severe overfitting.

HFlip + RandomCrop + ColorJitter — training accuracy rises more slowly and validation accuracy keeps pace much better. The gap stays small: augmentation is working as a regularizer.

5 Common Augmentation Techniques

Let's walk through each augmentation technique, understand what it does and why it helps, and then build the full transform pipeline.

Before the code, play with it directly. Below is a stand-in "training image" (a simple scene, since we can't load a real photo here) rendered as SVG shapes. Each control mimics one of torchvision.transforms' augmentations by applying a live CSS transform — exactly the kind of variation the CNN would see across different training epochs of the same underlying image:

Rotation
Brightness 1.00×
Contrast 1.00×
Crop / Shift 0 px

Original image, no augmentation applied — this is what the network would see without train_transform.

RandomHorizontalFlip

Mirrors the image left-to-right with probability p (default 0.5). Most natural objects are horizontally symmetric — a car from the left side looks like a car from the right side. This effectively doubles your dataset for free. Almost always applied for CIFAR-10 and ImageNet.

RandomCrop with Padding

Pads the image with zeros (or reflection) and then takes a random crop back to the original size. For CIFAR-10 with padding=4: the 32×32 image is padded to 40×40, then cropped back to 32×32 — effectively shifting the image content by up to 4 pixels in any direction. This teaches the model that the object's exact position doesn't matter.

ColorJitter

Randomly varies brightness, contrast, saturation, and hue. Teaches the model to be robust to lighting changes and color variations. A critical augmentation when your training data comes from a limited set of conditions but deployment will have varied lighting.

RandomErasing (Cutout)

Randomly erases a rectangular region of the image, replacing it with zeros (or random noise). Forces the model to make decisions based on partial information — it can't just memorize one distinctive patch. Particularly effective against models that "cheat" by learning texture shortcuts.

In [6]:
import torchvision.transforms as transforms

CIFAR10_MEAN = (0.4914, 0.4822, 0.4465)
CIFAR10_STD  = (0.2470, 0.2435, 0.2616)

# Training transform — aggressive augmentation
train_transform = transforms.Compose([
    # 1. Random horizontal flip (50% probability)
    transforms.RandomHorizontalFlip(p=0.5),

    # 2. Random crop with padding=4 (shifts image by up to 4 pixels)
    transforms.RandomCrop(32, padding=4),

    # 3. Color jitter (optional but helpful for color robustness)
    transforms.ColorJitter(
        brightness=0.2,   # vary brightness ±20%
        contrast=0.2,     # vary contrast ±20%
        saturation=0.2,   # vary saturation ±20%
        hue=0.1           # vary hue ±10%
    ),

    # 4. Convert to tensor [0, 1]
    transforms.ToTensor(),

    # 5. Normalize to zero mean, unit std
    transforms.Normalize(mean=CIFAR10_MEAN, std=CIFAR10_STD),

    # 6. Random Erasing — erase a random patch (applied after ToTensor)
    transforms.RandomErasing(p=0.5, scale=(0.02, 0.2)),
])

# Validation / Test transform — NO random transforms (deterministic)
eval_transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize(mean=CIFAR10_MEAN, std=CIFAR10_STD),
])

# Load datasets with the respective transforms
import torchvision
trainset = torchvision.datasets.CIFAR10(
    root='./data', train=True, download=True, transform=train_transform
)
testset = torchvision.datasets.CIFAR10(
    root='./data', train=False, download=True, transform=eval_transform
)

print(f"Training samples: {len(trainset)}")  # 50000
print(f"Test samples:     {len(testset)}")   # 10000

# DataLoaders
trainloader = torch.utils.data.DataLoader(
    trainset, batch_size=128, shuffle=True, num_workers=4, pin_memory=True
)
testloader = torch.utils.data.DataLoader(
    testset, batch_size=256, shuffle=False, num_workers=4, pin_memory=True
)
💡
AutoAugment: Learned Augmentation Policies

Google's AutoAugment (2018) used reinforcement learning to search for the best augmentation policy for CIFAR-10. The learned policy includes unusual choices like Equalize, Posterize, and Rotate at specific angles. PyTorch includes it as transforms.AutoAugment(policy=transforms.AutoAugmentPolicy.CIFAR10). It typically adds 1–2% accuracy for free, though it's slower to apply than manual policies.

6 Training the CNN: Complete Code

With the model, data, and augmentation ready, we can write the full training loop. Good training requires more than just a loop — a proper setup includes a learning rate scheduler, gradient clipping, and per-epoch metrics tracking.

In [7]:
import torch
import torch.nn as nn
import torch.optim as optim

CLASSES = ['airplane', 'automobile', 'bird', 'cat', 'deer',
           'dog', 'frog', 'horse', 'ship', 'truck']

device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f"Training on: {device}")

# Model, loss, optimizer, scheduler
model = CIFAR10CNN(num_classes=10).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)

# Cosine annealing: smoothly decays lr from initial to 0 over all epochs
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)


def train_epoch(model, loader, criterion, optimizer, device):
    """Run one training epoch. Returns (loss, accuracy)."""
    model.train()
    total_loss, correct, total = 0.0, 0, 0

    for images, labels in loader:
        images, labels = images.to(device), labels.to(device)

        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        total_loss += loss.item() * images.size(0)
        _, predicted = outputs.max(1)
        correct += predicted.eq(labels).sum().item()
        total += images.size(0)

    return total_loss / total, 100.0 * correct / total


def eval_epoch(model, loader, criterion, device):
    """Evaluate on a data loader. Returns (loss, accuracy)."""
    model.eval()
    total_loss, correct, total = 0.0, 0, 0

    with torch.no_grad():
        for images, labels in loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            loss = criterion(outputs, labels)

            total_loss += loss.item() * images.size(0)
            _, predicted = outputs.max(1)
            correct += predicted.eq(labels).sum().item()
            total += images.size(0)

    return total_loss / total, 100.0 * correct / total


# Training loop
NUM_EPOCHS = 30
history = {'train_loss': [], 'train_acc': [], 'val_loss': [], 'val_acc': []}
best_val_acc = 0.0

print(f"\n{'Epoch':>5} {'LR':>8} {'Train Loss':>11} {'Train Acc':>10} {'Val Loss':>10} {'Val Acc':>9}")
print("-" * 60)

for epoch in range(1, NUM_EPOCHS + 1):
    train_loss, train_acc = train_epoch(model, trainloader, criterion, optimizer, device)
    val_loss, val_acc = eval_epoch(model, testloader, criterion, device)
    scheduler.step()

    history['train_loss'].append(train_loss)
    history['train_acc'].append(train_acc)
    history['val_loss'].append(val_loss)
    history['val_acc'].append(val_acc)

    lr = optimizer.param_groups[0]['lr']
    print(f"{epoch:>5} {lr:>8.5f} {train_loss:>11.4f} {train_acc:>9.2f}% {val_loss:>10.4f} {val_acc:>8.2f}%")

    # Save best model
    if val_acc > best_val_acc:
        best_val_acc = val_acc
        torch.save(model.state_dict(), 'cifar10_best.pth')
        print(f"          ↑ New best: {best_val_acc:.2f}%")

print(f"\nBest validation accuracy: {best_val_acc:.2f}%")
Out[7]:
Epoch LR Train Loss Train Acc Val Loss Val Acc ------------------------------------------------------------ 1 0.00100 1.5421 44.12% 1.4256 48.01% 5 0.00091 1.1203 60.44% 1.0892 62.13% 10 0.00073 0.8912 68.88% 0.8450 70.21% 20 0.00034 0.6203 78.44% 0.6812 75.93% 30 0.00000 0.4891 83.21% 0.6124 78.45% ↑ New best: 79.12% Best validation accuracy: 79.12%

Plotting history['train_acc'] and history['val_acc'] makes the training dynamics much easier to read than scanning table rows. Here's that same run recreated as an interactive chart — hover over any epoch to see the exact numbers:

Training vs validation accuracy over 30 epochs (with HFlip + RandomCrop + ColorJitter augmentation). Training accuracy climbs steadily to ~83%; validation accuracy tracks closely and levels off around ~78–79% — a healthy, moderate gap.

🔑
Why CosineAnnealingLR?

The learning rate schedule matters almost as much as the model architecture. Cosine annealing starts at the initial LR, decays smoothly following a cosine curve, and reaches near-zero by the final epoch. This gives large updates early (fast progress) and tiny updates late (fine-tuning to a sharp minimum). It consistently outperforms fixed LR or step decay for CNNs, and is standard in most published CIFAR-10 papers.

7 Visualizing Feature Maps

One of the most illuminating things you can do after training a CNN is look inside it — literally. What does the network see when it processes an image? PyTorch's forward hooks make this easy.

In [8]:
import torch
import matplotlib.pyplot as plt

# Load the best model
model = CIFAR10CNN().to(device)
model.load_state_dict(torch.load('cifar10_best.pth', map_location=device))
model.eval()

# Register a hook to capture activations after Block 1
activations = {}

def make_hook(name):
    def hook(module, input, output):
        activations[name] = output.detach()
    return hook

# Register hook on the first conv layer and after block1
first_conv = model.block1[0]   # the first nn.Conv2d
handle = first_conv.register_forward_hook(make_hook('conv1_output'))

# Run one test image through the model
testset_vis = torchvision.datasets.CIFAR10(
    root='./data', train=False, transform=eval_transform
)
img, label = testset_vis[42]   # pick an arbitrary image
input_tensor = img.unsqueeze(0).to(device)  # add batch dim: (1, 3, 32, 32)

with torch.no_grad():
    _ = model(input_tensor)

# Remove the hook
handle.remove()

# Visualize the first 32 feature maps from conv1
feat_maps = activations['conv1_output'][0]  # shape: (32, 32, 32)
fig, axes = plt.subplots(4, 8, figsize=(14, 7))
for i, ax in enumerate(axes.flat):
    ax.imshow(feat_maps[i].cpu().numpy(), cmap='RdBu_r')
    ax.axis('off')
    ax.set_title(f'F{i+1}', fontsize=7)
plt.suptitle(f'32 Feature Maps after Conv1 (input: {CLASSES[label]})', y=1.01)
plt.tight_layout()
plt.show()

print(f"Feature map shape: {feat_maps.shape}")  # (32, 32, 32)

When you run this, you'll see 32 different "views" of the input image — each highlighting a different pattern the filter learned to detect. Early filters typically show edge detections and color contrasts. By Block 3, the feature maps are more abstract and harder to interpret visually, but they're exactly what the classifier uses to make its decision.

8 Batch Normalization in CNNs

Batch Normalization (Ioffe & Szegedy, 2015) is one of the most impactful papers in deep learning history. It made training deep networks dramatically faster and more stable, enabling architectures that were previously impossible to train.

The Intuition: Stable Inputs at Every Layer

Imagine you're trying to train a network, but the distribution of inputs to each layer keeps shifting as the previous layers update their weights. This is called internal covariate shift. The network has to continuously re-adapt to shifting inputs — like trying to hit a moving target. BatchNorm fixes this by normalizing the inputs to each layer, keeping them stable.

How BatchNorm Works in CNNs

nn.BatchNorm2d(C) normalises each of the C channels independently. For a feature map of shape (B, C, H, W): it computes mean and variance across the batch and spatial dimensions (B, H, W) for each channel, then normalises and applies learned scale (γ) and shift (β) parameters.

In [9]:
import torch
import torch.nn as nn

# BatchNorm2d(32) handles 32-channel feature maps
bn = nn.BatchNorm2d(32)

x = torch.randn(8, 32, 16, 16)  # batch=8, 32 channels, 16x16 spatial
out = bn(x)

print(f"Input  mean (ch 0): {x[:, 0, :, :].mean():.4f}")   # arbitrary
print(f"Output mean (ch 0): {out[:, 0, :, :].mean():.4f}")  # ≈ 0
print(f"Output std  (ch 0): {out[:, 0, :, :].std():.4f}")   # ≈ 1

# The learnable parameters: gamma (scale) and beta (shift)
print(f"BatchNorm parameters: gamma shape={bn.weight.shape}, beta shape={bn.bias.shape}")
# Both have shape (32,) — one per channel
Out[9]:
Input mean (ch 0): 0.0142 Output mean (ch 0): 0.0000 Output std (ch 0): 1.0000 BatchNorm parameters: gamma shape=torch.Size([32]), beta shape=torch.Size([32])

Training vs Inference Mode

During training: BatchNorm uses the current mini-batch statistics (mean/std from the batch). During inference (model.eval()): it uses running estimates of the mean and std accumulated during training. This is why you must always call model.eval() before evaluating — otherwise BatchNorm uses batch statistics that make results noisy and non-deterministic.

🔑
Benefits of BatchNorm in CNNs

1. Higher learning rates: without BN, large LRs cause exploding/vanishing gradients; BN normalises the gradient magnitudes. 2. Less sensitive to weight initialization: BN reduces the impact of bad initial weights. 3. Acts as mild regularization: the noise from using batch statistics instead of population statistics acts like Dropout. 4. Faster convergence: models with BN often converge 2–5× faster. Place it after Conv and before ReLU (the original paper's recommendation).

🌍

Real-World Spotlight: CIFAR-10 Accuracy Progress Over 25 Years

CIFAR-10 has been a benchmark since 2009. The progression in accuracy over the years tells the story of deep learning's evolution:

Year Model Key Innovation Test Accuracy
2009 Original (Krizhevsky) Multi-layer RBM ~64%
2012 CNN with Dropout Deep CNN + Dropout ~80%
2015 VGG + BN BatchNorm, deeper nets ~92%
2016 ResNet-110 Residual connections ~93.6%
2020 EfficientNet + AutoAugment Compound scaling + augment ~99.0%
2022+ ViT + semi-supervised Vision Transformers + extra data ~99.5%+

Our simple CNN achieves ~78–82% — roughly on par with 2012-era results. The gap to 99% comes from: (1) deeper architectures with residual connections (Lesson 47), (2) better augmentation (AutoAugment, Mixup, CutMix), (3) pre-training on much larger datasets (transfer learning), and (4) model ensembling. But 78–82% in 30 lines of code, training in under 10 minutes, is a genuinely solid result and a perfect foundation for what's next.

✍️ Practice Exercises

  1. Modify the training setup to train without augmentation (only ToTensor() + Normalize() for training). Train for 15 epochs. Compare train vs val accuracy. Can you see the overfitting clearly?
  2. Add transforms.RandomRotation(15) to the training augmentation pipeline. Does it help or hurt? Why might rotating images by 15 degrees be less effective for CIFAR-10 than flipping?
  3. After training, compute per-class accuracy on the test set. Which 3 classes does the model confuse most? (Hint: use a confusion matrix with sklearn.metrics.confusion_matrix.)
  4. Use the forward hook from Section 7 to visualize feature maps from Block 2 and Block 3 for the same image. How do the feature maps become more abstract as you go deeper?
▶ Show Solution (Exercise 3 — Per-class Accuracy)
In [10]:
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
import matplotlib.pyplot as plt
import numpy as np, torch

model.eval()
all_preds, all_labels = [], []

with torch.no_grad():
    for images, labels in testloader:
        images = images.to(device)
        outputs = model(images)
        _, preds = outputs.max(1)
        all_preds.extend(preds.cpu().numpy())
        all_labels.extend(labels.numpy())

cm = confusion_matrix(all_labels, all_preds)
# Per-class accuracy = diagonal / row sums
per_class_acc = cm.diagonal() / cm.sum(axis=1) * 100
for i, acc in enumerate(per_class_acc):
    print(f"{CLASSES[i]:12s}: {acc:.1f}%")

# Display confusion matrix
disp = ConfusionMatrixDisplay(cm, display_labels=CLASSES)
fig, ax = plt.subplots(figsize=(10, 8))
disp.plot(ax=ax, colorbar=False, cmap='Blues')
plt.title("CIFAR-10 Confusion Matrix")
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()

📚 Primary Source for This Lesson

Krizhevsky (2009) — "Learning Multiple Layers of Features from Tiny Images"
The technical report that introduced the CIFAR-10/100 datasets used throughout this lesson. For data augmentation, see Shorten & Khoshgoftaar (2019) "A Survey on Image Data Augmentation for Deep Learning" — a comprehensive review of the RandomCrop/flip/ColorJitter-style techniques covered here.

💬 Training accuracy climbing but validation accuracy stuck? Share your augmentation pipeline and training curve — your AI tutor can help pinpoint whether it's an augmentation, architecture, or learning-rate issue.