Want to build a Python CNN that actually sees an image? This guide walks through convolution, pooling, feature maps, and the layer-by-layer feature extraction that makes CNNs the backbone of modern computer vision. By the end you train an image classifier on the MNIST handwritten digits from scratch, in PyTorch, and watch the accuracy climb to around 99 percent.
“The first layer learns edges, the next learns textures, then parts, and the final layers learn whole objects. That step-by-step build up is the heart of how a CNN sees.”
The CNN feature hierarchy, popularized by Yann LeCun’s LeNet work
Last Updated: July 2026 | Tested on: Python 3.14.6, PyTorch 2.12.1, torchvision 0.27.1 | Difficulty: Advanced | Reading Time: 13 minutes
A fully connected network treats every pixel as a separate, unrelated feature. A 28×28 image has 784 pixels, so the first hidden layer alone needs 784 times N weights. Scale up to a 224×224 color photo (150,528 values) and the weight count explodes. Worse, a fully connected layer has no idea that pixels sitting next to each other belong together. It does not know that nearby pixels form edges, edges form textures, and textures form objects. A convolutional neural network (CNN) fixes both problems. It slides one small filter across the whole image so the same weights get reused everywhere, and it stacks layers so simple features build up into complex ones.
Think of the filter as a tiny rubber stamp, usually 3×3 or 5×5. You press it down at every spot on the image and write down how strongly the stamp matches what is underneath. Slide it across the whole picture and you get a feature map, a grid that lights up wherever the filter’s pattern appears. Early layers learn simple stamps like edges and corners. Deeper layers combine those into richer features like an eye, a wheel, or a letter. Pooling layers then shrink each feature map so there is less to compute, and they add a little wiggle room: the network still spots a cat whether it sits dead center, slid to the left, or tilted a bit.
Here is what we cover:
- How convolution extracts features from images
- Pooling layers and spatial downsampling
- Building a CNN architecture in PyTorch
- Training an MNIST classifier to 99% accuracy
- Visualizing what each layer learns
Table of Contents
Prerequisites
- PyTorch tutorial
- deep learning regularization tutorial
- pip install torch torchvision
CNN Architecture
Think of a CNN as an assembly line in a factory. Raw material (the image) enters at one end, and each station does one small job: this one sharpens edges, the next one bundles edges into shapes, another trims the piece down to size. By the time the product reaches the far end, all those small steps add up to a finished decision. The diagram below shows that same line, station by station.
Read the diagram top to bottom. A 28×28 grayscale digit enters and passes through two Conv2D plus ReLU (Rectified Linear Unit) plus MaxPool blocks. Each block pulls out slightly richer features while shrinking the picture (28 down to 14 down to 7). The Flatten layer then unrolls those 2D feature maps into one long 1D vector, and the fully connected layers turn that vector into a decision. The final layer hands back 10 numbers, one softmax probability per digit, and the biggest one is the network’s guess. Later in this post you build this exact Python CNN in PyTorch, line by line.
Convolution: Pattern Detection
The fastest way to get convolution is to watch one filter do its job. Picture a flashlight in a dark room. You can only see the small circle of wall the beam lands on, so you move the beam around to scan the whole wall. A filter works the same way: it only looks at a 3×3 patch at a time, and it slides over the image to scan every spot.
At each stop it multiplies its nine numbers by the nine pixels under it, adds them up, and writes that single number into the feature map. Here a developer named Niranjan builds a vertical edge detector and runs it across a small image that has a white bar down the middle.
📄 convolution_demo.py: how a filter scans an image
import torch
import torch.nn.functional as F
# Niranjan demonstrates convolution with a simple edge detector
image = torch.tensor([
[0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 0, 0],
[0, 0, 1, 1, 0, 0],
[0, 0, 1, 1, 0, 0],
[0, 0, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 0],
], dtype=torch.float32).unsqueeze(0).unsqueeze(0) # Add batch and channel dims
# Vertical edge detector
kernel = torch.tensor([
[-1, 0, 1],
[-1, 0, 1],
[-1, 0, 1],
], dtype=torch.float32).unsqueeze(0).unsqueeze(0)
feature_map = F.conv2d(image, kernel, padding=0)
print("Input image (6x6):")
print(image.squeeze().numpy().astype(int))
print(f"\nFilter (vertical edge detector):")
print(kernel.squeeze().numpy().astype(int))
print(f"\nFeature map after convolution (4x4):")
print(feature_map.squeeze().numpy().astype(int))
print("\nPositive = left edge, Negative = right edge, Zero = no edge")
▶ Output
Input image (6x6): [[0 0 0 0 0 0] [0 0 1 1 0 0] [0 0 1 1 0 0] [0 0 1 1 0 0] [0 0 1 1 0 0] [0 0 0 0 0 0]] Filter (vertical edge detector): [[-1 0 1] [-1 0 1] [-1 0 1]] Feature map after convolution (4x4): [[ 2 2 -2 -2] [ 3 3 -3 -3] [ 3 3 -3 -3] [ 2 2 -2 -2]] Positive = left edge, Negative = right edge, Zero = no edge
What happened here: The 3×3 filter slid across the 6×6 image and wrote one number at each of its 16 stops, which is why the feature map is 4×4. The white bar sits in columns 2 and 3. The left two columns of the feature map are positive because the filter sat on the bar’s left edge, a dark-to-light jump. The right two columns are negative because the filter sat on the right edge, a light-to-dark jump.
The middle rows read 3 and the top and bottom rows read 2 for a simple reason: the bar is four rows tall, so a window near the top or bottom only overlaps two of the bar’s rows instead of all three. There are no zeros here because the white bar is only two columns wide, so every output column lands on either its left edge or its right edge.
Make the bar wider and you would see zeros appear in the flat middle, where the filter sees no change at all. One small filter found every vertical edge in the picture using only 9 numbers, no matter how big the image gets. That reuse of the same 9 numbers everywhere is what we mean by weight sharing.
MNIST Classifier: A Full Python CNN
Time to put a real Python CNN to work. MNIST is the “hello world” of computer vision: 70,000 small 28×28 grayscale photos of handwritten digits, 0 through 9. Here a machine learning engineer named Rahul stacks the same building blocks you saw in the diagram, two convolution blocks followed by a couple of dense layers, and trains the whole thing for 5 passes over the data. The first run downloads the dataset (about 11 MB), so give it a moment the first time. On a plain laptop Central Processing Unit (CPU) each epoch takes a minute or two.
📄 mnist_cnn.py: complete CNN training pipeline
import torch
import torch.nn as nn
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
torch.manual_seed(0) # makes the run reproducible; your numbers may still drift a little
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)),
])
train_data = datasets.MNIST("./data", train=True, download=True, transform=transform)
test_data = datasets.MNIST("./data", train=False, transform=transform)
train_loader = DataLoader(train_data, batch_size=64, shuffle=True)
test_loader = DataLoader(test_data, batch_size=256)
class MNISTNet(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), # 28x28x1 -> 28x28x32
nn.ReLU(),
nn.MaxPool2d(2), # -> 14x14x32
nn.Conv2d(32, 64, 3, padding=1), # -> 14x14x64
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(2), # -> 7x7x64
)
self.classifier = nn.Sequential(
nn.Flatten(), # -> 3136
nn.Linear(7 * 7 * 64, 128),
nn.ReLU(),
nn.Dropout(0.25),
nn.Linear(128, 10),
)
def forward(self, x):
x = self.features(x)
return self.classifier(x)
# Rahul trains the CNN
model = MNISTNet()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
for epoch in range(5):
model.train()
for X_batch, y_batch in train_loader:
optimizer.zero_grad()
loss = criterion(model(X_batch), y_batch)
loss.backward()
optimizer.step()
model.eval()
correct = total = 0
with torch.no_grad():
for X_batch, y_batch in test_loader:
preds = model(X_batch).argmax(1)
correct += (preds == y_batch).sum().item()
total += len(y_batch)
print(f"Epoch {epoch+1}: Test accuracy = {correct/total:.2%}")
▶ Output
Epoch 1: Test accuracy = 98.15% Epoch 2: Test accuracy = 98.71% Epoch 3: Test accuracy = 98.71% Epoch 4: Test accuracy = 99.10% Epoch 5: Test accuracy = 98.97%
What happened here: In just 5 short passes over the data, the CNN reached about 99% accuracy on MNIST, crossing the 99% mark at epoch 4. Notice the numbers do not climb in a perfectly straight line: epoch 5 dips a touch below epoch 4. That wobble is normal, since the training shuffles batches and dropout randomly mutes neurons, so the accuracy bounces around within a fraction of a percent.
Under the hood, two Conv2D blocks pulled out edge and shape features, MaxPool2d halved the picture each time (28 down to 14 down to 7), and the dense head mapped the flattened features onto the 10 digit classes. The BatchNorm2d after the second conv layer kept training steady, and Dropout stopped the model from memorizing the training set. Here is the eye-opener about weight sharing: the two convolution layers together hold only 18,816 weights.
A single fully connected layer asked to produce that same first 28x28x32 feature volume from the 784 input pixels would need about 19.7 million weights. That is the efficiency a CNN buys you, the same small filter reused across the whole image instead of a fresh weight for every pixel pair.
Common Mistakes
- Wrong input dimensions: PyTorch Conv2d expects (batch, channels, height, width). A common slip is passing (batch, height, width, channels), which is the old TensorFlow convention, not PyTorch’s.
- Forgetting to normalize images: Raw pixel values (0-255) cause large gradients. Always normalize to (0,1) or standardize.
- Too many fully connected parameters: The Flatten layer can create huge fully connected layers. Use Global Average Pooling instead to reduce from spatial dimensions to a single value per channel.
Interview Corner
Q: Why do CNNs work better than fully connected networks for images?
Three reasons. First, weight sharing: the same small filter runs over the whole image, so you need far fewer weights than a dense layer. Second, local connectivity: each neuron only looks at a small patch, which fits the fact that visual features (edges, corners) are local. Third, translation equivariance: a filter spots the same pattern no matter where it sits in the image. These built-in assumptions match how images are actually put together, so a CNN learns from far fewer examples than a fully connected net would.
Practice Exercises
- Add a third Conv2D layer and compare accuracy with the 2-layer version.
- Replace MaxPool2d with strided convolution (stride=2) and compare results.
- Train the same CNN on CIFAR-10 (color images, 10 classes). What accuracy do you get?
- Visualize the 32 learned filters from the first Conv2D layer after training.
More in this series:
- Dropout and Batch Normalization: DL Regularization Explained
- AI: Computer Vision Project, Object Detection with YOLO
- DL: RNNs and LSTMs for Sequence Processing
Frequently Asked Questions
What filter size should I use in a CNN python model?
3×3 is the standard choice at the time of writing. Two stacked 3×3 filters cover the same area as one 5×5 but use fewer parameters and add an extra nonlinearity. Classic architectures like VGG and ResNet lean on 3×3 (ResNet uses a larger 7×7 only on the very first layer); newer designs such as ConvNeXt moved to 7×7 depthwise filters, but 3×3 remains the safe default for a hand-built CNN.
MaxPool vs AveragePool vs Global Average Pool?
MaxPool keeps the strongest activation in each window, which works well for classification. AveragePool smooths features and shows up in some older architectures. Global Average Pooling replaces the whole Flatten plus Dense block, which cuts parameters and reduces overfitting in larger models.
Why use a CNN instead of a plain neural network for images?
A CNN reuses one small filter across the entire image, so it needs far fewer weights than a dense network and it respects the fact that nearby pixels belong together. That is why the Python CNN in this guide reaches around 99% accuracy while its convolution layers use only a few thousand weights, a fraction of what an equivalent fully connected layer would need.
Interview Questions on CNNs
If you can walk through these without peeking, you are ready for this topic in an interview.
Q: What is a receptive field, and why does stacking layers grow it?
The receptive field is the region of the original input that a single neuron in a later layer can “see.” A first-layer 3×3 filter sees a 3×3 patch. Stack another 3×3 layer on top and each neuron now indirectly covers a 5×5 area, because it pools together neighbouring 3×3 outputs. This is why deep CNNs recognise large objects even though every filter stays tiny: depth, not filter size, is what widens the view.
Q: What does padding do, and how does it change the output size?
Padding adds a border of zeros around the image before convolution. With no padding a 3×3 filter shrinks each dimension by 2 (a 28×28 input becomes 26×26), and repeated layers would erode the image away. Setting padding=1 with a 3×3 filter keeps the output the same size as the input (“same” padding), which is why the Python CNN model in this guide uses padding=1 so only the MaxPool layers change the spatial dimensions.
Q: Why add BatchNorm2d between the convolution and the activation?
Batch normalization rescales each channel to a stable mean and variance across the batch, which smooths the loss surface and lets you train faster with a higher learning rate. It also acts as a mild regularizer because the batch statistics add a little noise. In practice it makes training far less sensitive to weight initialization, which is why the MNIST model reaches high accuracy in only 5 epochs.
Q: Your CNN hits 99 percent on the training set but only 82 percent on the test set. What do you check first?
That gap is classic overfitting: the model memorized the training images. First confirm it by watching train versus validation accuracy diverge over epochs. Then attack it in order: add or increase Dropout, add data augmentation (random crops, rotations, flips), reduce model capacity, or add weight decay. If the dataset is small, transfer learning usually beats every regularizer you can bolt on.
Q: You pass a batch into the model and get a shape mismatch error at the first Linear layer. Where is the bug?
The flattened feature size feeding the Linear layer does not match what you declared. After two MaxPool2d layers a 28×28 input becomes 7×7, and with 64 channels the flattened vector is 7*7*64 = 3136, so the Linear layer must be nn.Linear(3136, …). Change the input size, the number of pooling layers, or the channel count and that number changes. Print x.shape right before the Flatten to read the real dimensions instead of guessing.
Q: Training runs fine on CPU but throws a CUDA out-of-memory error on Graphics Processing Unit (GPU). Memory spikes on the very first batch. What do you check?
Start with batch size, since GPU memory scales roughly linearly with it, and halve it as a quick test. Next look at the spatial size of early feature maps: high-resolution inputs with many channels before any pooling are the biggest consumers, so pool earlier or downsample the input. Also make sure you are not holding on to the graph (use torch.no_grad() during evaluation) and that you call optimizer.zero_grad() each step. Mixed precision (autocast) roughly halves activation memory if you still need more headroom.
Q: Why does replacing MaxPool with a stride-2 convolution sometimes work just as well?
Both halve the spatial dimensions, but MaxPool downsamples with a fixed rule (keep the strongest activation) while a strided convolution learns how to downsample. That gives the network more flexibility at the cost of a few extra parameters. Several modern architectures drop pooling entirely and rely on strided convolutions, which is exactly what practice exercise 2 asks you to try.
What’s Next?
You built a Python CNN from scratch and saw why it beats a fully connected net on images: convolution slides one small filter everywhere so weights get reused, pooling shrinks the maps and adds a little position tolerance, and stacked layers turn edges into textures into whole digits. You trained it on MNIST to around 99 percent accuracy and watched exactly where the parameter savings come from. But what if you only have 200 images instead of 60,000? In the transfer learning tutorial, you will take a network already trained on millions of images and adapt it to your own task with very little data.
Want the full roadmap from Python basics to deep learning? Start at the Python + AI/ML tutorial series home and follow it in order.
Want more? the official Python documentation documents everything this post could not fit.
Related Posts
Previous: DL: Optimizers, Why Your Model Learns (or Doesn’t)
Next: DL: Transfer Learning with ResNet, VGG Pre-trained Models
Series Home: Python + AI/ML Tutorial Series

No comment