Session S11

Transfer Learning and Data Augmentation

Two ways to put knowledge into a model when labels are scarce: through its weights, and through its data.

Deep Learning · CS 4715/5715 · University of Idaho

📋 Admin: the project guide is released today

  • Full details, rubric and model card template: course site → project guide.
  • Teams of 1–3 (CS 5715: at most 2); every member submits every deliverable. Any deep learning problem; the Colab free tier is enough for a well-scoped one.
  • Pre-proposal (20 of the project's 350 points) due the end of the S13 week · proposal (70) the end of the S23 week · final report (155) the end of the S27 week · recorded presentation (105) the Sunday before S28 · code defense in S28 or S29, only for students the instructor calls.
  • Fair-comparison policy: a well-designed experiment where the simple baseline wins is a successful project.
  • Questions about the guide: office hours.

Graded on experimental design and analysis, not leaderboard position. Start thinking about topics now.

S10's networks learned from a million labels; your project will have far fewer

VGG and ResNet (S10) were trained on ImageNet: over a million labeled images.

  • Field data, medical imaging, a course project: a few hundred to a few thousand labeled examples.
  • A CNN trained from scratch on that little data can fit it perfectly and still generalize poorly. Today's first experiment has 24 training images.
Transfer learning

Knowledge enters through the weights: reuse features learned on a large task as the starting point for a small one.

Data augmentation

Knowledge enters through the data: label-preserving transforms encode invariances you know the task has.

Both address the same few-label regime, one through the weights and one through the data; in practice they are combined.

Why features transfer: early layers are general, late layers task-specific

A trained CNN builds its features layer by layer (S09).

Early layers
oriented edges, color blobs: edges are edges, whatever the task
→
Middle layers
textures, grids, object parts
→
Late layers and head
configurations tied to the training classes
  • Yosinski et al. (NeurIPS 2014) copied the first \(n\) layers between networks trained on disjoint halves of ImageNet: early layers transferred well, and transfer got worse with depth.
  • DeCAF and "CNN Features off-the-shelf" (2013–14): a pretrained ImageNet CNN's features (AlexNet in DeCAF, OverFeat in off-the-shelf) plus a linear classifier beat engineered pipelines across benchmarks.

The trunk of a network trained on a big dataset is a learned feature extractor; its head belongs to the old task.

The transfer recipe: keep the trunk, replace the head

A pretrained classifier is a trunk that computes features and a head that maps them to the source task's classes.

input
\((N,C,H,W)\)
→
pretrained trunk
→
features
\((N,D)\)
→
new head
nn.Linear(D, K)
→
logits
\((N,K)\)
  1. Take a network pretrained on a large source task.
  2. Discard its classification head (for ImageNet, \(D\to1000\) classes).
  3. Attach a fresh head for your \(K\) classes.
  4. Freeze the trunk and train only the head, or fine-tune some or all trunk weights at a small learning rate.

The recipe has not changed since about 2014. What varies is how much of the trunk you let move.

Trunk or head: where do VGG-16's 138M parameters sit?

Build VGG-16 (S10) from the paper's configuration table; import torch and import torch.nn as nn first.


cfg = [64, 64, "M", 128, 128, "M", 256, 256, 256, "M",
       512, 512, 512, "M", 512, 512, 512, "M"]   # output channels; "M": 2x2 max pool
layers, c_in = [], 3
for v in cfg:
    if v == "M":
        layers.append(nn.MaxPool2d(2))
    else:
        layers += [nn.Conv2d(c_in, v, 3, padding=1), nn.ReLU()]
        c_in = v
features = nn.Sequential(*layers)
classifier = nn.Sequential(nn.Flatten(), nn.Linear(512 * 7 * 7, 4096),
    nn.ReLU(), nn.Dropout(), nn.Linear(4096, 4096), nn.ReLU(),
    nn.Dropout(), nn.Linear(4096, 1000))
count = lambda m: sum(p.numel() for p in m.parameters())   # numel: entries per tensor
print(count(features), count(classifier), count(classifier[1]))
  
14714688 123642856 102764544

Trunk, 13 conv layers: 14.7M. Head, 3 fully connected: 123.6M, the first alone 102.8M. Total 138,357,544: the paper's 138M.

Five max pools take 224 to 7; the last stages hold the most conv weights

Run features (previous page) on two \(224\times224\) images; print the shape and, with count, the parameter count at each max pool.


h, n = torch.randn(2, 3, 224, 224), 0
for layer in features:
    h = layer(h)
    n += count(layer)
    if isinstance(layer, nn.MaxPool2d):   # the end of a stage
        print(tuple(h.shape), n)
        n = 0
    

(2, 64, 112, 112) 38720
(2, 128, 56, 56) 221440
(2, 256, 28, 28) 1475328
(2, 512, 14, 14) 5899776
(2, 512, 7, 7) 7079424
    
  • padding=1 keeps \(H,W\) (S09); each nn.MaxPool2d(2) halves them: \(224/2^5=7\).
  • Stage 5: three \(3\times3\) convs, 512 to 512 channels, \(512\cdot512\cdot9+512\) parameters each.
  • Flattening \((N,512,7,7)\) gives 25,088 inputs for nn.Linear(25088, 4096).

The 102.8M layer exists because the head flattens a \(512\times7\times7\) map into one long vector.

Global average pooling replaces the flatten

\(123{,}642{,}856/138{,}357{,}544=89.4\%\) of VGG-16 is its classifier. Global average pooling (GAP) averages each channel's \(7\times7\) map to one number.


x = torch.randn(2, 3, 224, 224)       # two RGB images
feat = features(x)                    # features: the VGG-16 page
print(feat.shape)
print(nn.Flatten()(feat).shape)       # what the classifier receives
print(feat.mean(dim=(2, 3)).shape)    # global average pooling
    

torch.Size([2, 512, 7, 7])
torch.Size([2, 25088])
torch.Size([2, 512])
    

feat.mean(dim=(2, 3)) is a reduction over the two spatial axes \(H\) and \(W\): one mean per image and channel. nn.AdaptiveAvgPool2d(1) is the module form of this reduction.

into the headthe whole headparameters
Flatten\((N,512,7,7)\to(N,25088)\)VGG-16's three nn.Linear layers123,642,856
GAPmean over \(H,W\): \((N,512)\)one nn.Linear(512, 1000)513,000

GoogLeNet and ResNet classify from the averaged vector, so ResNet-152 (about 60M parameters) is smaller than VGG-16 (138M).

GAP also frees the head from the input size

The flatten head is sized for \(224\times224\). Feed \(256\times256\) through features and classifier (VGG-16 page).


x2 = torch.randn(2, 3, 256, 256)            # a larger image
feat2 = features(x2)
print(feat2.shape)                          # 256 / 2**5 = 8
gap = nn.AdaptiveAvgPool2d(1)               # average to 1x1, whatever H, W arrive
print(gap(feat2).flatten(1).shape)          # flatten(1): keep axis 0
print(torch.allclose(gap(feat2).flatten(1), feat2.mean(dim=(2, 3))))
try:
    classifier(feat2)                       # the flatten head
except RuntimeError as e:
    print(e)
    

torch.Size([2, 512, 8, 8])
torch.Size([2, 512])
True
mat1 and mat2 shapes cannot be multiplied (2x32768 and 25088x4096)
    

Flattening \(8\times8\) gives \(512\cdot64=32{,}768\) inputs where the head expects 25,088. GAP gives 512 numbers whatever the map's size, so a GAP backbone runs at your image size (at least \(32\times32\) for five pools); for transfer, still feed the size it was pretrained at (S12).

At a downsampling block, \(F(X)\) and \(X\) have different shapes

Toward ResNet-18, the backbone we transfer: its stages 2–4 halve the resolution and double the channels at their first block. Try the identity skip there (torch, nn as before).


x = torch.randn(8, 64, 56, 56)
branch = nn.Sequential(                    # F: stride 2, 64 -> 128 channels
    nn.Conv2d(64, 128, 3, stride=2, padding=1), nn.ReLU(),
    nn.Conv2d(128, 128, 3, padding=1))
print(branch(x).shape, x.shape)
try:
    branch(x) + x                          # the identity skip
except RuntimeError as e:
    print(e)
    

torch.Size([8, 128, 28, 28]) torch.Size([8, 64, 56, 56])
The size of tensor a (28) must match the size of tensor b (56) at non-singleton dimension 3
    
  • Stride 2: \(O=\lfloor(56+2-3)/2\rfloor+1=28\) (S09). PyTorch compares shapes from the last axis: 28 against 56 fails first; 128 against 64 channels would fail too.

The identity skip needs equal shapes, so at a downsampling block the skip must change \(X\).

A \(1\times1\) convolution with stride 2 fixes the skip

Put a convolution on the skip that produces 128 channels at \(28\times28\) (x and branch from the previous page, count from the VGG-16 page).


shortcut = nn.Conv2d(64, 128, kernel_size=1, stride=2)
print(shortcut(x).shape)
out = branch(x) + shortcut(x)             # the projection shortcut
print(out.shape, count(shortcut))
    

torch.Size([8, 128, 28, 28])
torch.Size([8, 128, 28, 28]) 8320
    
  • The \(1\times1\) convolution mixes the 64 channels into 128 at each pixel (S09); stride 2 keeps every second row and column: \(\lfloor(56-1)/2\rfloor+1=28\).
  • Cost: \(64\cdot128\) weights \(+\,128\) biases \(=8{,}320\) parameters. ResNet calls this the projection shortcut.
  • Where the shapes already match, the skip stays the identity and adds no parameters.

ResNet projects only where a block changes the shape; every other block keeps the identity skip.

BatchNorm2d: S07's rule, applied per channel over \((N,H,W)\)

ResNet uses batch normalization (BN) after every convolution. S07's BatchNorm1d on \((N,C)\) normalizes each column over \(N\) rows; BatchNorm2d normalizes each channel over \(N\cdot H\cdot W\) values.


torch.manual_seed(0)
h = torch.randn(8, 16, 5, 5) * 3 + 1              # (N, C, H, W)
bn = nn.BatchNorm2d(16)
mean = h.mean(dim=(0, 2, 3), keepdim=True)          # (1, 16, 1, 1)
var = h.var(dim=(0, 2, 3), unbiased=False, keepdim=True)
manual = (h - mean) / torch.sqrt(var + bn.eps)
print(mean.shape, torch.allclose(bn(h), manual, atol=1e-5))
print(count(bn))
  

torch.Size([1, 16, 1, 1]) True
32
  
  • dim=(0, 2, 3): reduce over batch, height, width. keepdim=True keeps \((1,16,1,1)\), which broadcasts back over \((8,16,5,5)\).
  • unbiased=False: divide by the count, \(8\cdot5\cdot5=200\), as BatchNorm does in training mode.
  • \(\gamma\) starts at 1, \(\beta\) at 0, one of each per channel: 32 parameters.

Per channel: one mean, one variance, one \(\gamma\), one \(\beta\), shared by all its positions.

The ResNet basic block: conv, BN, ReLU, conv, BN, add, ReLU

Reading 2.2's Block, plus a BatchNorm after each convolution and a projection when the shape changes.


class BasicBlock(nn.Module):
    def __init__(self, c_in, c_out, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(c_in, c_out, 3, stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(c_out)
        self.conv2 = nn.Conv2d(c_out, c_out, 3, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(c_out)
        self.shortcut = nn.Identity()               # shapes match: no parameters
        if stride != 1 or c_in != c_out:            # the projection shortcut
            self.shortcut = nn.Sequential(
                nn.Conv2d(c_in, c_out, 1, stride, bias=False), nn.BatchNorm2d(c_out))
    def forward(self, x):
        out = torch.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        return torch.relu(out + self.shortcut(x))
  
  • bias=False: the BatchNorm that follows subtracts each channel's mean, which would cancel a bias; its \(\beta\) supplies the shift.

ResNet-18: a stem, four stages of basic blocks, GAP, one linear layer

Stack BasicBlock in four stages of two; stages 2–4 halve the resolution and double the channels (count: VGG-16 page).


def resnet(blocks_per_stage, num_classes=1000):
    layers = [nn.Conv2d(3, 64, 7, stride=2, padding=3, bias=False),   # stem
              nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(3, stride=2, padding=1)]
    c_in = 64
    for i, (c, n) in enumerate(zip([64, 128, 256, 512], blocks_per_stage)):
        for j in range(n):
            stride = 2 if i > 0 and j == 0 else 1   # first block of stages 2-4
            layers.append(BasicBlock(c_in, c, stride))
            c_in = c
    layers += [nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(512, num_classes)]
    return nn.Sequential(*layers)
print(count(resnet([2, 2, 2, 2])), count(resnet([3, 4, 6, 3])))   # ResNet-18, -34
  
11689512 21797672
input
\(3\times224^2\)
stem, \(\div4\)
\(64\times56^2\)
stage 1 ×2
\(64\times56^2\)
stage 2 ×2
\(128\times28^2\)
stage 3 ×2
\(256\times14^2\)
stage 4 ×2
\(512\times7^2\)
GAP
\(512\)
linear
\(1000\)

18 weight layers: stem, 16 block convs, final linear; projections not counted.

ResNet-18 as a trunk and a head: 11.2M to keep, 1,539 to train

The transfer recipe on the network just built: everything before the last nn.Linear is the trunk.


net = resnet([2, 2, 2, 2])                  # ResNet-18, previous page
trunk, old_head = net[:-1], net[-1]         # the head is the last nn.Linear
x = torch.randn(4, 3, 224, 224)
feats = trunk(x)
print(feats.shape, count(trunk), count(old_head))
head = nn.Linear(512, 3)                    # a fresh head for 3 classes
print(head(feats).shape, count(head))
    

torch.Size([4, 512]) 11176512 513000
torch.Size([4, 3]) 1539
    
  • net[:-1] slices an nn.Sequential: every module but the last. The trunk ends with global average pooling, nn.AdaptiveAvgPool2d(1) (each channel averaged to \(1\times1\)), and nn.Flatten(), so its features are \((N,512)\).

The old head had \(512\cdot1000+1000=513{,}000\) parameters. Freeze the trunk and only the new head's \(512\cdot3+3=1{,}539\) train: the linear probe.

Self-check: fix the skip at a downsampling block

A residual block maps \((N,64,32,32)\) to \((N,128,16,16)\). Why does out + x fail, and which projection shortcut fixes it? How many parameters does it add?

The channels (128 against 64) and the resolution (16 against 32) differ. nn.Conv2d(64, 128, kernel_size=1, stride=2) on the skip: \(64\cdot128+128=8{,}320\) parameters.

ResNet's own block (bias=False, then BatchNorm2d(128)) adds \(64\cdot128+2\cdot128=8{,}448\).

A RuntimeError at out + x means the block changed \(H,W\) or \(C\): put a \(1\times1\) convolution with the block's stride on the skip.

After ResNet: how to spend a compute budget

Once depth stopped being the obstacle, depth, width and resolution became separate choices (reading 2.2).

networkchangereported result
Pre-activation ResNet
(He et al., ECCV 2016)
BN and ReLU before each conv; nothing after the addition1001 layers trained on CIFAR-10, with improved accuracy
Wide ResNet
(Zagoruyko, Komodakis, BMVC 2016)
fewer layers, more channelson CIFAR, 16 wide layers outperform thousand-layer thin nets
DenseNet
(Huang et al., CVPR 2017)
each layer gets all earlier outputs, concatenatedResNet-level ImageNet accuracy at about half the parameters and compute
EfficientNet
(Tan, Le, ICML 2019)
depth, width and resolution scaled jointlyB0: 76.3% ImageNet top-1 with 5.3M parameters
ConvNeXt
(Liu et al., CVPR 2022)
ResNet-50 modernized step by step: recipe, \(7\times7\) kernels, LayerNormmatches or exceeds comparable Swin Transformers

Pre-activation's design rule: anything placed on the identity path, such as a ReLU or a BatchNorm, multiplies every gradient that crosses it.

Freeze or fine-tune: decide by data size and domain

ResNet-18's trunk has 11.2M parameters and the new head 1,539. How many of them should train?

what trainswhenrisk
Freeze
(linear probe)
the head onlyfew labels; target resembles the sourcefeatures may not suit an unusual domain
Fine-tunehead and some or all of the trunk, at a learning rate \(10\text{–}100\times\) smaller than from scratchmore labels, or a different domain (medical scans, spectrograms)a large learning rate destroys pretrained features
Middle paththe last block or two, plus the headin between
  • Why the small rate: the new head starts random, so the first gradients that reach the trunk are driven by the head's arbitrary weights (reading 2.3).

Default: freeze first, and unfreeze once the data can support it.

A miniature you can run: pretrain on bars, transfer to crosses

No downloads: reading 2.3 builds the pretrained network itself, on \(16\times16\) grayscale images with Gaussian noise (standard deviation 0.4) added.

Task A (source)
2,000 images,
4 orientations

class 0

class 1

class 2

class 3

Task B (target)
24 labeled images

class 0: "+"

class 1: "x"

Noise-free \(9\times9\) crops drawn with the reading's draw_bar; each bar is 7 pixels long at a random position.

A "+" is bars of classes 0 and 2; an "x" is bars of classes 1 and 3. A trunk that detects task A's four orientations already computes the features task B needs.

The trunk and the head, as tensors

One architecture for the pretrained network, the transferred model and the from-scratch baseline.


import torch
import torch.nn as nn
def make_trunk():
    return nn.Sequential(
        nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),   # (N, 8, 8, 8)
        nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),  # (N, 16, 4, 4)
        nn.Flatten(),                                                 # (N, 256)
    )
count = lambda m: sum(p.numel() for p in m.parameters())
trunk, head = make_trunk(), nn.Linear(256, 2)
x = torch.randn(24, 1, 16, 16)          # 24 grayscale 16x16 images
feats = trunk(x)
print(feats.shape, head(feats).shape)
print(count(trunk), count(head), count(trunk) + count(head))
  

torch.Size([24, 256]) torch.Size([24, 2])
1248 514 1762
  

Trunk: \(80+1{,}168=1{,}248\) parameters. Head: \(256\cdot2+2=514\). HW2 (out at S12) calls this architecture SmallCNN.

The data tensors and the training function

For each seed, reading 2.3 seeds torch with it and builds these tensors with its make_task_a and make_task_b; one short function trains every model.

tensorsshapescontents
Xa, ya(2000, 1, 16, 16), (2000,)task A images; labels 0–3
Xb, yb(24, 1, 16, 16), (24,)task B training images; labels 0 ("+"), 1 ("x")
Xb_te, yb_te(500, 1, 16, 16), (500,)task B test images

def train(model, X, y, epochs, params=None):
    params = list(model.parameters()) if params is None else list(params)
    opt = torch.optim.Adam(params, lr=1e-3)      # updates only these params
    loss_fn = nn.CrossEntropyLoss()
    for _ in range(epochs):
        perm = torch.randperm(len(X))            # new order every epoch
        for i in range(0, len(X), 64):           # minibatches of 64
            idx = perm[i:i + 64]
            opt.zero_grad()
            loss_fn(model(X[idx]), y[idx]).backward()
            opt.step()
  

Transfer and baseline: same data and budget, different starting trunk

Inside reading 2.3's loop over seeds 0–2, which builds Xa, ya, Xb, yb with make_task_a and make_task_b; make_trunk and train come from the previous pages.


pre = nn.Sequential(make_trunk(), nn.Linear(256, 4))
train(pre, Xa, ya, epochs=8)             # 1) pretrain trunk + 4-way head on task A

trunk = pre[0]                           # 2) transfer: keep the trunk ...
for p in trunk.parameters():
    p.requires_grad = False              #    ... freeze it ...
torch.manual_seed(seed + 100)
head = nn.Linear(256, 2)                 #    ... and train a fresh 2-way head
transfer = nn.Sequential(trunk, head)
train(transfer, Xb, yb, epochs=150, params=head.parameters())

torch.manual_seed(seed + 100)            # 3) baseline: same architecture, from scratch
scratch = nn.Sequential(make_trunk(), nn.Linear(256, 2))
train(scratch, Xb, yb, epochs=150)
  
  • requires_grad = False: backward computes no gradient for the trunk, and the optimizer, built over head.parameters(), updates only the head's 514 parameters.
  • The baseline trains all 1,762 parameters on the same 24 images for the same 150 epochs.

Transfer wins in each of the three seeds

Test accuracy on the 500 task-B test images (reading 2.3's printout):


seed 0: transfer = 0.7640, scratch = 0.5940
seed 1: transfer = 0.7740, scratch = 0.5380
seed 2: transfer = 0.9480, scratch = 0.8800
transfer mean test accuracy = 0.8287
scratch  mean test accuracy = 0.6707
  
  • Paired by seed (same task-B data), transfer is ahead by 7 to 24 points; the means are 0.8287 and 0.6707.
  • Both models reach 100% training accuracy on the 24 images (checked per seed). The scratch model's problem is overfitting.
  • Transfer only learns a 514-parameter linear readout over features that already encode orientation.
  • The seed spread is large: 0.76 to 0.95 within one condition. Small-data results are noisy, hence 3 seeds and means (S12).

What pretraining did to the first layer: stronger line and edge detectors

Seed 0's trunk: the first convolution's 8 kernels of \(3\times3\), before and after 8 epochs on task A (one kernel per column, one color scale; white is 0). Orange: positive weights; blue: negative.

before pretraining
(random)
after pretraining
on task A
  • Each kernel keeps its rough pattern, and its weights grow (deeper orange and blue).
  • The patterns that grow are rows, columns or diagonals of one sign against the other sign, such as kernel 2's blue row between orange rows: detectors for lines and edges at one orientation, the parts task B's crosses are made of.

The frozen trunk builds its 256 features from these detectors; the head's 514 parameters only learn to read them.

When transfer fails: the same task in a shifted domain

Reading 2.3 inverts the contrast of task B (X = -X: dark shapes on bright noise). Shapes, labels and difficulty stay the same.

matched

shifted


matched domain             frozen transfer = 0.8287, scratch = 0.6707
shifted domain (inverted)  frozen transfer = 0.6347, scratch = 0.6627
    
  • The 16-point advantage is gone: the kernels were learned on bright bars, so the inverted responses are harder to read, and 24 examples are not enough for the head.
  • Scratch barely moves (0.6707 against 0.6627). At scale, Transfusion (Raghu et al., NeurIPS 2019) found little benefit from ImageNet weights on the medical tasks studied.

Transfer helps most when your images resemble the source task's and labels are scarce. In an unusual domain, measure the scratch baseline.

Data augmentation: a transform you assert leaves the label unchanged

\[(x,y)\;\mapsto\;(a(x),\,y),\qquad a\sim\mathcal A\]
  • \(\mathcal A\) is a set of transforms; a fresh \(a\) is drawn each time an example is used. Training on \((a(x),y)\) says these variations do not matter.
  • Convolution builds in translation structure (S09); augmentation can also teach invariances no layer builds in, such as lighting.

Training on \((a(x),y)\) asserts that \(a\) does not change the class.

The usual augmentation families

Each family asserts a different invariance; pick only those that hold for your labels.

Geometricshifts, crops (random-resized crops are the ImageNet staple), horizontal flips, small rotations, scaling
Photometricbrightness, contrast, saturation and hue jitter; grayscale; blur; noise
Occlusion, mixingrandom erasing (cutout); mixup blends two images and their labels; CutMix pastes a labeled patch
Learned policiesAutoAugment, RandAugment: tune the type and strength

Strength matters as much as type: a transform that changes the class is label noise.

A task where a common augmentation is false

Reading 2.3: class 0 is an "L", class 1 its mirror image, at random positions in \(16\times16\) noise; 32 training examples.

class 0: "L"

class 1: mirrored "L"

class 0, shifted:
still an "L", label 0 correct

class 0, flipped:
a mirrored "L", label 0 wrong

Noise-free rows 2–13 of the \(16\times16\) images, drawn with the reading's draw_L, torch.roll and torch.flip.

A shift keeps the class. A horizontal flip turns one class into the other and keeps the old label.

The two augmentations as tensor operations

X has shape \((32,1,16,16)\), that is \((N,C,H,W)\); rng is a NumPy random generator.


def shift_aug(X, rng):
    """Random translation up to 3 px: label-PRESERVING for this task."""
    out = torch.empty_like(X)
    for i in range(len(X)):
        dr, dc = rng.integers(-3, 4, size=2)
        out[i] = torch.roll(X[i], shifts=(int(dr), int(dc)), dims=(1, 2))
    return out
def flip_aug(X, rng):
    """Random horizontal flip: label-DESTROYING for this task."""
    out = X.clone()
    mask = torch.from_numpy(rng.random(len(X)) < 0.5)
    out[mask] = torch.flip(out[mask], dims=[3])
    return out
  
  • X[i] is one image, \((1,16,16)\); dims=(1, 2) rolls its rows and columns, wrapping around the edge.
  • mask: booleans, shape (32,). out[mask] is about half the images, \((k,1,16,16)\); dims=[3] reverses their width axis.

Shifts add 25 points; flips fall to the majority-class rate

Reading 2.3 trains a CNN with the layers of make_trunk plus a 2-way head, 400 Adam steps on the 32 images, with a fresh augmentation at every step, and tests on 500 images; 3 seeds.


no augmentation                       test acc = 0.7147 (seeds: 0.706, 0.634, 0.804)
random shifts (label-preserving)      test acc = 0.9633 (seeds: 1.000, 0.896, 0.994)
horizontal flips (label-destroying)   test acc = 0.5173 (seeds: 0.510, 0.462, 0.580)
  
  • Shifts: 0.7147 → 0.9633. With 32 examples, a true invariance added 25 points.
  • Flips: 0.5173, against majority-class rates (always predicting the more common test class) of 0.514, 0.510 and 0.508 in the three test sets (checked): a coin flip. About half of each augmented batch carries the wrong label.

Same code, same effort. The difference is whether the invariance you asserted holds for this task.

Flips are not a universal default

Real datasets carry chirality (a shape and its mirror image are different classes) and orientation semantics more often than expected.

  • "b" flips to "d"; "6" rotates to "9"; anatomical sidedness; text in photos.
  • A copy-pasted flip silently caps accuracy: the model trains without errors and converges, just worse than it should.
  • Audit the augmentation list against the label semantics before trusting any other diagnosis.
  • When in doubt, ablate: train once with and once without the suspect transform, as the experiment did.

An augmentation is valid only if it preserves the label for your task.

Self-check: plan a small-data road-sign classifier

You have 40 labeled images per class of 3 road signs, including "turn left" and "turn right", and an ImageNet-pretrained network whose trunk outputs features of shape \((N,512)\).

(a) Freeze or fine-tune first? How many parameters does the new head train?

(b) Which of these augmentations is unsafe here: small rotations, brightness jitter, horizontal flips? Why?

(a) Freeze first: with 40 images per class, a linear probe is the default. nn.Linear(512, 3) trains \(512\cdot3+3=1{,}539\) parameters.

(b) Horizontal flips: they turn a "turn left" sign into a "turn right" sign and keep the old label. Small rotations and brightness jitter keep the class.

For next time

  • Read: 2.4 The Training Recipe and 2.5 Experiments and Reproducibility
  • S12: training in practice: debugging, overfitting diagnosis, experiment tracking; it opens with transfer in practice: probe or fine-tune measured, BatchNorm's two traps in a frozen backbone, and the full transfer recipe
  • HW2 (CNN + transfer learning) goes out at S12, due at S18; it has you rebuild today's transfer experiment
  • Project: read the guide this week; pre-proposal due the end of the S13 week