Machine Learning for CybersecuritySource on GitHub

Unit 6 · Lab 6a

A convolutional network and transfer learning

Lab 6a

A convolutional network and transfer learning

# On Google Colab, install the scikit-learn version the course was built with.
# On your own computer, install requirements.txt instead; this cell then does nothing.
import subprocess, sys
if "google.colab" in sys.modules:
    subprocess.run([sys.executable, "-m", "pip", "install", "-q", "scikit-learn==1.9.0"], check=True)

Lab 6a. A convolutional network and transfer learning

This lab has two parts. First it trains a small convolutional network (CNN) on a subset of MNIST, counts its parameters layer by layer, and plots its eight first-layer filters and the feature maps they produce. Second it studies transfer learning on CIFAR-10, comparing a frozen ResNet-18 feature extractor followed by logistic regression with a small network trained from scratch as the number of labeled training images grows. Everything runs on CPU within a few minutes.

You will:

  1. train a small CNN on an MNIST subset and read off its parameter count by layer;
  2. visualize the first-layer filters and the feature maps they produce;
  3. extract pretrained ResNet-18 features from CIFAR-10 images and classify them with logistic regression;
  4. train a small CNN from scratch on the same CIFAR-10 subsets;
  5. plot accuracy against the number of labeled images for both approaches, averaged over seeds.

MNIST and CIFAR-10 are standard image datasets. They stand in for the image-shaped security data of Lesson 6.1, such as malware byte-plots, because they are small, labeled and well understood. The learning mechanics are identical.

import os
import warnings

import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.linear_model import LogisticRegression
from torchvision import transforms
from torchvision.datasets import MNIST, CIFAR10
import torchvision.models as models

warnings.filterwarnings("ignore", category=UserWarning)
torch.set_num_threads(4)
SEED = 20251015
torch.manual_seed(SEED)
rng = np.random.default_rng(SEED)
ROOT = "../../data/downloads"

1. A small CNN on MNIST

We take a balanced subset of MNIST to keep training fast. The network has two convolutional layers, each followed by max pooling, then two fully connected layers. Max pooling keeps the largest activation in each 2 by 2 window, which lowers the resolution and makes the features less sensitive to small shifts.

mnist_train = MNIST(root=ROOT, train=True, download=True)
mnist_test = MNIST(root=ROOT, train=False, download=True)
Xtr = (mnist_train.data.numpy().astype(np.float32) / 255.0)
ytr = mnist_train.targets.numpy()
Xte = (mnist_test.data.numpy().astype(np.float32) / 255.0)
yte = mnist_test.targets.numpy()

# Balanced subset: 600 training and 200 test images per digit.
def balanced_subset(X, y, per_class, seed):
    r = np.random.default_rng(seed)
    idx = []
    for c in range(10):
        pool = np.flatnonzero(y == c)
        idx.extend(r.choice(pool, per_class, replace=False))
    idx = np.array(idx)
    r.shuffle(idx)
    return X[idx], y[idx]

Xm, ym = balanced_subset(Xtr, ytr, 600, SEED)
Xmte, ymte = balanced_subset(Xte, yte, 200, SEED + 1)
Xm_t = torch.from_numpy(Xm).unsqueeze(1)
ym_t = torch.from_numpy(ym).long()
Xmte_t = torch.from_numpy(Xmte).unsqueeze(1)
print(f"MNIST subset: {len(Xm)} train, {len(Xmte)} test")


class SmallCNN(nn.Module):
    def __init__(self, in_ch=1, n_classes=10, hw=28):
        super().__init__()
        self.conv1 = nn.Conv2d(in_ch, 8, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(8, 16, kernel_size=3, padding=1)
        flat = 16 * (hw // 4) * (hw // 4)
        self.fc1 = nn.Linear(flat, 64)
        self.fc2 = nn.Linear(64, n_classes)

    def features(self, x):
        a1 = F.relu(self.conv1(x))
        p1 = F.max_pool2d(a1, 2)
        a2 = F.relu(self.conv2(p1))
        p2 = F.max_pool2d(a2, 2)
        return a1, p1, a2, p2

    def forward(self, x):
        _, _, _, p2 = self.features(x)
        z = p2.flatten(1)
        z = F.relu(self.fc1(z))
        return self.fc2(z)


cnn = SmallCNN()
MNIST subset: 6000 train, 2000 test

Parameter count by layer

The convolutional layers have few parameters because their weights are shared across all positions. The fully connected layers, which connect every input to every output, hold most of the parameters. The cell below lists each layer's count.

layer_params = {}
for name, p in cnn.named_parameters():
    layer_params[name] = int(p.numel())
by_layer = {}
for name, n in layer_params.items():
    key = name.split(".")[0]
    by_layer[key] = by_layer.get(key, 0) + n
total_params = sum(by_layer.values())
for k, n in by_layer.items():
    print(f"{k:<6} {n:>8,}  ({100 * n / total_params:.1f}%)")
print(f"{'total':<6} {total_params:>8,}")

conv_params = by_layer["conv1"] + by_layer["conv2"]
fc_params = by_layer["fc1"] + by_layer["fc2"]
# For comparison, a dense first layer mapping the 784 input pixels to the same number of
# first-layer feature values (8 channels at 28x28) would have this many parameters
# (weights and biases).
dense_equiv = (28 * 28) * (8 * 28 * 28) + (8 * 28 * 28)
print(f"conv layers: {conv_params:,}; fully connected: {fc_params:,}")
print(f"a dense layer producing the same first feature map: {dense_equiv:,} parameters")
conv1        80  (0.2%)
conv2     1,168  (2.2%)
fc1      50,240  (96.4%)
fc2         650  (1.2%)
total    52,138
conv layers: 1,248; fully connected: 50,890
a dense layer producing the same first feature map: 4,923,520 parameters

Train the CNN

A few epochs of Adam are enough on this subset to reach high accuracy.

def train_cnn(model, X, y, epochs=8, lr=3e-3, batch=128, seed=0):
    r = np.random.default_rng(seed)
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.CrossEntropyLoss()
    n = len(X)
    for ep in range(epochs):
        order = r.permutation(n)
        model.train()
        for i in range(0, n, batch):
            idx = order[i:i + batch]
            opt.zero_grad()
            loss = loss_fn(model(X[idx]), y[idx])
            loss.backward()
            opt.step()
    return model


@torch.no_grad()
def accuracy(model, X, y):
    model.eval()
    preds = model(X).argmax(1).numpy()
    return float((preds == y).mean())


train_cnn(cnn, Xm_t, ym_t, epochs=8, seed=SEED)
mnist_acc = accuracy(cnn, Xmte_t, ymte)
print(f"MNIST subset test accuracy: {mnist_acc:.3f}")
MNIST subset test accuracy: 0.967

2. Filters and feature maps

The first convolutional layer learns eight 3 by 3 filters. Each one slides over the image and responds to a small local pattern. The cell below extracts the eight filters and the feature map each of them produces on one example digit, a 7 from the test subset, and plots them. The digit is at the left, the filters are in the top row, and their feature maps are in the bottom row, where brighter means a stronger response.

filters = cnn.conv1.weight.detach().numpy()[:, 0, :, :]  # 8 filters, 3x3
example = Xmte_t[ymte == 7][0:1]  # a digit 7
a1, p1, a2, p2 = cnn.features(example)
fmaps = a1.detach().numpy()[0]  # 8 feature maps, 28x28
example_img = example.numpy()[0, 0]

fig, axes = plt.subplots(2, 9, figsize=(10, 2.6))
axes[0, 0].imshow(example_img, cmap="gray")
axes[1, 0].axis("off")
for k in range(8):
    axes[0, k + 1].imshow(filters[k], cmap="gray")
    axes[1, k + 1].imshow(fmaps[k], cmap="gray")
for ax in axes.flat:
    ax.set_xticks([])
    ax.set_yticks([])
plt.tight_layout()
plt.show()
Plot produced by the cell above

3. Transfer learning on CIFAR-10 with pretrained features

CIFAR-10, a set of small color photographs of ten object classes, is harder than MNIST. A network trained from scratch on a few hundred images overfits. Transfer learning reuses a network trained on a large dataset, here ImageNet; in this lab the pretrained network serves as a fixed feature extractor. We pass CIFAR-10 images through a pretrained ResNet-18 with its classification head removed, take the 512-dimensional output, and train a logistic regression on top. The ResNet weights never change, so this is the frozen feature extractor approach of Lesson 6.1.

cifar_train = CIFAR10(root=ROOT, train=True, download=True)
cifar_test = CIFAR10(root=ROOT, train=False, download=True)
Ctr = cifar_train.data  # (50000, 32, 32, 3) uint8
ctr = np.array(cifar_train.targets)
Cte = cifar_test.data
cte = np.array(cifar_test.targets)

# A fixed, balanced test set and a labeled pool to draw training subsets from.
pool_X, pool_y = balanced_subset(Ctr, ctr, 100, SEED)       # 1000 images, 100 per class
test_X, test_y = balanced_subset(Cte, cte, 200, SEED + 2)   # 2000 images, 200 per class

IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]
resnet_tf = transforms.Compose([
    transforms.ToTensor(),
    transforms.Resize(64, antialias=True),
    transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
])


def to_resnet_batch(imgs):
    return torch.stack([resnet_tf(img) for img in imgs])


backbone = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
backbone.fc = nn.Identity()
backbone.eval()


@torch.no_grad()
def resnet_features(imgs, batch=200):
    feats = []
    for i in range(0, len(imgs), batch):
        x = to_resnet_batch(imgs[i:i + batch])
        feats.append(backbone(x).numpy())
    return np.concatenate(feats)


pool_feat = resnet_features(pool_X)
test_feat = resnet_features(test_X)
print(f"ResNet-18 features: pool {pool_feat.shape}, test {test_feat.shape}")
ResNet-18 features: pool (1000, 512), test (2000, 512)

4. A small CNN trained from scratch on CIFAR-10

For comparison we train the same style of small CNN directly on the raw 32 by 32 images, with no pretraining.

def cifar_tensors(imgs):
    x = imgs.astype(np.float32) / 255.0
    x = (x - np.array(IMAGENET_MEAN)) / np.array(IMAGENET_STD)
    return torch.from_numpy(x.transpose(0, 3, 1, 2)).float()


test_X_t = cifar_tensors(test_X)
test_y_np = test_y

5. Accuracy as a function of the number of labeled images

For each training size we draw a balanced subset from the labeled pool of 1000 images, train both methods, and record test accuracy, averaged over three seeds. At 1000 images the subset is the whole pool, so the seeds then differ only in the order of the images and, for the from-scratch network, in its initialization. The frozen-feature approach is expected to win by a wide margin when labels are scarce.

SIZES = [100, 300, 1000]
SEEDS = [0, 1, 2]
transfer_acc = {n: [] for n in SIZES}
scratch_acc = {n: [] for n in SIZES}

for n in SIZES:
    per_class = n // 10
    for sd in SEEDS:
        r = np.random.default_rng(1000 + sd)
        sel = []
        for c in range(10):
            pc = np.flatnonzero(pool_y == c)
            sel.extend(r.choice(pc, per_class, replace=False))
        sel = np.array(sel)

        clf = LogisticRegression(max_iter=2000, C=1.0)
        clf.fit(pool_feat[sel], pool_y[sel])
        transfer_acc[n].append(float((clf.predict(test_feat) == test_y).mean()))

        torch.manual_seed(sd)
        net = SmallCNN(in_ch=3, n_classes=10, hw=32)
        Xsub = cifar_tensors(pool_X[sel])
        ysub = torch.from_numpy(pool_y[sel]).long()
        train_cnn(net, Xsub, ysub, epochs=25, lr=3e-3, seed=sd)
        scratch_acc[n].append(accuracy(net, test_X_t, test_y_np))
    print(f"n={n:>4}  transfer {np.mean(transfer_acc[n]):.3f}  scratch {np.mean(scratch_acc[n]):.3f}")

fig, ax = plt.subplots(figsize=(6, 3.2))
for acc, label in [(transfer_acc, "frozen ResNet-18 features"),
                   (scratch_acc, "small CNN from scratch")]:
    ax.errorbar(SIZES, [np.mean(acc[n]) for n in SIZES],
                yerr=[np.std(acc[n]) for n in SIZES],
                marker="o", capsize=3, label=label)
ax.axhline(0.1, ls=":", color="gray", label="uniform guessing")
ax.set_xscale("log")
ax.set_xticks(SIZES, [str(n) for n in SIZES])
ax.minorticks_off()
ax.set_ylim(0, 0.8)
ax.set_xlabel("labeled training images")
ax.set_ylabel("test accuracy")
ax.legend()
plt.tight_layout()
plt.show()
n= 100  transfer 0.562  scratch 0.244
n= 300  transfer 0.614  scratch 0.326
n=1000  transfer 0.675  scratch 0.404
Plot produced by the cell above

The cell above prints the mean accuracy of each method at each training size and plots both curves, with error bars showing the standard deviation across seeds. The frozen ResNet-18 features, classified by a simple logistic regression, reach higher accuracy than the from-scratch CNN at every size, and the gap is largest when labels are scarcest. This is the practical case for transfer learning in a security setting where labeled attack images are few.

Try this

Results summary

These are the numbers quoted in the lesson text.

results = {
    "mnist_train": int(len(Xm)),
    "mnist_test": int(len(Xmte)),
    "mnist_acc": float(mnist_acc),
    "total_params": int(total_params),
    "conv_params": int(conv_params),
    "fc_params": int(fc_params),
    "fc_fraction": float(fc_params / total_params),
    "conv1_params": int(by_layer["conv1"]),
    "conv2_params": int(by_layer["conv2"]),
    "fc1_params": int(by_layer["fc1"]),
    "fc2_params": int(by_layer["fc2"]),
    "dense_equiv_params": int(dense_equiv),
    "cifar_test": int(len(test_X)),
    "transfer_acc_100": float(np.mean(transfer_acc[100])),
    "transfer_acc_300": float(np.mean(transfer_acc[300])),
    "transfer_acc_1000": float(np.mean(transfer_acc[1000])),
    "scratch_acc_100": float(np.mean(scratch_acc[100])),
    "scratch_acc_300": float(np.mean(scratch_acc[300])),
    "scratch_acc_1000": float(np.mean(scratch_acc[1000])),
    "gap_100": float(np.mean(transfer_acc[100]) - np.mean(scratch_acc[100])),
}
for k, v in results.items():
    print(f"{k:<22} {v}")

export = {
    "sizes": SIZES,
    "transfer_mean": [float(np.mean(transfer_acc[n])) for n in SIZES],
    "transfer_std": [float(np.std(transfer_acc[n])) for n in SIZES],
    "scratch_mean": [float(np.mean(scratch_acc[n])) for n in SIZES],
    "scratch_std": [float(np.std(scratch_acc[n])) for n in SIZES],
    "filters": filters.tolist(),
    "feature_maps": fmaps.tolist(),
    "example_img": example_img.tolist(),
    "layer_names": ["conv1", "conv2", "fc1", "fc2"],
    "layer_counts": [int(by_layer[k]) for k in ["conv1", "conv2", "fc1", "fc2"]],
}

if os.environ.get("COURSE_RESULTS"):
    import json
    with open(os.environ["COURSE_RESULTS"], "w") as f:
        json.dump({"results": results, "data": export}, f)
mnist_train            6000
mnist_test             2000
mnist_acc              0.9665
total_params           52138
conv_params            1248
fc_params              50890
fc_fraction            0.9760635237254978
conv1_params           80
conv2_params           1168
fc1_params             50240
fc2_params             650
dense_equiv_params     4923520
cifar_test             2000
transfer_acc_100       0.5618333333333333
transfer_acc_300       0.6141666666666666
transfer_acc_1000      0.6746666666666666
scratch_acc_100        0.24433333333333332
scratch_acc_300        0.3261666666666667
scratch_acc_1000       0.4040000000000001
gap_100                0.3175