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)Visualizing Feature Maps from VGG11 and ResNet50 in PyTorch
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.
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 imgdef 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)
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, axesdef 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, axesVGG-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
modelSequential(
(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.


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.




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.


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)
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
modelResNet(
(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.


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.



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)
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.