ML Grind

E2E: Convolutional Neural Network - End-to-End Implementations

Implement E2E: Convolutional Neural Network in Python with NumPy. hard exercise with examples, hints, and instant feedback. No setup or signup required.

End-to-End CNN

What you'll build

A LeNet-style image classifier in NumPy: 2-D convolution, max pooling and flatten as standalone functions, then a CNN class that chains two conv blocks and two fully connected layers into logits.

Architecture (input (batch, C, H, W), e.g. MNIST (batch, 1, 28, 28))

LayerOperationOutput shape (28×28 input)
conv1conv2d(x, conv1_w (32, C, 3, 3), conv1_b) + ReLU(batch, 32, 26, 26)
pool1maxpool2d(·, 2)(batch, 32, 13, 13)
conv2conv2d(·, conv2_w (64, 32, 3, 3), conv2_b) + ReLU(batch, 64, 11, 11)
pool2maxpool2d(·, 2)(batch, 64, 5, 5)
flattenflatten(·)(batch, 1600)
fc1· @ fc1_w + fc1_b + ReLU(batch, 128)
fc2· @ fc2_w + fc2_b(batch, num_classes) logits (no softmax)

General sizes: a 3×3 valid conv maps H → H − 2; a 2×2 pool maps H → H // 2 (an odd leftover row/column is dropped). So flat_size = 64 · h2 · w2 with h1 = (H − 2) // 2, h2 = (h1 − 2) // 2 (same for W).

Function contract

FunctionBehaviourReturns
conv2d(x, kernel, bias)stride 1, no padding, cross-correlation (no kernel flip, like every DL framework): out[b, o, i, j] = Σ_{c,u,v} x[b, c, i+u, j+v] · kernel[o, c, u, v] + bias[o](batch, out_ch, H − kH + 1, W − kW + 1)
maxpool2d(x, pool_size=2)non-overlapping windows (stride = pool_size), max per window and channel(batch, ch, H // p, W // p)
flatten(x)keep the batch axis, merge the rest(batch, −1)
CNN(input_shape, num_classes)creates conv1_w/b, conv2_w/b (given, after np.random.seed(42)), then fc1_w (flat_size, 128), fc1_b, fc2_w (128, num_classes), fc2_b (weights N(0, 0.1²), zero biases)
CNN.forward(x)the table abovelogits (batch, num_classes)
test_cnn()a smoke test: build CNN((1, 28, 28), 10), run 4 random imagesTrue if the logits have shape (4, 10)

Constraints

NumPy only. Loops over output positions are acceptable for clarity, but try to vectorise conv2d (np.lib.stride_tricks.sliding_window_view + np.einsum): the naive 4-level loop is very slow in the browser.

Follow-up

  • Parameter count of conv1 and fc1 for MNIST? (32·1·9 + 32 = 320 and 1600·128 + 128 = 204,928: the dense layer dominates.)
  • How does im2col turn convolution into one matrix multiply?

Examples

Input: conv2d(np.arange(16.0).reshape(1, 1, 4, 4), np.array([[[[1.0, 0.0], [2.0, -1.0]]]]), np.array([0.5]))
Output: [[[[3.5, 5.5, 7.5], [11.5, 13.5, 15.5], [19.5, 21.5, 23.5]]]]

With x[i, j] = 4i + j, each output is x[i, j] + 2·x[i+1, j] − x[i+1, j+1] + 0.5 = 8i + 2j + 3.5

Input: maxpool2d(np.array([[[[1, 3, 2, 0], [4, 2, 1, 5], [0, 1, 7, 6], [2, 8, 3, 1]]]]))
Output: [[[[4.0, 5.0], [8.0, 7.0]]]]

The max of each non-overlapping 2×2 block

Input: CNN((1, 28, 28), 10).forward(np.random.randn(2, 1, 28, 28)).shape
Output: (2, 10)

28 → 26 → 13 → 11 → 5, so fc1 sees 64·5·5 = 1600 features

Practice in Python

import numpy as np

def conv2d(x: np.ndarray, kernel: np.ndarray, bias: np.ndarray) -> np.ndarray:
    """
    2D convolution (cross-correlation, no padding, stride=1).

    Args:
        x: Input (batch, in_channels, height, width)
        kernel: Weights (out_channels, in_channels, kH, kW)
        bias: Bias (out_channels,)

    Returns:
        output: (batch, out_channels, out_height, out_width)
    """
    # Your code here
    pass


def maxpool2d(x: np.ndarray, pool_size: int = 2) -> np.ndarray:
    """
    2D max pooling with non-overlapping windows (stride = pool_size).

    Args:
        x: Input (batch, channels, height, width)
        pool_size: Size of pooling window

    Returns:
        output: (batch, channels, height // pool_size, width // pool_size)
    """
    # Your code here
    pass


def flatten(x: np.ndarray) -> np.ndarray:
    """Flatten all dimensions except batch."""
    # Your code here
    pass


class CNN:
    def __init__(self, input_shape: tuple, num_classes: int):
        """
        Initialize CNN.

        Args:
            input_shape: (channels, height, width)
            num_classes: Number of output classes
        """
        np.random.seed(42)
        in_channels, H, W = input_shape

        # Conv layer 1: in_channels -> 32
        self.conv1_w = np.random.randn(32, in_channels, 3, 3) * 0.1
        self.conv1_b = np.zeros(32)

        # Conv layer 2: 32 -> 64
        self.conv2_w = np.random.randn(64, 32, 3, 3) * 0.1
        self.conv2_b = np.zeros(64)

        # Flattened size after conv(3x3) -> pool(2) -> conv(3x3) -> pool(2)
        # Your code here

        # Fully connected layers: fc1_w (flat_size, 128), fc1_b (128,),
        # fc2_w (128, num_classes), fc2_b (num_classes,); weights * 0.1, biases zero
        # Your code here
        pass

    def forward(self, x: np.ndarray) -> np.ndarray:
        """
        Forward pass through CNN.

        Args:
            x: Input images (batch, channels, height, width)

        Returns:
            logits: (batch, num_classes), no softmax
        """
        # Your code here
        pass


def test_cnn():
    """Smoke test: CNN((1, 28, 28), 10) on 4 random images; return True if logits are (4, 10)."""
    # Your code here
    pass

The interactive editor loads with Python and NumPy. Run your implementation against 7 tests, or use the progressive hints.

More exercises in this section →