Greg’s blog
  • Blog
  • Experiments

Table of Contents

  • Introduction
  • Prerequisites
  • VGG-11
    • Activations Across Depth
  • ResNet-50
    • Activations Across Residual Stages
  • Conclusions

Visualizing Feature Maps from VGG11 and ResNet50 in PyTorch

pytorch
nn
Author

Gregor Cerar

Published

2025-05-06

Abstract

Convolutional neural networks produce intermediate activation maps whose channel count and spatial resolution change across the architecture. This post records selected maps from pretrained VGG-11 and ResNet-50 models with PyTorch forward hooks, then displays individual channels and principal-component summaries.

Introduction

A convolutional layer produces one spatial activation map per output channel. Looking at those maps can reveal how resolution, sparsity, and spatial structure change as an image moves through a network, although the maps do not by themselves explain why the classifier made a decision.

This post uses PyTorch forward hooks to record selected activations from pretrained VGG-11 and ResNet-50 models. I display individual channels and a compact principal-component projection, then compare how the two architectures reduce spatial resolution across depth.

Prerequisites

The implementation uses NumPy, Matplotlib, scikit-learn, PyTorch, and Torchvision.

import math
from collections.abc import Callable
from pathlib import Path
from typing import Final, Literal

import numpy as np
import torch
from matplotlib import pyplot as plt
from matplotlib.figure import Figure
from sklearn.decomposition import PCA
from torch import Tensor, nn
from torchvision import models
from torchvision.io import decode_image
from torchvision.transforms import v2 as T

DPI: Final[int] = 200  # 2x the ~800px content column, so figures stay crisp on HiDPI screens
FIGURES = Path("./figures")


def save_fig(fig: Figure, name: str, lossless: bool = True) -> None:
    """Write a figure into figures/ as WebP, keeping its transparent background."""
    FIGURES.mkdir(parents=True, exist_ok=True)
    opts = {"lossless": True} if lossless else {"quality": 90, "alpha_quality": 100}
    fig.savefig(FIGURES / name, dpi=DPI, pil_kwargs={"method": 6, **opts})
    plt.close(fig)

Both models use Torchvision weights pretrained on ImageNet-1K.

The preprocessing pipeline resizes each image to 256 pixels on its shorter side, takes a 224x224 center crop, converts values to floating point, and normalizes each channel with ImageNet statistics. This matches the input convention used by the pretrained weights.

# ImageNet normalization weights per channel
IMAGENET1K_MEAN = [0.485, 0.456, 0.406]
IMAGENET1K_STD = [0.229, 0.224, 0.225]

transform = T.Compose(
    [
        T.Resize(256),
        T.CenterCrop(224),
        T.ToImage(),
        T.ToDtype(torch.float32, scale=True),
        T.Normalize(IMAGENET1K_MEAN, IMAGENET1K_STD),
    ]
)


def load_image(path: str | Path) -> Tensor:
    # Transform images into tensors
    img: Tensor = transform(decode_image(str(path)))

    # Add dimension to imitate batch size equal to 1: (C,H,W) -> (B,C,H,W)
    img = img.unsqueeze(0)
    return img
def inverse_normalize(
    x_norm: Tensor,
    mean: list[float] = IMAGENET1K_MEAN,
    std: list[float] = IMAGENET1K_STD,
) -> Tensor:
    # Ensure mean and std have the correct shape
    _mean = torch.as_tensor(mean).to(x_norm.device).view(1, -1, 1, 1)
    _std = torch.as_tensor(std).to(x_norm.device).view(1, -1, 1, 1)
    # Inverse normalization: x = x_normalized * std + mean
    return x_norm.mul(_std).add(_mean)


reverse_transform = T.Compose(
    [
        T.Lambda(inverse_normalize),
        T.Lambda(lambda x: torch.clamp(x, min=0.0, max=1.0)),
    ]
)
sample = load_image(FIGURES / "bridge.jpg")
orig_sample = reverse_transform(sample)

fig, ax = plt.subplots(frameon=False)
fig.subplots_adjust()
ax.imshow(orig_sample.squeeze(0).permute(1, 2, 0))
ax.axis("off")
save_fig(fig, "original-image.webp", lossless=False)

Original image, resized.
def get_activation(name: str, activations: dict[str, Tensor]) -> Callable:
    def hook(model: nn.Module, tensor: Tensor, output: Tensor) -> None:
        # map layer's `name` to layer's output value
        activations[name] = output.detach()

    return hook


def set_hooks(model: nn.Module, layer_ids: list[str], out: dict[str, Tensor]) -> None:
    layer_ids = [str(i) for i in layer_ids]
    for name, module in model.named_modules():
        if name in layer_ids:
            module.register_forward_hook(get_activation(name, out))
def visualize_feature_maps(
    feature_map: Tensor | np.ndarray,
    max_maps: int | None = None,
    max_cols: int = 8,
    figsize_per_plot: float = 1.0,
    norm: Literal["linear", "log", "symlog", "logit", None] = None,
    cmap: str = "viridis",
):
    if isinstance(feature_map, Tensor):
        feature_map = feature_map.cpu().numpy()

    if feature_map.ndim == 4:
        feature_map = feature_map.squeeze(0)  # remove batch dimension if present
    assert feature_map.ndim == 3, "Expected tensor shape (C, H, W)"

    C, H, W = feature_map.shape

    if max_maps:
        C = min(C, max_maps)

    n_cols = min(C, max_cols)
    n_rows = math.ceil(C / n_cols)

    figsize = (figsize_per_plot * n_cols, figsize_per_plot * n_rows)

    fig, axes = plt.subplots(nrows=n_rows, ncols=n_cols, figsize=figsize, frameon=False, squeeze=False)
    fig.subplots_adjust(wspace=0.03, hspace=0.03)

    for ax in axes.flat:
        ax.axis("off")

    for i in range(C):
        t = feature_map[i]
        axes.flat[i].imshow(t, cmap=cmap, norm=norm, aspect="equal", interpolation="none")

    return fig, axes
def minmax_scale_per_channel(arr: np.ndarray, eps: float = 1e-5) -> np.ndarray:
    """Per-channel MinMax normalization. Expects (C, W, H)."""
    assert arr.ndim == 3, f"{arr.ndim=}"

    c_min = arr.min(axis=(1, 2), keepdims=True)
    c_max = arr.max(axis=(1, 2), keepdims=True)

    scaled = (arr - c_min) / (c_max - c_min + eps)  # avoid division by zero
    return scaled


def pca_rgb(
    feature_map: np.ndarray | Tensor,
    n_components: Literal[1, 3] = 3,
    normalize: bool = True,
    random_state: int | None = None,
) -> np.ndarray:
    if isinstance(feature_map, torch.Tensor):
        feature_map = feature_map.cpu().numpy()

    if feature_map.ndim == 4:
        feature_map = feature_map.squeeze(0)  # remove batch dimension if present
    assert feature_map.ndim == 3, "Expected array shape (C, H, W)"

    C, H, W = feature_map.shape
    pca = PCA(n_components=n_components, random_state=random_state)
    flat = feature_map.reshape(C, -1).T
    rgb = pca.fit_transform(flat).T.reshape(n_components, H, W)

    if normalize:
        rgb = minmax_scale_per_channel(rgb)

    return rgb


def visualize_feature_maps_pca(
    feature_maps: dict[str, Tensor],
    n_components: Literal[1, 3] = 3,
    max_cols: int = 4,
    figsize_per_plot: float = 2.0,
    norm: Literal["linear", "log", "symlog", "logit", None] = None,
    subtitles: bool = True,
    cmap: str = "viridis",
):
    c = len(feature_maps)
    n_cols = min(c, max_cols)
    n_rows = math.ceil(c / n_cols)
    fig_size = (figsize_per_plot * n_cols, figsize_per_plot * n_rows)

    fig, axes = plt.subplots(n_rows, n_cols, figsize=fig_size, squeeze=False, frameon=False)
    fig.subplots_adjust(wspace=0.03, hspace=0.20, top=0.85)

    for ax in axes.flat:
        ax.axis("off")

    for ax, (layer, feature_map) in zip(axes.flat, feature_maps.items(), strict=False):
        rgb_features = pca_rgb(feature_map, n_components=n_components)
        rgb_features = rgb_features.transpose(1, 2, 0)
        rgb_features = rgb_features.squeeze()

        ax.imshow(rgb_features, cmap=cmap, norm=norm, aspect="equal", interpolation="none")
        if subtitles:
            ax.set_title(layer, color="0.5")

    return fig, axes

VGG-11

Simonyan and Zisserman introduced the VGG family as networks built from repeated 3x3 convolutions and periodic spatial pooling (Simonyan and Zisserman 2014). VGG-11 is no longer a competitive ImageNet architecture, but its sequential feature extractor makes changes in channel count and spatial resolution easy to inspect.

model = models.vgg11(weights=models.VGG11_Weights.IMAGENET1K_V1).features

# Let's inspect the VGG's feature extractor layers
model
Sequential(
  (0): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (1): ReLU(inplace=True)
  (2): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  (3): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (4): ReLU(inplace=True)
  (5): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  (6): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (7): ReLU(inplace=True)
  (8): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (9): ReLU(inplace=True)
  (10): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  (11): Conv2d(256, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (12): ReLU(inplace=True)
  (13): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (14): ReLU(inplace=True)
  (15): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  (16): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (17): ReLU(inplace=True)
  (18): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (19): ReLU(inplace=True)
  (20): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
)
# cherry-pick layers of which outputs we want to see
selected_layers = ["0", "3", "6", "8", "11", "13", "16", "18"]

# add forward hooks to the model
vgg_activations = {}
set_hooks(model, selected_layers, vgg_activations)

# make forward pass through NN
with torch.no_grad():
    model(sample)

A feature map, also called an activation map, is one channel of a layer’s output tensor. A convolutional layer applies learned spatial kernels to its input and produces a stack of these maps. The figures below show at most 64 channels per selected layer, so layers with more channels are only partially displayed.

for layer, filters in vgg_activations.items():
    fig, _ = visualize_feature_maps(filters, max_maps=8 * 8, norm="linear")
    save_fig(fig, f"vgg-layer-{layer}.webp")

Activations Across Depth

The first two selected convolutions sit on opposite sides of the first max-pooling layer. Layer 0 produces 64 full-resolution maps; layer 3 produces 128 maps on a grid whose height and width have each been halved.

VGG-11 layer 0 activations before the first max-pooling operation.

VGG-11 layer 3 activations after the first max-pooling operation.

Layers 6 and 8 share the next spatial scale and each produce 256 channels. Layers 11 and 13 follow another pooling operation and produce 512 channels. The figures keep the same 64-map display limit, making the increase in unshown channels explicit.

VGG-11 layer 6 activations after the second max-pooling operation.

VGG-11 layer 8 activations at the same spatial scale as layer 6.

VGG-11 layer 11 activations after the third max-pooling operation.

VGG-11 layer 13 activations at the same spatial scale as layer 11.

Layers 16 and 18 come after the fourth pooling operation. They retain 512 channels on the smallest displayed VGG grid, so each activation covers a larger region of the original image than an activation in layer 0.

VGG-11 layer 16 activations after the fourth max-pooling operation.

VGG-11 layer 18 activations at the final selected VGG-11 convolution.

Displaying every channel becomes impractical as their number grows. The pca_rgb helper treats each spatial position as one sample and the channel activations at that position as its features. PCA.fit_transform returns scores along the first one or three principal axes; the code reshapes those scores into grayscale or three-channel images and normalizes each displayed channel. It does not reconstruct or average the original feature maps. The projection is a lossy summary, and its three colors represent principal-component coordinates rather than natural image colors.

fig, _ = visualize_feature_maps_pca(vgg_activations, max_cols=4)
save_fig(fig, "vgg-pca.webp", lossless=False)

Principal-component projections of the feature-map stacks for the corresponding layers of VGG-11.

ResNet-50

He et al. introduced residual networks as stacks of blocks that learn a residual function and add it to a shortcut connection (He et al. 2015). Their experiments showed that this formulation made substantially deeper networks easier to optimize, including a 152-layer ImageNet model. Here I use pretrained ResNet-50 and record the stem convolution plus the output of each residual stage.

model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)

# inspect layers within ResNet
model
ResNet(
  (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
  (relu): ReLU(inplace=True)
  (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False)
  (layer1): Sequential(
    (0): Bottleneck(
      (conv1): Conv2d(64, 64, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(64, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (downsample): Sequential(
        (0): Conv2d(64, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
        (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      )
    )
    (1): Bottleneck(
      (conv1): Conv2d(256, 64, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(64, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
    (2): Bottleneck(
      (conv1): Conv2d(256, 64, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(64, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
  )
  (layer2): Sequential(
    (0): Bottleneck(
      (conv1): Conv2d(256, 128, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(128, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (downsample): Sequential(
        (0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      )
    )
    (1): Bottleneck(
      (conv1): Conv2d(512, 128, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(128, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
    (2): Bottleneck(
      (conv1): Conv2d(512, 128, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(128, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
    (3): Bottleneck(
      (conv1): Conv2d(512, 128, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(128, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
  )
  (layer3): Sequential(
    (0): Bottleneck(
      (conv1): Conv2d(512, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(256, 1024, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(1024, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (downsample): Sequential(
        (0): Conv2d(512, 1024, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(1024, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      )
    )
    (1): Bottleneck(
      (conv1): Conv2d(1024, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(256, 1024, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(1024, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
    (2): Bottleneck(
      (conv1): Conv2d(1024, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(256, 1024, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(1024, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
    (3): Bottleneck(
      (conv1): Conv2d(1024, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(256, 1024, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(1024, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
    (4): Bottleneck(
      (conv1): Conv2d(1024, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(256, 1024, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(1024, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
    (5): Bottleneck(
      (conv1): Conv2d(1024, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(256, 1024, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(1024, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
  )
  (layer4): Sequential(
    (0): Bottleneck(
      (conv1): Conv2d(1024, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(512, 2048, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (downsample): Sequential(
        (0): Conv2d(1024, 2048, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      )
    )
    (1): Bottleneck(
      (conv1): Conv2d(2048, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(512, 2048, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
    (2): Bottleneck(
      (conv1): Conv2d(2048, 512, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (conv3): Conv2d(512, 2048, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (bn3): BatchNorm2d(2048, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
    )
  )
  (avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
  (fc): Linear(in_features=2048, out_features=1000, bias=True)
)
selected_layers = ["conv1", "layer1", "layer2", "layer3", "layer4"]
resnet_feature_maps: dict[str, Tensor] = {}
set_hooks(model, selected_layers, resnet_feature_maps)

with torch.no_grad():
    model(sample)
for layer, filters in resnet_feature_maps.items():
    fig, _ = visualize_feature_maps(filters, max_maps=8 * 8, norm="linear")
    save_fig(fig, f"resnet-layer-{layer}.webp")

Activations Across Residual Stages

For the 224x224 input, conv1 produces a 112x112 grid. The following max-pooling operation reduces the grid before layer1, whose output is 56x56.

ResNet-50 stem-convolution activations on a 112x112 spatial grid.

ResNet-50 layer1 activations after the stem’s spatial downsampling.

Each subsequent stage begins with another spatial reduction: layer2, layer3, and layer4 produce 28x28, 14x14, and 7x7 grids, respectively, while expanding the output to 512, 1024, and 2048 channels. As above, each figure displays only the first 64 channels.

ResNet-50 layer2 activations on a 28x28 spatial grid.

ResNet-50 layer3 activations on a 14x14 spatial grid.

ResNet-50 layer4 activations on a 7x7 spatial grid.

These hooks capture the output of each complete residual stage, not the internal branches of its individual bottleneck blocks.

fig, _ = visualize_feature_maps_pca(resnet_feature_maps, max_cols=3)
save_fig(fig, "resnet-pca.webp", lossless=False)

Principal-component projections of the feature-map stacks for the corresponding layers of ResNet-50.

Conclusions

Forward hooks provide a lightweight way to capture intermediate activations without changing a model’s forward method. In these examples, the visualizations make architectural facts concrete: pooling and strided stages shrink the spatial grid, while later stages expose more channels.

An activation map shows where one channel responds for one input, but it does not establish what concept that channel represents or which pixels caused the final prediction. The PCA view is even more compressed: it preserves dominant variance across channels while discarding most of the original activation tensor.

For prediction attribution or systematic interpretability analysis, purpose-built methods are more appropriate. Captum provides PyTorch attribution implementations, while SHAP provides Shapley-value-based explanations.

References

He, Kaiming, Xiangyu Zhang, Shaoqing Ren, and Jian Sun. 2015. “Deep Residual Learning for Image Recognition.” arXiv Preprint arXiv:1512.03385.
Simonyan, Karen, and Andrew Zisserman. 2014. “Very Deep Convolutional Networks for Large-Scale Image Recognition.” arXiv Preprint arXiv:1409.1556.

Reuse

CC BY-NC-SA 4.0
 

© Copyright 2021, Gregor Cerar