🎓 A1Learner
🔍

PyTorch What the CNN Learned

What the CNN learned is stored in its kernels. Each first-layer kernel is a 3 by 3 pattern of weights, printed as a shade grid in this lesson.

The first layer's weight tensor

conv1.weight holds all 8 kernels in one tensor. The middle 1 is the single input channel.

model = ShapesCNN()
print(model.conv1.weight.shape)

One kernel on its own

Two indices pick one kernel out of the tensor. The first index picks the filter, and the second picks the channel.

kernel = model.conv1.weight.detach()[0, 0]
print(kernel.shape)

From numbers to shades

Each weight maps to one character by its place between the layer's smallest and largest weights. Equal weights print as equal characters.

shades = ".:-=+*#@"
level = int((value - low) / (high - low) * 7.99)
char = shades[level]

Example

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset


def draw_shape(label, g):
    rows = torch.arange(16).view(16, 1).float()
    cols = torch.arange(16).view(1, 16).float()
    if label == 0:
        centre = 8 + int(torch.randint(-2, 3, (1,), generator=g))
        mask = (rows >= centre - 1) & (rows <= centre + 1) & (cols >= 0)
    elif label == 1:
        cr = 7.5 + int(torch.randint(-1, 2, (1,), generator=g))
        cc = 7.5 + int(torch.randint(-1, 2, (1,), generator=g))
        dist = torch.sqrt((rows - cr) ** 2 + (cols - cc) ** 2)
        mask = (dist >= 4.5) & (dist <= 6.5)
    else:
        top = int(torch.randint(2, 7, (1,), generator=g))
        left = int(torch.randint(2, 7, (1,), generator=g))
        mask = (rows >= top) & (rows <= top + 7) & (cols >= left) & (cols <= left + 7)
    return mask.float()


def make_shapes(count, seed):
    g = torch.Generator().manual_seed(seed)
    labels = torch.arange(count) % 3
    labels = labels[torch.randperm(count, generator=g)]
    images = [draw_shape(int(label), g) for label in labels]
    return torch.stack(images).unsqueeze(1), labels


class ShapesCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 8, 3, padding=1)
        self.pool = nn.MaxPool2d(2)
        self.conv2 = nn.Conv2d(8, 16, 3, padding=1)
        self.gap = nn.AdaptiveAvgPool2d(1)
        self.head = nn.Linear(16, 3)

    def forward(self, x):
        x = self.pool(torch.relu(self.conv1(x)))
        x = torch.relu(self.conv2(x))
        x = self.gap(x)
        return self.head(torch.flatten(x, 1))


train_x, train_y = make_shapes(240, seed=42)
val_x, val_y = make_shapes(60, seed=43)
loader = DataLoader(TensorDataset(train_x, train_y), batch_size=32,
                    shuffle=True, generator=torch.Generator().manual_seed(0))

torch.manual_seed(1)
model = ShapesCNN()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=4, gamma=0.5)
loss_fn = nn.CrossEntropyLoss()


def accuracy():
    model.eval()
    with torch.no_grad():
        predictions = model(val_x).argmax(dim=1)
    return float((predictions == val_y).float().mean())


for epoch in range(8):
    model.train()
    for xb, yb in loader:
        optimizer.zero_grad()
        loss = loss_fn(model(xb), yb)
        loss.backward()
        optimizer.step()
    scheduler.step()

print(f"Validation accuracy: {accuracy():.3f}")
print("First-layer kernels (dark cells weigh most):")
shades = ".:-=+*#@"
kernels = model.conv1.weight.detach()
low = float(kernels.min())
high = float(kernels.max())
for index in range(kernels.shape[0]):
    print(f"Kernel {index}:")
    for row in kernels[index, 0].tolist():
        line = ""
        for value in row:
            level = int((value - low) / (high - low) * 7.99)
            line += shades[level]
        print(line)

Key points

  • Kernel: a small grid of weights that one conv filter slides over the image
  • conv1.weight: 8 kernels of shape (3, 3), one per filter on the single input channel
  • In the gallery, the darkest cells hold the largest weights in the layer
  • Kernel 5 weighs its top and bottom rows and mostly skips the middle row
  • Kernel 6 weighs its middle column most and carries the layer's largest weights

In this example, the program trains the same CNN as the Training a CNN lesson. The data, seeds, and schedule match that lesson too. The validation accuracy prints first and reaches 1.000. The first layer's weight tensor then gives up its 8 kernels. Each kernel prints as three rows of shade characters. The scale is shared by the whole layer, so shades compare across kernels. Kernel 1 is dark in most cells, so its weights are high almost everywhere. Kernel 5 is dark on its top and bottom rows and pale in the middle. Kernel 6 carries several @ cells, the largest weights in the layer, down its middle column.

Note

The kernels come from training, not from a hand design, and a different seed would grow different kernels.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Python Playground

Exercise

What does one first-layer kernel hold?