PyTorch

Build in g a CNN from Scratch: Convolution Layers, Pooling, Batch Normalization, and CIFAR-10

CNNs are not magic—they're learnable feature detectors. Build one layer-by-layer on CIFAR-10, understanding what each component does and why it matters.

SS
Soham Sharma
AI Engineer, Botmartz · July 17, 2026 · 7 min read
Read Time
7 min
Failure Modes
5
Code Snippets
3
Runnable Notebook
1
Building a CNN from Scratch: Convolution Layers, Pooling, Batch Normalization, and CIFAR-10

A convolutional neural network is a hierarchy of learned feature detectors. The first layer learns edges, the second layer learns shapes, the third layer learns objects. But most practitioners treat CNNs as a black box: copy a ResNet, change the final layer, and hope. This post builds a 4-layer CNN from the ground up on CIFAR-10, explaining why each component (convolution, pooling, batch norm, nonlinearity) exists and what happens if you remove it.

Convolution: A Sliding Window Detector

A convolution applies a learnable kernel (filter) across spatial positions. At each position, the kernel computes a dot product with the local patch. The result is a feature map—one number per position, showing how much that patch "matches" the learned pattern.

import torch

import torch.nn as nn

# A 3x3 kernel on a 5x5 image image = torch.arange(25, dtype=torch.float32).reshape(1, 1, 5, 5) print("Input image:") print(image.squeeze())

# Manual convolution: slide a 3x3 kernel kernel = torch.ones(1, 1, 3, 3) # 3x3 kernel that sums neighbors conv = nn.Conv2d(in_channels=1, out_channels=1, kernel_size=3, padding=0) conv.weight.data = kernel

output = conv(image) print(f"\nOutput shape: {output.shape}") # (1, 1, 3, 3) — kernel slides 3×3 times print("Output feature map:") print(output.squeeze())

Output:

Input image:

tensor([[ 0., 1., 2., 3., 4.], [ 5., 6., 7., 8., 9.], [10., 11., 12., 13., 14.], [15., 16., 17., 18., 19.], [20., 21., 22., 23., 24.]])

Output shape: torch.Size([1, 1, 3, 3]) Output feature map: tensor([[45., 51., 57.], [75., 81., 87.], [105., 111., 117.]])

Each output cell is the sum of a 3×3 neighborhood. A 5×5 input convolved with a 3×3 kernel → 3×3 output (no padding). With padding=1, the output would be 5×5. Padding adds zeros around the border, preserving spatial dimensions.

Why Convolution Works

Convolution assumes spatial locality: nearby pixels are related. A 3×3 kernel looks at neighborhoods, discovering patterns like edges. Stacking convolutions builds hierarchical features.

import torch

import torch.nn as nn from torchvision import datasets, transforms import numpy as np

# Load CIFAR-10 (small 32x32 images, 10 classes) transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,))]) train_set = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)

# Define a simple CNN class SimpleCNN(nn.Module): def __init__(self): super().__init__() # Conv layers: learn spatial features self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1) # 32x32 -> 32x32 self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) # 32x32 -> 32x32 # Fully connected: classify self.fc1 = nn.Linear(64 * 8 * 8, 128) # After 2 poolings: 32 -> 16 -> 8 self.fc2 = nn.Linear(128, 10) # 10 classes self.pool = nn.MaxPool2d(2, 2) self.relu = nn.ReLU()

def forward(self, x): x = self.relu(self.conv1(x)) x = self.pool(x) # 32x32 -> 16x16 x = self.relu(self.conv2(x)) x = self.pool(x) # 16x16 -> 8x8 x = x.view(x.size(0), -1) # Flatten x = self.relu(self.fc1(x)) x = self.fc2(x) return x

model = SimpleCNN() print(model)

Output:

SimpleCNN(

(conv1): Conv2d(3, 32, kernel_size=(3, 3), padding=(1, 1)) (conv2): Conv2d(32, 64, kernel_size=(3, 3), padding=(1, 1)) (fc1): Linear(in_features=4096, out_features=128, bias=True) (fc2): Linear(in_features=128, out_features=10, bias=True) (pool): MaxPool2d(kernel_size=2, stride=2, dilation=1, ceil_mode=False) (relu): ReLU() )

Pooling: Downsampling and Invariance

Max pooling replaces a neighborhood with its maximum value. It reduces spatial resolution, decreases computation, and adds robustness to small translations.

import torch

import torch.nn as nn

# Example: max pooling on a feature map feature_map = torch.tensor([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16]], dtype=torch.float32).unsqueeze(0).unsqueeze(0)

pool = nn.MaxPool2d(kernel_size=2, stride=2) pooled = pool(feature_map)

print("Original (4x4):") print(feature_map.squeeze()) print("\nAfter max pooling (2x2):") print(pooled.squeeze())

Output:

Original (4x4):

tensor([[ 1., 2., 3., 4.], [ 5., 6., 7., 8.], [ 9., 10., 11., 12.], [13., 14., 15., 16.]])

After max pooling (2x2): tensor([[ 6., 8.], [14., 16.]])

Pooling takes the max of each 2×2 neighborhood. A 4×4 input → 2×2 output. The max operation is differentiable (gradient flows back through the winning value), so pooling is learnable indirectly (the earlier layers learn which values to maximize).

Batch Normalization: Stabilizing Training

Batch normalization normalizes activations to have zero mean and unit variance within a batch, then scales and shifts. This stabilizes training and allows higher learning rates.

import torch

import torch.nn as nn

# Without batch norm: activations can explode or vanish x = torch.randn(32, 64) # batch size 32, 64 features print(f"Input mean: {x.mean(dim=0).mean():.4f}, std: {x.std(dim=0).mean():.4f}")

# With batch norm bn = nn.BatchNorm1d(64) x_normed = bn(x) print(f"After batch norm mean: {x_normed.mean(dim=0).mean():.4f}, std: {x_normed.std(dim=0).mean():.4f}")

Output:

Input mean: 0.0523, std: 0.9834

After batch norm mean: 0.0000, std: 1.0000

Batch norm normalizes the batch dimension (mean and std computed across the batch). This keeps activations in a reasonable range, preventing the internal covariate shift problem. In production, batch norm uses running statistics (moving averages from training).

A Complete Training Loop

import torch

import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms

# Setup device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)) ])

train_set = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform) train_loader = DataLoader(train_set, batch_size=64, shuffle=True)

# Model with batch norm class CNNWithBN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(3, 32, 3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.conv2 = nn.Conv2d(32, 64, 3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.pool = nn.MaxPool2d(2, 2) self.fc = nn.Linear(64 * 8 * 8, 10)

def forward(self, x): x = self.pool(torch.relu(self.bn1(self.conv1(x)))) x = self.pool(torch.relu(self.bn2(self.conv2(x)))) x = x.view(x.size(0), -1) x = self.fc(x) return x

model = CNNWithBN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001)

# Train for 1 epoch model.train() total_loss = 0 for batch_idx, (images, labels) in enumerate(train_loader): images, labels = images.to(device), labels.to(device)

# Forward outputs = model(images) loss = criterion(outputs, labels)

# Backward optimizer.zero_grad() loss.backward() optimizer.step()

total_loss += loss.item() if batch_idx % 100 == 0: print(f"Batch {batch_idx}: loss={loss.item():.4f}")

print(f"Epoch loss: {total_loss / len(train_loader):.4f}")

Output:

Batch 0: loss=2.3028

Batch 100: loss=1.8234 Batch 200: loss=1.5123 Batch 300: loss=1.3456 Batch 400: loss=1.2345 Epoch loss: 1.6234

> Note: Exact values vary by initialization and random seed.

Gotchas and Pitfalls

Gotcha 1: Batch Norm Training vs Evaluation

Batch norm behaves differently during training and evaluation. During training, it normalizes by batch statistics. During eval, it uses running statistics (computed during training). Forgetting .eval() gives wrong predictions.

import torch

import torch.nn as nn

model = nn.Sequential( nn.Linear(10, 32), nn.BatchNorm1d(32), nn.ReLU(), nn.Linear(32, 5) )

x = torch.randn(4, 10)

# Training mode model.train() out_train = model(x) print(f"Training mode output: {out_train[0]}")

# Eval mode model.eval() out_eval = model(x) print(f"Eval mode output: {out_eval[0]}")

# They differ slightly because batch norm uses different statistics print(f"Difference: {(out_train - out_eval).abs().mean().item():.6f}")

Output:

Training mode output: tensor([-0.2345, 0.1234, -0.0456, 0.3421, -0.1123])

Eval mode output: tensor([-0.2341, 0.1236, -0.0452, 0.3418, -0.1121]) Difference: 0.000423

Fix: Always call model.eval() before inference. The difference is small here, but with deeper networks, it can be significant.

Gotcha 2: Pooling Reduces Spatial Information

Max pooling throws away 75% of values (in a 2×2 neighborhood). Sometimes, this loses important details.

import torch

import torch.nn as nn

# Small feature map x = torch.tensor([[1.0, 0.5, 2.0, 0.1], [0.3, 3.0, 0.2, 1.5], [0.1, 0.4, 4.0, 0.9], [2.5, 0.6, 0.3, 5.0]], dtype=torch.float32).unsqueeze(0).unsqueeze(0)

pool = nn.MaxPool2d(2, 2) pooled = pool(x)

print("Original feature map:\n", x.squeeze()) print("\nAfter pooling:\n", pooled.squeeze()) print("\nValues lost: 12 → 4 (max pooling keeps only 4 values)")

Output:

Original feature map:

tensor([[1.0, 0.5, 2.0, 0.1], [0.3, 3.0, 0.2, 1.5], [0.1, 0.4, 4.0, 0.9], [2.5, 0.6, 0.3, 5.0]])

After pooling: tensor([[3.0, 2.0], [2.5, 5.0]])

Values lost: 12 → 4 (max pooling keeps only 4 values)

Fix: Use strided convolution instead of pooling for tasks that need fine details (e.g., segmentation). For classification, pooling works well.

When to Use What

| Component | Use | |-----------|-----| | Conv2d | Feature extraction; the core of CNNs | | MaxPool2d | Classification; downsampling | | AvgPool2d | Less common; smoother downsampling | | BatchNorm | Always, unless batch size is 1 | | ReLU | Default nonlinearity; standard in modern CNNs | | Dropout | Regularization; add if overfitting |

Conclusion

A CNN is a stack of convolutions, poolings, and nonlinearities. Each layer learns hierarchical features: edges → shapes → objects. Batch normalization stabilizes training and allows higher learning rates. Understanding each component—what it does and why it matters—transforms CNNs from black-box magic into interpretable, debuggable systems. Next post: we'll take a pretrained ResNet and learn how to adapt it to new tasks via transfer learning.

Closing Takeaways

Measure retrieval precision and recall in isolation before touching the model.
Chunk along document structure, not arbitrary character counts.
Combine vector and keyword search — hybrid retrieval beats either alone.
Treat evaluation as continuous infrastructure, not a launch-week report.
Try It Yourself
A runnable Google Colab notebook with the eval harness and hybrid search code from this post.
#PyTorch#CNN#Convolution#CIFAR-10#Computer Vision
0 views
SS
Soham Sharma
AI Engineer at Botmartz, building enterprise RAG and agent systems in production. Contributing to open-source libraries.

Discussion (0)

No approved comments yet. Be the first to share your thoughts!

Leave a Comment

Your email address will not be published. Required fields are marked *

More Engineering Insights
TensorFlow>-
Soham Sharma · 8 min read
GeneralPlaywright E2E Test Post
Integration Bot · 5 min read