M44: Convolutions, pooling, and CNN architectures

CSCI 3151 — Foundations of Machine Learning

Frank Rudzicz

Learning outcomes

By the end of this module, you should be able to:

  1. Explain what a discrete convolution layer does in terms of local receptive fields, weight sharing, and translation equivariance.
  2. Derive & manipulate basic convolution hyperparameters (kernel size, stride, padding, channels) to compute output shapes and receptive fields.
  3. Compare & implement pooling strategies (max vs average) and reason about their effect on invariance, resolution, and information loss.
  4. Assemble & analyze a small CNN architecture for image classification, contrasting it with an MLP baseline on the same data.
  5. Evaluate & diagnose a CNN using training curves and accuracy metrics, recognizing common failure modes (underfitting, overfitting, too aggressive pooling).

Conceptual scaffold

Images as tensors

  • A grayscale image:
    • 2D grid of intensities
    • Shape: \(H \times W\) (height x width)
  • An RGB image:
    • 3 channels (R, G, B)
    • Shape: \(H \times W \times 3\)
  • In most DL libraries (\(N\): batch size; \(C\): channels/image):
    • Batch of images: (N, C, H, W)

👉 Key idea: images are highly structured 👈:

  • Nearby pixels are related
  • Objects can move slightly without changing their identity
  • CNNs are built to exploit locality and (approximate) translation invariance

Why MLPs are awkward for images

  • A fully connected layer takes a flattened image:
    • 32×32 RGB = 3072 input features
    • First hidden layer with 512 units → \(3072 \times 512 \approx 1.6M\) weights
  • Problems:
    • Ignores spatial structure (pixel (0,0) and (31,31) treated similarly)
    • Huge number of parameters → slower, easier to overfit
    • Hard to express locality (edges, corners, textures)
    • Small translations of digits (shifted up by 1 pixel) cause accuracy to drop sharply.

What went wrong?

Among other problems, the model has no built-in notion that a digit shifted by one pixel is “the same” object. It must learn that invariance from scratch.

Convolution: big picture 🖼️

Convolution layer:

  • Slides a small kernel (filter) across the image.
  • At each location, computes a weighted sum of pixel values in a local patch.
  • Reuses the same weights at all spatial locations (weight sharing).
  • Produces a new image-like array: a feature map.

From here

Intuition

  • A kernel can act as:
    • Edge detector
    • Blur / smoothing filter
    • Texture detector
  • Different kernels detect different patterns; stacking them gives rich feature hierarchies.

Math lens

Discrete 1D convolution (warm-up)

For 1D signals \(x\) (input) and \(w\) (kernel), the discrete convolution is:

\[ (y = x * w)[i] = \sum_{k} x[i - k] w[k] \]

From here

In deep learning libraries, we often use cross-correlation instead:

\[ (y = x \star w)[i] = \sum_{k} x[i + k] w[k] \]

  • Same flavour, just flipped indexing; many libraries call this “convolution.”

2D convolution — definition

For single-channel 2D input \(X \in \mathbb{R}^{H \times W}\) and kernel \(K \in \mathbb{R}^{k_H \times k_W}\), ignoring stride & padding:

\[ Y[i, j] = (X \star K)[i, j] = \sum_{u=0}^{k_H-1} \sum_{v=0}^{k_W-1} K[u, v] \, X[i + u, j + v] \]

  • Output \(Y\) has shape \((H - k_H + 1) \times (W - k_W + 1)\).
  • With padding and stride, we adjust indices and shapes.

Conv hyperparameters: stride & padding

  • Kernel size: \(k_H \times k_W\)
  • Stride: how far we move the kernel each step
    • E.g., stride = 2 → skip every other pixel
  • Padding: how many pixels we add around the border (usually zeros)

For input \(H \times W\), kernel \(k_H \times k_W\), padding \(p_H, p_W\), stride \(s_H, s_W\):

\[ H_{ \text{out}} = \left\lfloor \frac{H + 2p_H - k_H}{s_H} \right\rfloor + 1,\quad W_{ \text{out}} = \left\lfloor \frac{W + 2p_W - k_W}{s_W} \right\rfloor + 1 \]

padding=0; stride=1

padding=1; stride=1

padding=0; stride=2

padding=1; stride=2

Channels & feature maps

For multi-channel input (e.g., RGB):

  • Input: \(X \in \mathbb{R}^{C_\text{in} \times H \times W}\)
  • One conv filter has weights \(K \in \mathbb{R}^{C_\text{in} \times k_H \times k_W}\)
  • The output of this single filter (one feature map):

\[ Y[i, j] = \sum_{c=1}^{C_\text{in}} \sum_{u,v} K[c, u, v] \, X[c, i+u, j+v] \]

To produce \(C_\text{out}\) feature maps, we use \(C_\text{out}\) filters:

  • Weight tensor: \(W \in \mathbb{R}^{C_\text{out} \times C_\text{in} \times k_H \times k_W}\)
  • Output: \(Y \in \mathbb{R}^{C_\text{out}\times H_\text{out} \times W_\text{out}}\)

From here

Parameter counts: MLP vs conv

Consider 32×32 RGB images (\(C=3, H=W=32\)) and a layer with 64 hidden units:

  • MLP first layer
    • Input dim = \(3 \times 32 \times 32 = 3072\)
    • Weights: \(3072 \times 64 \approx 196{,}608\), plus 64 biases
  • Conv layer
    • Use kernel size 3×3, stride 1, padding 1
    • One filter: \(3 \times 3 \times 3 = 27\) weights
    • 64 filters → \(64 \times 27 = 1{,}728\) weights + 64 biases
Layer type Parameters (approx)
MLP dense 196,608
Conv (3×3, 64 filters) 1,728

Tip

Convs are much more parameter-efficient for images.

🔬 Translation equivariance (informal)

Ideally, if we shift the input, the feature map should shift in the same way.

Simplify to 1D and cross-correlation:

  • Input: \(x[i]\)
  • Kernel: \(w[k]\)
  • Output: \(y[i] = (x \star w)[i] = \sum_k x[i + k]w[k]\)

Define shift operator: \((T_\delta x)[i] = x[i - \delta]\).

Claim (equivariance):

\[ (T_\delta x \star w)[i] = T_\delta (x \star w)[i] = y[i - \delta] \]

Proof sketch:

\[ \begin{aligned} (T_\delta x \star w)[i] &= \sum_k (T_\delta x)[i + k] w[k] \\ &= \sum_k x[i + k - \delta] w[k] \\ &= \sum_k x[(i - \delta) + k] w[k] \\ &= (x \star w)[i - \delta] = y[i - \delta] = T_\delta y[i]. \end{aligned} \]

So convolution (or cross-correlation) is translation equivariant: shift in, shift out.

Pooling: local invariance & downsampling

Pooling layers reduce spatial resolution by summarizing local neighbourhoods.

Max pooling

\[ Y[i, j] = \max_{(u,v) \in \text{window}} X[i + u, j + v] \]

  • Keeps strongest activation
  • Encourages local translation invariance:
    • Slight shifts of an edge still give high response somewhere in the window

Average pooling

\[ Y[i, j] = \frac{1}{|W|} \sum_{(u,v)\in W} X[i + u, j + v] \]

  • Smoother; preserves average energy rather than sharpness

Pooling window size & stride (often same) control how much invariance & downsampling.

❌ Anti-example: too aggressive pooling

Imagine a CNN for handwritten digit recognition:

  • You use max pooling with:
    • Window size 7×7
    • Stride 7
  • After a couple of conv+pool blocks, the feature maps are tiny (e.g., 1×1).

Warning

Because the 7×7, stride-7 pooling windows collapse many different input locations into the same pooled value (and, after enough pooling, to a 1×1 map there’s only one cell), 👉 the network’s later layers see exactly the same representation no matter where the pattern appeared, making it effectively invariant to location 👈.

Consequences:

  • Model becomes almost completely invariant to location: “where” a stroke is drawn no longer matters.
  • Fine-grained structure (like distinguishing 3 vs 8) may be lost.
  • Training accuracy can saturate but test accuracy suffers—model has thrown away discriminative information.

Takeaway

Pooling is powerful, but too much invariance is also bad.

Worked example 1: Edge detection

Toy 2D convolution & pooling

We’ll build a tiny “image” and a simple edge-detection kernel.

🥅 Goal

  • Represent convolution as local dot product
  • Inspect how pooling compresses information
Code
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# Tiny 5x5 "image" with a bright horizontal bar
img = np.array([
    [0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0],
    [0, 1, 1, 1, 0],
    [0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0],
], dtype=float)

# Simple horizontal edge detector (Sobel-like)
kernel = np.array([
    [-1, -2, -1],
    [ 0,  0,  0],
    [ 1,  2,  1],
], dtype=float)

img_df = pd.DataFrame(img)
kernel_df = pd.DataFrame(kernel)

print("Our image:")
img_df
Our image:
0 1 2 3 4
0 0.0 0.0 0.0 0.0 0.0
1 0.0 0.0 0.0 0.0 0.0
2 0.0 1.0 1.0 1.0 0.0
3 0.0 0.0 0.0 0.0 0.0
4 0.0 0.0 0.0 0.0 0.0

Applying the kernel

Code
def conv2d_valid(x, k):
    H, W = x.shape
    kH, kW = k.shape
    out = np.zeros((H - kH + 1, W - kW + 1))
    for i in range(out.shape[0]):
        for j in range(out.shape[1]):
            patch = x[i:i+kH, j:j+kW]
            out[i, j] = np.sum(patch * k)
    return out

edge = conv2d_valid(img, kernel)
edge_df = pd.DataFrame(edge)
print("Our edge-detecting kernel:")
edge_df
Our edge-detecting kernel:
0 1 2
0 3.0 4.0 3.0
1 0.0 0.0 0.0
2 -3.0 -4.0 -3.0
Code
fig, axes = plt.subplots(1, 2, figsize=(6, 3))
axes[0].imshow(img, cmap="gray")
axes[0].set_title("Input image")
axes[0].axis("off")

axes[1].imshow(edge, cmap="gray")
axes[1].set_title("After convolution")
axes[1].axis("off")
plt.tight_layout()

Adding pooling

Code
def max_pool2d(x, pool_size=2, stride=2):
    H, W = x.shape
    out_H = (H - pool_size) // stride + 1
    out_W = (W - pool_size) // stride + 1
    out = np.zeros((out_H, out_W))
    for i in range(out_H):
        for j in range(out_W):
            patch = x[i*stride:i*stride+pool_size,
                      j*stride:j*stride+pool_size]
            out[i, j] = patch.max()
    return out

pooled = max_pool2d(edge, pool_size=2, stride=1)
pd.DataFrame(pooled)
0 1
0 4.0 4.0
1 0.0 0.0
Code
plt.figure(figsize=(3, 3))
plt.imshow(pooled, cmap="gray")
plt.title("After max pooling")
plt.axis("off")
plt.tight_layout()

Note

  • Pooling keeps where the edge is in a coarse sense, while reducing resolution.
  • ✏️ In your usual tweaks to the code, what happens if you set stride=2?

Receptive fields

Receptive field of a unit = region of the input it “sees.”

  • In a single 3×3 conv layer (stride 1, no pooling):
    • Each output pixel depends on a 3×3 patch in the input.
  • Stack another 3×3 conv (stride 1):
    • Receptive field becomes 5×5.
  • Add pooling with stride 2:
    • Receptive field grows and the effective spacing between receptive fields increases.

Mini-table example

Layer Kernel / stride Effective RF size
Conv1 3×3 / 1 3×3
Conv2 3×3 / 1 5×5
MaxPool (2×2) 2×2 / 2 6×6 (coarser grid)

Receptive fields help explain how deep layers can “see” entire objects.

From here

CNN building blocks

Most CNNs are made of a few repeating pieces:

  • Conv layer: Conv2d(in_channels, out_channels, kernel_size, stride, padding)
  • Nonlinearity: typically ReLU
  • Pooling: max or average pooling
  • Normalization: batch norm (see earlier modules)
  • Classifier head:
    • Flatten or global pooling
    • One or more dense layers

Example pattern (“conv block”):

  1. Conv2d → ReLU
  2. Conv2d → ReLU
  3. MaxPool2d

Worked example 2: CIFAR-10

MLP vs CNN on (some) CIFAR-10

🥅 Goal

  • Compare a baseline MLP vs a small CNN on image data

We’ll train only for a few epochs on a small subset so this runs quickly in a notebook.

Setup & data

Code
import torch
from torch import nn
from torch.utils.data import DataLoader, Subset
import torchvision
from torchvision import datasets, transforms
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

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_full = datasets.CIFAR10(root="data", train=True, download=True, transform=transform)
test_full = datasets.CIFAR10(root="data", train=False, download=True, transform=transform)

# Use a small subset to keep training fast (e.g., 5000 train, 1000 test)
np.random.seed(0)
train_indices = np.random.choice(len(train_full), size=5000, replace=False)
test_indices = np.random.choice(len(test_full), size=1000, replace=False)

train_subset = Subset(train_full, train_indices)
test_subset = Subset(test_full, test_indices)

train_loader = DataLoader(train_subset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_subset, batch_size=64, shuffle=False)

Models: MLP baseline

Code
class MLPClassifier(nn.Module):
    def __init__(self):
        super().__init__()
        self.flatten = nn.Flatten()
        self.net = nn.Sequential(
            nn.Linear(3 * 32 * 32, 512),
            nn.ReLU(),
            nn.Linear(512, 10),
        )

    def forward(self, x):
        x = self.flatten(x)
        return self.net(x)

mlp = MLPClassifier().to(device)

Models: small CNN

Code
class SmallCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2),   # 32x16x16
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2),   # 64x8x8
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(64 * 8 * 8, 10),
        )

    def forward(self, x):
        x = self.features(x)
        x = self.classifier(x)
        return x

cnn = SmallCNN().to(device)

Training helper

Code
def train_epoch(model, loader, optimizer, loss_fn):
    model.train()
    total_loss = 0.0
    correct = 0
    total = 0
    for x, y in loader:
        x, y = x.to(device), y.to(device)
        optimizer.zero_grad()
        logits = model(x)
        loss = loss_fn(logits, y)
        loss.backward()
        optimizer.step()
        total_loss += loss.item() * x.size(0)
        preds = logits.argmax(dim=1)
        correct += (preds == y).sum().item()
        total += x.size(0)
    return total_loss / total, correct / total


@torch.no_grad()
def evaluate(model, loader, loss_fn):
    model.eval()
    total_loss = 0.0
    correct = 0
    total = 0
    for x, y in loader:
        x, y = x.to(device), y.to(device)
        logits = model(x)
        loss = loss_fn(logits, y)
        total_loss += loss.item() * x.size(0)
        preds = logits.argmax(dim=1)
        correct += (preds == y).sum().item()
        total += x.size(0)
    return total_loss / total, correct / total

Running the experiment

Code
def run_experiment(model, train_loader, test_loader, epochs=5, lr=1e-3):
    loss_fn = nn.CrossEntropyLoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    history = {"train_loss": [], "train_acc": [],
               "test_loss": [], "test_acc": []}

    for epoch in range(epochs):
        tl, ta = train_epoch(model, train_loader, optimizer, loss_fn)
        vl, va = evaluate(model, test_loader, loss_fn)
        history["train_loss"].append(tl)
        history["train_acc"].append(ta)
        history["test_loss"].append(vl)
        history["test_acc"].append(va)
        print(f"Epoch {epoch+1}: "
              f"train_acc={ta:.3f}, test_acc={va:.3f}")
    return history

mlp_hist = run_experiment(mlp, train_loader, test_loader, epochs=5, lr=1e-3)
cnn_hist = run_experiment(cnn, train_loader, test_loader, epochs=5, lr=1e-3)
Epoch 1: train_acc=0.330, test_acc=0.369
Epoch 2: train_acc=0.447, test_acc=0.392
Epoch 3: train_acc=0.507, test_acc=0.373
Epoch 4: train_acc=0.561, test_acc=0.382
Epoch 5: train_acc=0.602, test_acc=0.399
Epoch 1: train_acc=0.307, test_acc=0.363
Epoch 2: train_acc=0.455, test_acc=0.452
Epoch 3: train_acc=0.527, test_acc=0.488
Epoch 4: train_acc=0.576, test_acc=0.498
Epoch 5: train_acc=0.608, test_acc=0.495

Results table

Code
def count_params(model):
    return sum(p.numel() for p in model.parameters())

summary = pd.DataFrame({
    "model": ["MLP", "CNN"],
    "params": [count_params(mlp), count_params(cnn)],
    "train_acc": [mlp_hist["train_acc"][-1],
                  cnn_hist["train_acc"][-1]],
    "test_acc": [mlp_hist["test_acc"][-1],
                 cnn_hist["test_acc"][-1]],
})

summary
model params train_acc test_acc
0 MLP 1578506 0.6022 0.399
1 CNN 60362 0.6078 0.495

Training curves

Code
epochs = range(1, len(mlp_hist["train_loss"]) + 1)

plt.figure(figsize=(6, 4))
plt.plot(epochs, mlp_hist["train_loss"], label="MLP train")
plt.plot(epochs, mlp_hist["test_loss"], label="MLP test")
plt.plot(epochs, cnn_hist["train_loss"], label="CNN train", linestyle="--")
plt.plot(epochs, cnn_hist["test_loss"], label="CNN test", linestyle="--")
plt.xlabel("Epoch")
plt.ylabel("Cross-entropy loss")
plt.title("MLP vs CNN on CIFAR-10 subset")
plt.legend()
plt.tight_layout()

✏️ Ask yourself

  • Does the CNN reach lower test loss?
  • Is either model clearly overfitting (train loss ↓ while test loss ↑)?

Summary

Evaluation & decision context

Imagine CNN-based classification, e.g.:

A pre-filter for organizing photos into broad categories in a consumer app.

  • Main metric: overall accuracy
  • Secondary metrics:
    • Confusion matrix: which classes get confused?
    • Possibly top-3 accuracy for a nicer user experience

For more sensitive applications (e.g., medical imaging, safety-critical robotics):

  • You’d care about:
    • Per-class recall (miss rate) for certain classes
    • Calibration (confidence vs accuracy)
    • Robustness to distribution shift (e.g., lighting changes)

Warning

CNNs provide better feature extraction for images than MLPs, but the evaluation metric must still match the real-world cost of errors.

Ethics & risks (CNNs in the wild)

When applying CNNs to real images, we must think beyond accuracy:

  • Bias & representation
    • Training images may overrepresent some groups, underrepresent others.
    • CNNs can inherit and amplify these biases (e.g., face recognition, medical imaging) (Bissoto et al. 2019).
  • Privacy (Fredrikson, Jha, and Ristenpart 2015; Shokri et al. 2017)
    • Images can contain sensitive info (faces, locations, documents).
    • Storing embeddings or raw images has implications for user privacy.
  • Robustness & adversarial examples
    • Small, human-imperceptible perturbations can fool CNNs (Szegedy et al. 2014).
    • Over-trusting predictions in safety-critical systems is dangerous.
  • Mitigations (starter list)
    • Use diverse, audited datasets; monitor performance across sub-groups.
    • Anonymize and secure data; consider on-device inference.
    • Use robustness checks, adversarial training or detection where appropriate.
    • Keep humans in the loop for high-stakes decisions.

Curiosity: CNNs everywhere

CNN-style architectures show up in:

Connections:

  • Next module (M45): ResNets & modern CNN design patterns

Curiosity: convolutional inductive biases

CNNs bake in strong assumptions:

  • Locality: nearby pixels matter more
  • Translation equivariance: pattern recognized regardless of small shifts
  • Shared weights: the same detector works everywhere in the image

These are inductive biases:

  • Great for many natural images
  • Less ideal when:
    • Geometry is non-Euclidean (graphs → GNNs)
    • Global relationships dominate (long-range dependencies → attention)

Tip

Thinking in terms of “what invariances / equivariances does my model assume?” is a powerful design habit.

Quick checks

Q1: You apply a conv layer with:

  • Input: shape (C=3, H=32, W=32)
  • Kernel size: 3×3
  • Padding: 1
  • Stride: 1
  • Out channels: 16

What is the output shape?

A. (16, 30, 30)
B. (16, 32, 32)
C. (3, 32, 32)
D. (16, 34, 34)

Q2 (short) — equivariance vs invariance

In one sentence each:

    1. What does it mean for a layer to be translation equivariant?
    1. What does it mean for a layer to be translation invariant?

Q3 (MCQ) — pooling

Which of the following is not a good reason to use pooling?

A. To reduce spatial resolution and computation
B. To introduce local translation invariance
C. To guarantee that the network never overfits
D. To gradually increase receptive field size

Q4 (short numeric) — receptive field size

You stack:

  1. Conv (3×3, stride 1, padding 1)
  2. Conv (3×3, stride 1, padding 1)
  3. MaxPool (2×2, stride 2)

What is the receptive field size (in terms of input pixels) of a unit after the max pool?

(Assume square kernels and ignore boundary effects.)

References

Abdel-Hamid, Ossama, Abdel-rahman Mohamed, Hui Jiang, Li Deng, Gerald Penn, and Dong Yu. 2014. “Convolutional Neural Networks for Speech Recognition.” IEEE/ACM Transactions on Audio, Speech, and Language Processing 22 (10): 1533–45. https://doi.org/10.1109/TASLP.2014.2339736.
Bissoto, Alceu, Michel Fornaciali, Eduardo Valle, and Sandra Avila. 2019. “(De)constructing Bias on Skin Lesion Datasets.” In 2019 IEEE/CVF Conference on Computer Vision and Pattern Recognition Workshops (CVPRW), 2766–74. https://doi.org/10.1109/CVPRW.2019.00335.
Fredrikson, Matt, Somesh Jha, and Thomas Ristenpart. 2015. “Model Inversion Attacks That Exploit Confidence Information and Basic Countermeasures.” In Proceedings of the 22nd ACM SIGSAC Conference on Computer and Communications Security, 1322–33. CCS ’15. Association for Computing Machinery. https://doi.org/10.1145/2810103.2813677.
Krizhevsky, Alex, Ilya Sutskever, and Geoffrey E. Hinton. 2012. ImageNet Classification with Deep Convolutional Neural Networks.” In Advances in Neural Information Processing Systems (NeurIPS), 25:1106–14.
LeCun, Yann, Léon Bottou, Yoshua Bengio, and Patrick Haffner. 1998. “Gradient-Based Learning Applied to Document Recognition.” Proceedings of the IEEE 86 (11): 2278–2324. https://doi.org/10.1109/5.726791.
Piczak, Karol J. 2015. “Environmental Sound Classification with Convolutional Neural Networks.” In 2015 IEEE 25th International Workshop on Machine Learning for Signal Processing (MLSP), 1–6. https://doi.org/10.1109/MLSP.2015.7324337.
Shokri, Reza, Marco Stronati, Congzheng Song, and Vitaly Shmatikov. 2017. “Membership Inference Attacks Against Machine Learning Models.” In 2017 IEEE Symposium on Security and Privacy (SP), 3–18. IEEE. https://doi.org/10.1109/SP.2017.41.
Simonyan, Karen, and Andrew Zisserman. 2015. “Very Deep Convolutional Networks for Large-Scale Image Recognition.” In International Conference on Learning Representations (ICLR). https://arxiv.org/abs/1409.1556.
Szegedy, Christian, Wojciech Zaremba, Ilya Sutskever, Joan Bruna, Dumitru Erhan, Ian Goodfellow, and Rob Fergus. 2014. “Intriguing Properties of Neural Networks.” In International Conference on Learning Representations (ICLR), Poster.