EasyVGG

VGG Conv Block

Very Deep Convolutional Networks

Easy

Problem

Implement a VGG convolution block in NHWC layout. Apply every supplied same-padded convolution kernel and bias in order, followed by ReLU after each layer. All kernels use the same positive odd spatial size. Return the final activations as a float64 NumPy array.

Theory

A conv block is the core repeating unit of VGGNet (Simonyan & Zisserman, 2014). It stacks multiple convolution-then-ReLU layers in sequence before passing the result to a pooling layer. This deceptively simple pattern of small 3 \times 3 filters repeated two or three times replaced the large single-layer convolutions of earlier architectures like AlexNet, achieving deeper networks with fewer parameters and stronger nonlinear capacity.


What It Is

A VGG conv block takes an input tensor and applies a fixed number of convolution layers back to back, with a ReLU activation after each convolution. There is no pooling, normalization, or skip connection inside the block. Pooling happens after the block, not within it.

In the simplified (pointwise) version used in this problem, each convolution is a linear transform applied independently at every spatial position. Given an input tensor x of shape (B, H, W, C_{in}), a weight matrix W of shape (C_{in}, C_{out}), and a bias vector b of shape (C_{out},), the convolution computes:

\texttt{out}[b, h, w, :] = x[b, h, w, :] \cdot W + b

followed by element-wise ReLU:

\texttt{out} = \max(0, \texttt{out})

This pair of operations (linear transform + ReLU) repeats for each layer in the block. The output of one layer becomes the input to the next. The first layer maps from C_{in} to C_{out} channels; all subsequent layers map from C_{out} to C_{out}, keeping the channel dimension constant within the block.

Spatial dimensions (H and W) are preserved throughout the block because VGGNet uses same-padding (p = 1 for 3 \times 3 kernels with stride 1). Downsampling is handled exclusively by $ \times 2$ max pooling with stride 2 after each block.


Key Equations

For a block with L convolution layers, the computation is a chain of linear-then-ReLU steps. Let a_0 = x be the block input.

For each layer l = 1, 2, \ldots, L:

z_l[b, h, w, :] = a_{l-1}[b, h, w, :] \cdot W_l + b_l

a_l = \max(0, ; z_l)

The block output is a_L. Each weight matrix W_l and bias vector b_l is a separate set of learnable parameters:

The ReLU activation \max(0, z) is applied element-wise, zeroing out all negative values. It introduces nonlinearity between every pair of linear transforms. Without ReLU, stacking L linear layers would collapse to a single linear layer (W_1 W_2 \cdots W_L is still a matrix), defeating the purpose of depth.


Why 3 \times 3 Filters

The central insight of the VGGNet paper is that a stack of small 3 \times 3 convolution filters can replace a single large filter while achieving the same effective receptive field. A 3 \times 3 filter is the smallest kernel that captures directional structure: left/right, up/down, and center. The paper states explicitly: "The use of three 3 \times 3 conv. layers instead of a single 7 \times 7 layer leads to more non-linearities and fewer parameters."

Receptive Field Equivalence

When convolutions use stride 1 and same-padding, stacking them grows the effective receptive field linearly:

The general formula for L stacked 3 \times 3 layers is:

\text{receptive field} = 2L + 1

So L = 1 gives 3, L = 2 gives 5, and L = 3 gives 7.


Parameter Savings

The parameter advantage of stacked 3 \times 3 layers over a single large kernel is significant. For a single convolution layer with C input channels and C output channels:

For the 7 \times 7 case:

Beyond raw parameter count, each stacked layer adds a ReLU activation. Two 3 \times 3 layers give two nonlinearities versus one for a single 5 \times 5 layer. Three 3 \times 3 layers give three nonlinearities versus one for a single 7 \times 7 layer. More nonlinearities mean a more discriminative function at each stage, which the VGGNet results confirm: deeper configurations consistently outperform shallower ones on ImageNet.


The Stacking Pattern

VGGNet organizes its convolutional layers into five groups (blocks), each followed by 2 \times 2 max pooling with stride 2. The number of conv layers per block and the channel count vary across configurations. The paper evaluates six configurations (A through E), with the two most cited being VGG-16 (configuration D) and VGG-19 (configuration E).

VGG-16 (Configuration D)

Total: 13 conv layers + 3 FC layers = 16 weight layers (hence "VGG-16").

VGG-19 (Configuration E)

Blocks 3, 4, and 5 each have 4 conv layers instead of 3. Total: 16 conv layers + 3 FC layers = 19 weight layers.

Within every block, all conv layers use the same number of output channels. The channel count doubles at each block boundary (64, 128, 256, 512, 512), while spatial dimensions halve due to pooling. This creates a consistent trade-off: as spatial resolution shrinks, feature depth grows.


Paper Context

Simonyan and Zisserman published "Very Deep Convolutional Networks for Large-Scale Image Recognition" in 2014 (presented at ICLR 2015). The paper's central contribution is demonstrating that network depth, when achieved through stacked small filters, is a critical factor for image classification accuracy. At the time, AlexNet (2012) used large kernels (11 \times 11, 5 \times 5), and the prevailing belief was that the first layer needed a large receptive field to capture meaningful patterns from raw pixels.

VGGNet challenged this by showing that a uniform architecture of exclusively 3 \times 3 conv filters, stacked to depth, could substantially outperform AlexNet. The paper systematically evaluates configurations from 11 weight layers (A) to 19 weight layers (E), demonstrating consistent improvement with depth. VGG-16 and VGG-19 achieved state-of-the-art results on ImageNet 2014, with a top-5 test error of 7.3% using an ensemble.

The paper also introduced a practical training strategy: start by training the shallower configuration A, then initialize deeper networks by copying the first and last layers from A and initializing new intermediate layers randomly. This staged training approach addressed the difficulty of training very deep networks before batch normalization and residual connections were widely adopted.

VGGNet's influence extends beyond classification accuracy. Its simple, uniform block structure became a template for feature extraction in object detection (Faster R-CNN), segmentation (FCN), and style transfer. The conv block pattern of "stack N convolutions of the same channel size, then pool" became a foundational design principle in CNN architectures.


Numerical Example

Consider a small conv block with 2 layers. Input x has shape (1, 2, 2, 3): one image, 2 \times 2 spatial, 3 input channels. The block outputs 2 channels.

Layer 1: Linear Transform + ReLU

W_1 \in \mathbb{R}^{3 \times 2}, b_1 \in \mathbb{R}^{2}:

W_1 = \begin{pmatrix} 1 & -1 \\ 0 & 2 \\ -1 & 1 \end{pmatrix}, \quad b_1 = \begin{pmatrix} 0 & 0 \end{pmatrix}

Pixel at (h=0, w=0) with x[0,0,0,:] = (1, 0, 2):

z_1[0,0,0,:] = (1, 0, 2) \cdot \begin{pmatrix} 1 & -1 \\ 0 & 2 \\ -1 & 1 \end{pmatrix} + (0, 0) = (1 \cdot 1 + 0 \cdot 0 + 2 \cdot (-1), ; 1 \cdot (-1) + 0 \cdot 2 + 2 \cdot 1) = (-1, ; 1)

a_1[0,0,0,:] = \max(0, (-1, 1)) = (0, ; 1)

Pixel at (h=0, w=1) with x[0,0,1,:] = (2, 1, 0):

z_1[0,0,1,:] = (2, 1, 0) \cdot W_1 + b_1 = (2 + 0 + 0, ; -2 + 2 + 0) = (2, ; 0)

a_1[0,0,1,:] = \max(0, (2, 0)) = (2, ; 0)

Pixel at (h=1, w=0) with x[0,1,0,:] = (0, 1, 1):

z_1[0,1,0,:] = (0, 1, 1) \cdot W_1 + b_1 = (0 + 0 - 1, ; 0 + 2 + 1) = (-1, ; 3)

a_1[0,1,0,:] = \max(0, (-1, 3)) = (0, ; 3)

Pixel at (h=1, w=1) with x[0,1,1,:] = (1, 1, 1):

z_1[0,1,1,:] = (1, 1, 1) \cdot W_1 + b_1 = (1 + 0 - 1, ; -1 + 2 + 1) = (0, ; 2)

a_1[0,1,1,:] = \max(0, (0, 2)) = (0, ; 2)

After Layer 1, a_1 has shape (1, 2, 2, 2) with values (0, 1), (2, 0), (0, 3), (0, 2).

Layer 2: Linear Transform + ReLU

W_2 \in \mathbb{R}^{2 \times 2}, b_2 \in \mathbb{R}^{2} (note: C_{out} \to C_{out}, not C_{in} \to C_{out}):

W_2 = \begin{pmatrix} 1 & 0 \\ -1 & 1 \end{pmatrix}, \quad b_2 = \begin{pmatrix} 0.5 & -0.5 \end{pmatrix}

Position (0, 0), input (0, 1):

z_2[0,0,0,:] = (0, 1) \cdot \begin{pmatrix} 1 & 0 \\ -1 & 1 \end{pmatrix} + (0.5, -0.5) = (0 - 1 + 0.5, ; 0 + 1 - 0.5) = (-0.5, ; 0.5)

a_2[0,0,0,:] = \max(0, (-0.5, 0.5)) = (0, ; 0.5)

Position (0, 1), input (2, 0):

z_2[0,0,1,:] = (2, 0) \cdot W_2 + b_2 = (2 + 0 + 0.5, ; 0 + 0 - 0.5) = (2.5, ; -0.5)

a_2[0,0,1,:] = \max(0, (2.5, -0.5)) = (2.5, ; 0)

Position (1, 0), input (0, 3):

z_2[0,1,0,:] = (0, 3) \cdot W_2 + b_2 = (0 - 3 + 0.5, ; 0 + 3 - 0.5) = (-2.5, ; 2.5)

a_2[0,1,0,:] = \max(0, (-2.5, 2.5)) = (0, ; 2.5)

Position (1, 1), input (0, 2):

z_2[0,1,1,:] = (0, 2) \cdot W_2 + b_2 = (0 - 2 + 0.5, ; 0 + 2 - 0.5) = (-1.5, ; 1.5)

a_2[0,1,1,:] = \max(0, (-1.5, 1.5)) = (0, ; 1.5)

Final block output has shape (1, 2, 2, 2) with values (0, 0.5), (2.5, 0), (0, 2.5), (0, 1.5). ReLU after each layer clips negatives, creating sparse activations. Without the intermediate ReLU, the two linear layers would collapse to a single matrix W_1 W_2 and the network would lose the representational benefit of depth.


Pitfalls


Examples

Example 1

Input
x = [[[[1],[-1]],[[2],[0.5]]]], kernels = [[[[[2,-1]]]]], biases = [[0,0.5]]
Output
[[[[2,0],[0,1.5]],[[4,0],[1,0]]]]
Explanation
The supplied kernel mixes input channels at every spatial position before ReLU.

Example 2

Input
x = [[[[1,-1]]]], kernels = [[[[[1,0],[0.5,1]]]],[[[[0.5],[2]]]]], biases = [[0,0.1],[-0.2]]
Output
[[[[0.05]]]]

Example 3

Input
x = [[[[1],[2],[3]],[[4],[5],[6]],[[7],[8],[9]]]], kernels = [[[[[0]],[[0]],[[0]]],[[[0]],[[1]],[[0]]],[[[0]],[[0]],[[0]]]]], biases = [[-5]]
Output
[[[[0],[0],[0]],[[0],[0],[1]],[[2],[3],[4]]]]

Hints

  1. Pad height and width by kernel_size // 2.
  2. Advance through kernels and biases together.
  3. Use np.tensordot to contract each patch with its kernel.

Requirements

Constraints

Starter Code

import numpy as np

def vgg_conv_block(x: np.ndarray, kernels: list,
                   biases: list) -> np.ndarray:
    """
    Returns the float64 block activations in NHWC layout.
    """
    pass

Test Cases

CaseMatches
One convolutionpublic
Two convolutionspublic
Spatial kernelpublic