Session S10

CNNs II: ResNet and Modern Architecture Design

Why deep plain networks stopped training, and what one added line changed.

Deep Learning · CS 4715/5715 · University of Idaho

S09's small CNN had two convolution layers. Why not fifty?

S09 ended with a small CNN inside the S08 training loop: two nn.Conv2d layers, each followed by ReLU and a \(2\times2\) max pool, then flatten and one nn.Linear.

  • 1998 to 2015: from 5-layer networks reading digits to 152-layer networks that beat a reported human baseline on ImageNet classification.
  • For most of that period, adding layers made a network harder to train. Each generation removed specific obstacles.
History
LeNet, AlexNet, VGG: three obstacles; depth and size are separate
Why depth failed
backpropagation multiplies one factor per layer; we measure it; then degradation
The fix
\(Y=F(X)+X\): the residual block, its gradient, a controlled experiment
What it teaches
symptoms to recognize, defaults for your work, a self-check

Today's question: why was depth hard, and what fixed it?

LeNet-5 (1998) already had the modern template

LeCun, Bottou, Bengio and Haffner, after a decade of work at Bell Labs. The 1998 paper describes commercial deployment for reading checks.

  • tanh-family activations; subsampling is \(2\times2\) averaging (average pooling); about 60,000 parameters.
  • Three obstacles to scaling it up: compute (the CPUs of the era), data (MNIST: 60,000 labeled training images), trainability (deep stacks of saturating activations did not train).
  • For about a decade, engineered features with support vector machines (SVMs) often won the benchmarks of the time.

The template was right in 1998. The history that follows removes these three obstacles.

LeNet-5's layers as tensors: \(32\to5\) pixels, \(1\to16\) channels

Each block is one convolution's output after pooling, labeled \(C\times H\times W\): height grows with \(\sqrt{H}\), width with \(\log_2 C\), on the same scale as the AlexNet and VGG-16 drawings. Bars are vectors.

input 1×32×32 conv 5×5 + avg pool 6×14×14 conv 5×5 + avg pool 16×5×5 FC 120 FC 84 FC 10 flatten 400
  • Two \(5\times5\) convolutions without padding, each followed by \(2\times2\) average pooling: \(32\to14\to5\) pixels, \(1\to6\to16\) channels.
  • Flatten gives \(16\cdot5\cdot5=400\) numbers; three fully connected layers map them to 120, 84, then 10 class scores.

The next page's code builds these layers and prints each shape.

LeNet-5 in today's PyTorch: shapes and 61,706 parameters

LeNet-5's layer sizes in PyTorch; each printed line is the shape after one layer.


import torch
import torch.nn as nn
lenet = nn.Sequential(
    nn.Conv2d(1, 6, kernel_size=5), nn.Tanh(), nn.AvgPool2d(2),
    nn.Conv2d(6, 16, kernel_size=5), nn.Tanh(), nn.AvgPool2d(2),
    nn.Flatten(), nn.Linear(400, 120), nn.Tanh(),
    nn.Linear(120, 84), nn.Tanh(), nn.Linear(84, 10))
h = torch.randn(1, 1, 32, 32)            # one 32x32 grayscale digit
for layer in lenet:
    h = layer(h)
    if not isinstance(layer, nn.Tanh):   # skip the activations
        print(type(layer).__name__, tuple(h.shape))
print(sum(p.numel() for p in lenet.parameters()))
    

Conv2d (1, 6, 28, 28)
AvgPool2d (1, 6, 14, 14)
Conv2d (1, 16, 10, 10)
AvgPool2d (1, 16, 5, 5)
Flatten (1, 400)
Linear (1, 120)
Linear (1, 84)
Linear (1, 10)
61706
    
  • No padding, stride 1: \(32-5+1=28\). nn.Flatten(): \(16\cdot5\cdot5=400\) inputs.
  • nn.AvgPool2d(2): the mean of each \(2\times2\) window (S09 took the maximum).

The 1998 network had 60,000: its second convolution was only partly connected.

AlexNet (2012): data and compute arrived together

Two of LeNet's three obstacles went away at once.

  • Data: ImageNet (2009); its yearly challenge, ILSVRC, trains on about 1.2 million labeled images in 1,000 classes. Compute: GPUs, several times a CPU's peak arithmetic, and far faster on the large matrix products of training.
  • Krizhevsky, Sutskever and Hinton (NeurIPS 2012): a scaled LeNet, 5 convolutional + 3 fully connected layers, about 60M parameters, about a week on two GTX 580 GPUs.
  • Choices that became standard: ReLU instead of saturating tanh or sigmoid, dropout in the classifier, data augmentation (S11).
  • ILSVRC-2012 top-5 error (the true class is not among the model's five highest scores): 15.3%, against 26.2% for the runner-up built on engineered features.

Within about two years essentially every competitive entry was a deep CNN. 2012 is the usual start date of the deep learning era.

AlexNet's layers as tensors: a scaled LeNet

The same drawing and scale as LeNet-5's; each block is one convolution's output, pooled where the network pools.

input 3×227×227 conv 11×11 stride 4, pool 96×27×27 conv 5×5 + max pool 256×13×13 conv 3×3 384×13×13 conv 3×3 384×13×13 conv 3×3 + max pool 256×6×6 FC 4,096 FC 4,096 FC 1,000 flatten 9,216
  • First convolution: \(11\times11\) kernels with stride 4, \(\lfloor(227-11)/4\rfloor+1=55\), then a \(3\times3\) max pool with stride 2 (overlapping windows), \(\lfloor(55-3)/2\rfloor+1=27\). The paper states a \(224\times224\) input; the arithmetic needs 227.

A scaled LeNet: a PyTorch rebuild of the paper's layers has 60,965,224 parameters, 58.6M of them in the three fully connected layers.

VGG (2014): small kernels, deeper stacks, and a ceiling

With data and compute in place, the next thing to scale was depth.

  • Simonyan and Zisserman (ICLR 2015): only \(3\times3\) kernels, double the channels after each \(2\times2\) max pool up to 512, 16–19 weight layers.
  • Two stacked \(3\times3\) layers see a \(5\times5\) receptive field (S09) with fewer weights and one more ReLU. With \(C\) channels in and out, biases omitted:
weights\(C=64\)
two \(3\times3\) layers\(2\cdot9C^2=18C^2\)73,728
one \(5\times5\) layer\(25C^2\)102,400
  • But stacking still more plain layers (each layer feeds only the next one) stopped helping: the paper's error stopped improving at 19 layers.

The third obstacle, trainability, remained.

VGG-16's layers as tensors: five stages of \(3\times3\) convolutions

The same drawing and scale; each block is one stage's output after its \(2\times2\) max pool.

input 3×224×224 2 convs 3×3 + max pool 64×112×112 2 convs 3×3 + max pool 128×56×56 3 convs 3×3 + max pool 256×28×28 3 convs 3×3 + max pool 512×14×14 3 convs 3×3 + max pool 512×7×7 FC 4,096 FC 4,096 FC 1,000 flatten 25,088
  • Each stage: 2 or 3 convolutions with padding 1 (size kept), then a max pool (size halved): \(224/2^5=7\). 13 convolutions + 3 fully connected = 16 weight layers.

LeNet's template again: \(H\) and \(W\) shrink, the channels grow, and the head flattens the last map into fully connected layers.

The ImageNet challenge, 2010–2017, records this history

ILSVRC (ImageNet Large Scale Visual Recognition Challenge) ran once a year. Top-5 error: the share of test images whose true class is not among the model's five highest-scoring classes.

2010–2011engineered features win
2012AlexNet: 15.3%, runner-up 26.2%
2013–2014Clarifai; then VGG and GoogLeNet
2015ResNet, 152 layers: 3.57% (an ensemble)
2017last year; top-5 error below 3%
  • The 152-layer result beat a reported human top-5 estimate: an informal single-annotator baseline, not a claim that machines see better than people.
  • The winning networks outlived the contest: they became the standard pretrained backbones of S11.

Depth went from 8 layers (2012) to 152 (2015). Did the parameter count grow with it?

Depth and parameter count are separate choices

Reading 2.2's table: the papers' reported sizes; "~" marks a widely reported count rather than a number printed in the paper.

networkvenue, yearweight layersparameterssignature idea
LeNet-5Proc. IEEE 19985~60 Kconv + subsample template
AlexNetNeurIPS 2012860 MReLU, dropout, GPU training
VGG-16ICLR 201516138 Muniform \(3\times3\) stacks
GoogLeNetCVPR 201522~5 MInception modules; no big fully connected head
ResNet-152CVPR 2016152~60 Mresidual connections
DenseNet-121CVPR 2017121~8 Mevery layer sees all earlier features
EfficientNet-B0ICML 2019n/a5.3 Mdepth, width, resolution scaled jointly

VGG-16, at 16 layers, is more than twice the size of 152-layer ResNet-152: depth does not set the size. Where the parameters sit opens S11.

Backpropagation through a stack multiplies one factor per layer

Back to the third obstacle, trainability: why does a deep plain stack not train? Take \(K\) Linear + sigmoid layers, numbered \(i=1,\dots,K\) from the input, on a batch \(X\) of shape \((N,32)\); the loss is computed from \(A_K\). \(\sigma'\) is the derivative of the sigmoid, \(\sigma'(z)=\sigma(z)(1-\sigma(z))\): the \(p(1-p)\) of S03.

\[A_0=X,\qquad Z_i=A_{i-1}W_i+b_i,\qquad A_i=\sigma(Z_i),\qquad W_i\in\mathbb R^{32\times32}\]

The backward rule from S05, one layer at a time:

\[dZ_i=dA_i\odot\sigma'(Z_i),\qquad dA_{i-1}=dZ_i\,W_i^\top\]
  • \(\odot\) keeps the shape \((N,32)\); \(dZ_i\,W_i^\top\) is \((N,32)(32,32)\to(N,32)\).
\(dA_K\)→ layer \(K\): \(\odot\,\sigma'(Z_K)\), then \(\cdot\,W_K^\top\)→ \(dA_{K-1}\)→⋯→ layer 1: \(\odot\,\sigma'(Z_1)\)→ \(dW_1=X^\top dZ_1\)

Layer \(i\)'s factor has two parts: \(\odot\,\sigma'(Z_i)\), then \(\cdot\,W_i^\top\). The first layer's gradient is a product of every factor above it; if the factors shrink the gradient, the product shrinks geometrically with depth.

Deriving: the sigmoid factor is at most 1/4

S02's derivative table showed the maximum of \(\sigma'\), 0.25; here is why.

  1. Write \(s=\sigma(z)\), with \(0<s<1\). Then \(\sigma'(z)=s(1-s)=s-s^2\).
  2. Complete the square: \(s-s^2=\tfrac14-\left(s-\tfrac12\right)^2\).
  3. A square is never negative, so \(\sigma'(z)\le\tfrac14\), with equality only at \(s=\tfrac12\), that is, \(z=0\).

Result: every entry of \(\sigma'(Z_i)\) is at most 1/4, so row by row (lowercase \(da\) is one row of \(dA\)): \(\lVert da_{i-1}\rVert\le\tfrac14\,\lVert W_i\rVert\,\lVert da_i\rVert\) (\(\lVert W_i\rVert\): the most \(W_i^\top\) can stretch a vector, never more than the square root of the sum of its squared entries) (proved on the next page).

Each sigmoid layer can pass on at most a quarter of the gradient norm, times its weights' stretch.

Deriving: how much can \(W_i^\top\) stretch a vector?

Write \(W\) for \(W_i\). For row vectors \(v\), \(\lVert W\rVert=\max_{v\ne0}\lVert vW^\top\rVert/\lVert v\rVert\): the most \(W^\top\) can stretch a vector.

  1. The \(j\)-th entry of \(vW^\top\) is \((vW^\top)_j=\sum_kv_kW_{jk}\).
  2. Cauchy–Schwarz, \(\lvert a\cdot b\rvert\le\lVert a\rVert\,\lVert b\rVert\), with \(a=v\) and \(b\) the \(j\)-th row of \(W\): \(\lvert(vW^\top)_j\rvert\le\lVert v\rVert\sqrt{\sum_kW_{jk}^2}\).
  3. Square and sum over \(j\): \(\lVert vW^\top\rVert^2\le\lVert v\rVert^2\sum_{j,k}W_{jk}^2\), so \(\lVert W\rVert\le\sqrt{\sum_{j,k}W_{jk}^2}\).

Self-check: for \(W=cI\) (\(32\times32\)), \(\lVert W\rVert=\lvert c\rvert\), while \(\sqrt{\sum_{j,k}W_{jk}^2}=\lvert c\rvert\sqrt{32}\): the bound holds but is not tight.

The previous page used this bound: it can be loose, but it is never violated.

Away from \(z=0\) the sigmoid factor is far below 1/4

The \(\tfrac14\) bound is the best case. Compute \(\sigma'(z)\) at five inputs (the dots), and compare ReLU's derivative.

0 0.25 0.5 1 -6 -4 -2 0 2 4 6 z ReLU′ = 1 for z > 0 sigmoid′: at most 0.25

z = torch.tensor([0.0, 1.0, 2.0, 3.0, 5.0])
s = torch.sigmoid(z)
print(s * (1 - s))        # sigma'(z) = s (1 - s)
      
tensor([0.2500, 0.1966, 0.1050, 0.0452, 0.0066])

Hochreiter (1991) and Bengio, Simard and Frasconi (1994) analyzed this failure in recurrent networks; deep feedforward stacks inherit it.

Self-check. With \(\lVert W_i\rVert\le1\), what fraction of the gradient norm can survive 10 sigmoid layers at best? If every unit sits at \(z=2\) (\(\sigma'=0.1050\) in the printout), what fraction is left?

At best \((1/4)^{10}\approx9.5\times10^{-7}\); at \(z=2\), at most \(0.1050^{10}\approx1.6\times10^{-10}\): the typical case is far below the bound.

Measure it: a stack of Linear + sigmoid layers

Build the stack at several depths (torch, nn as before); the loss is mean squared error against random targets, since only the gradient's size matters.


torch.manual_seed(0)
def make_plain_net(depth, width=32):
    """A deep stack of Linear + Sigmoid layers (early-1990s style)."""
    layers = []
    for _ in range(depth):
        layers += [nn.Linear(width, width), nn.Sigmoid()]
    layers += [nn.Linear(width, 1)]
    return nn.Sequential(*layers)
x = torch.randn(64, 32)    # N = 64 examples, 32 features
y = torch.randn(64, 1)     # random regression targets
  
  • net[0]: the first nn.Linear(32, 32), weight \((32,32)\). net[-1]: the output layer, weight \((1,32)\).
  • .weight.grad.norm() returns one number per weight matrix, \(\sqrt{\sum_{j,k}dW_{jk}^2}\), computed in float32.

The first layer's gradient collapses as depth grows

One backward pass per depth, same seed each time (make_plain_net, x, y from the previous page).


for depth in [2, 6, 12, 24, 48]:
    torch.manual_seed(0)
    net = make_plain_net(depth)
    loss = nn.functional.mse_loss(net(x), y)
    loss.backward()
    g_first = net[0].weight.grad.norm().item()   # first nn.Linear
    g_last = net[-1].weight.grad.norm().item()   # output nn.Linear
    print(f"depth {depth:2d}: ||grad|| at output layer = {g_last:.2e}, "
          f"at first layer = {g_first:.2e}, ratio = {g_first / g_last:.1e}")
  

depth  2: ||grad|| at output layer = 4.18e-01, at first layer = 1.68e-02, ratio = 4.0e-02
depth  6: ||grad|| at output layer = 1.48e+00, at first layer = 4.70e-06, ratio = 3.2e-06
depth 12: ||grad|| at output layer = 8.26e-01, at first layer = 2.05e-11, ratio = 2.5e-11
depth 24: ||grad|| at output layer = 7.76e-02, at first layer = 5.30e-21, ratio = 6.8e-20
depth 48: ||grad|| at output layer = 1.97e+00, at first layer = 0.00e+00, ratio = 0.0e+00
  

The output layer's gradient stays between 0.078 and 1.97 at every depth. The first layer's falls from \(1.7\times10^{-2}\) to a norm that prints 0.

Layer by layer, each weight gradient is 10% to 16% of the one above it

Depth 12 from the table, now every weight gradient (make_plain_net, x, y as defined for the table).


torch.manual_seed(0)
net = make_plain_net(12)
nn.functional.mse_loss(net(x), y).backward()
norms = [m.weight.grad.norm().item()
         for m in net if isinstance(m, nn.Linear)]
print(len(norms), f"{norms[0]:.2e}", f"{norms[-1]:.2e}")
ratios = [a / b for a, b in zip(norms, norms[1:])]
print(f"{min(ratios):.3f} {max(ratios):.3f}")
      

13 2.05e-11 8.26e-01
0.104 0.156
      
1 10-4 10-8 10-12 1 2 3 4 5 6 7 8 9 10 11 12 13 nn.Linear layer: 1 = first, 13 = output

log scale; orange: the output layer

isinstance(m, nn.Linear) skips the nn.Sigmoid modules; zip(norms, norms[1:]) pairs each layer with the one above it.

A nearly constant factor per layer makes a straight descent on a log scale: printing per-layer gradient norms is how you diagnose a vanishing gradient.

Reading the table: geometric decay, and a norm that prints 0

Geometric decay: as depth doubles from 6 to 12 to 24, the table's ratio goes \(10^{-5.5}\to10^{-10.6}\to10^{-19.2}\): the exponent roughly doubles too. At depth 48 the first-layer norm prints 0; look at the gradient itself.


torch.manual_seed(0)
net = make_plain_net(48)                      # the depth-48 row again
nn.functional.mse_loss(net(x), y).backward()
g = net[0].weight.grad                        # float32, shape (32, 32)
print((g != 0).sum().item(), f"{g.abs().max().item():.2e}")
print(f"{g.norm().item():.2e} {g.double().norm().item():.2e}")
    

1020 2.09e-42
0.00e+00 1.44e-41
    
  • The entries are not 0 (1,020 of 1,024): .norm() squares them, and \((2\times10^{-42})^2\) is below \(1.4\times10^{-45}\), the smallest positive float32.
  • Not training: \(\eta\,dW_1\) is below float32's spacing near 0.1 (about \(7\times10^{-9}\)), so \(W_1-\eta\,dW_1=W_1\) even at \(\eta=10^6\).

This is the vanishing gradient: early layers get almost no gradient and do not change.

Partial fix: ReLU layers with Kaiming/He initialization

2010–2015 brought partial fixes. AlexNet's ReLU has derivative 1 where active. S07 chose the weight variance \(2/D\) for a ReLU layer with \(D\) inputs (Kaiming/He, 2015). Rebuild the stack with ReLU, with and without that initialization.


def make_relu_net(depth, he, width=32):
    layers = []
    for _ in range(depth):
        lin = nn.Linear(width, width)
        if he:
            nn.init.kaiming_normal_(lin.weight, nonlinearity="relu")   # variance 2/D
            nn.init.zeros_(lin.bias)
        layers += [lin, nn.ReLU()]
    return nn.Sequential(*layers, nn.Linear(width, 1))
  
weight entries drawn fromstandard deviation, \(D=32\)
nn.Linear defaultuniform on \([-1/\sqrt{32},\,1/\sqrt{32}]\)\(1/\sqrt{96}\approx0.102\)
kaiming_normal_normal, variance \(2/D\)\(\sqrt{2/32}=0.25\)

nn.init.kaiming_normal_ redraws lin.weight in place (the trailing underscore marks in-place functions); nn.init.zeros_ sets the bias to 0.

ReLU alone still vanishes; ReLU with He initialization does not

The sigmoid table's loop: seed 0, the same x and y (make_relu_net from the previous page).


print("depth   default   He init")
for depth in [2, 6, 12, 24, 48]:
    g = []
    for he in [False, True]:
        torch.manual_seed(0)
        net = make_relu_net(depth, he)
        nn.functional.mse_loss(net(x), y).backward()
        g.append(net[0].weight.grad.norm().item())
    print(f"{depth:5d}  {g[0]:.2e}  {g[1]:.2e}")
    

depth   default   He init
    2  2.21e-01  3.97e-01
    6  2.70e-03  1.32e+00
   12  7.39e-06  4.16e-01
   24  4.11e-11  1.14e+00
   48  7.38e-21  4.94e-02
    
  • Why: ReLU passes about half the units (derivative 1 or 0), and \(W_i^\top\) scales the squared norm by about \(D\,\mathrm{Var}(w)\): per layer \(\tfrac12\cdot32\cdot\tfrac1{96}=\tfrac16\) (default) vs. \(\tfrac12\cdot32\cdot\tfrac2{32}=1\) (He).
  • He initialization and BatchNorm (S07) both date from 2015, after VGG; with them, plain networks of VGG depth (16–19 layers) train from scratch.

The gradient now reaches the first layer, yet deeper plain networks still trained worse.

BatchNorm keeps the signal at scale, even under a bad initialization

The other partial fix, BatchNorm (BN, S07). Reading 2.2: a 30-block Linear + ReLU stack, width 64, every weight matrix multiplied by 0.5, a batch of shape \((256,64)\), loss = mean of the squared output. Printed values, tabulated.

activation standard deviation after blockfirst-layer gradient norm
1102030
without BN0.170.0450.0410.0410
with BN0.580.610.610.6032
  • nn.BatchNorm1d(64) on \((256,64)\): per feature, subtract the mean and divide by the standard deviation over the 256 rows (dim=0), then apply a learned scale \(\gamma\) and shift \(\beta\), 64 values each.
  • Each BN layer divides the 0.5 mis-scaling back out: the first-layer gradient norm is 32 instead of a value that prints 0 (\(1.3\times10^{-22}\) in float64).

BN fixes the scale of the signal. Deep plain networks can still fit their training data worse.

Degradation: a deeper plain network fits its own training data worse

With BatchNorm throughout, He et al. (the ResNet paper) trained on CIFAR-10, 60,000 color images of \(32\times32\) pixels in 10 classes, and found that a 56-layer plain network had higher training error than a 20-layer one.

training errorvalidation errorwhat failedwhat helps
Overfittinglowhighthe model fits noise in the training setmore data, regularization
Degradationhigher for the deeper nethigherthe optimizer does not fit the dataan easier-to-optimize architecture
  • The 56-layer network contains the 20-layer solution: set the 36 extra layers to the identity. Gradient descent does not find it.
  • He et al. call this the degradation problem: a failure of optimization, not of capacity.

The diagnosis needs the training error. Regularization cannot help a network that does not fit its training set.

The miniature's data: 256 points, label 1 when \(x_1x_2>0\)

Reading 2.2 compares plain and residual training fit at toy scale, on a task one block can fit (torch as before).


import numpy as np
def make_data(n, rng):
    X = rng.normal(0.0, 0.5, size=(n, 2)).astype(np.float32)
    y = (X[:, 0] * X[:, 1] > 0).astype(np.int64)
    X += rng.normal(0.0, 0.1, size=X.shape).astype(np.float32)
    return torch.from_numpy(X), torch.from_numpy(y)
X, y = make_data(256, np.random.default_rng(0))
print(X.dtype, y.dtype, y.float().mean().item())
      
torch.float32 torch.int64 0.47265625
x1 x2

blue circles: class 1; orange squares: class 0

  • The label is set before the jitter, so a few points sit on the wrong side of an axis.
  • float32 inputs, int64 labels: what nn.CrossEntropyLoss expects.

Class 1 is 47.3% of this seed's points, so always answering class 0 scores 0.527.

The miniature's model: a stem, \(n\) identical blocks, a two-class head

Reading 2.2's block: two nn.Linear(32, 32) layers; skip switches on one added line.


class Block(nn.Module):
    def __init__(self, width, skip):
        super().__init__()
        self.fc1 = nn.Linear(width, width)
        self.fc2 = nn.Linear(width, width)
        self.relu = nn.ReLU()
        self.skip = skip
    def forward(self, x):
        out = self.fc2(self.relu(self.fc1(x)))
        if self.skip:
            out = out + x
        return self.relu(out)
def make_net(n_blocks, skip, width=32):
    return nn.Sequential(nn.Linear(2, width), nn.ReLU(),
                         *[Block(width, skip) for _ in range(n_blocks)],
                         nn.Linear(width, 2))
    
\(X\): \((256,2)\)
↓
stem: Linear(2, 32), ReLU
\((256,32)\)
↓
\(n\) × Block(32, skip)
\((256,32)\to(256,32)\)
↓
head: Linear(32, 2)
logits \((256,2)\)

*[...] unpacks \(n\) new blocks; plain and residual nets differ only in skip.

Training: same data, seed and budget at every depth

make_data and make_net from the previous two pages. 600 SGD steps on minibatches of 64, then accuracy on all 256 training points.


def train_accuracy(n_blocks, skip, seed, steps=600):
    rng = np.random.default_rng(seed)
    torch.manual_seed(seed)
    X, y = make_data(256, rng)
    net = make_net(n_blocks, skip)
    opt = torch.optim.SGD(net.parameters(), lr=0.05, momentum=0.9)
    loss_fn = nn.CrossEntropyLoss()
    for _ in range(steps):
        idx = torch.randint(0, len(X), (64,))   # 64 random row indices
        opt.zero_grad()
        loss_fn(net(X[idx]), y[idx]).backward()
        opt.step()
    with torch.no_grad():
        return (net(X).argmax(1) == y).float().mean().item()
  
  • X[idx]: a \((64,2)\) minibatch. The loop is S08's: zero, loss, backward, step.
  • argmax(1): the larger logit in each row; .float().mean() of the matches is the fraction correct.
  • One seed fixes the data and the starting weights: plain and residual differ only in skip.

From 4 blocks on, the plain nets predict a single class

Mean training accuracy over seeds 0, 1, 2 (train_accuracy from the previous page).


print("blocks   plain  residual")
for n in [1, 4, 16, 32]:
    row = [np.mean([train_accuracy(n, skip, s) for s in range(3)])
           for skip in [False, True]]
    print(f"{n:6d}  {row[0]:.4f}    {row[1]:.4f}")
    

blocks   plain  residual
     1  0.8815    0.8854
     4  0.5091    0.8841
    16  0.5221    0.8906
    32  0.5221    0.8880
    
  • At 1 block both fit: 0.88, about as good as the label noise allows.
  • From 4 blocks on, each plain net predicts one class for all 256 points (in a separate run of the same recipe, net(X).argmax(1).unique() has one entry), so its accuracy is that class's share: 0.527, 0.520, 0.520 for class 0; at 4 blocks seed 1 chose class 1 (0.480).
  • In that run their training loss stays at 0.692–0.694, about \(\ln2\approx0.693\): a 50/50 guess everywhere. Residual nets reach 0.19–0.29.

More depth, same data and budget, worse training fit; the residual column differs by one line, out = out + x. Degradation, or the vanishing gradient?

Without BN the plain gradient vanishes; with BN it explodes

The miniature uses default initialization and no BatchNorm, the setting where the ReLU stack's gradient vanished. "+ BN": nn.BatchNorm1d(32) after each nn.Linear in every block.

blocksplainplain + BNresidual + BN
1\(7.8\times10^{-3}\)0.360.45
4\(2.7\times10^{-5}\)0.401.2
16\(2.2\times10^{-14}\)552.6
32prints 0\(2.8\times10^{4}\)9.2

Separate runs, tabulated: first-layer gradient norm of the full-batch loss before training, seed 0.

  • With BN the gradient grows toward the input instead: \(2.8\times10^4\) at the first layer of 32 blocks, 0.32 at the output layer.
  • Residual + BN: it stays between 0.45 and 9.2.

BatchNorm turns the plain net's vanishing gradient into an exploding one.

With or without BN, deep plain nets fit worse; residual nets fit

Training accuracy, mean of seeds 0–2, the same recipe; "+ BN" nets evaluated after net.eval().

blocksplainplain + BNresidualresidual + BN
10.88150.89190.88540.8906
40.50910.86590.88410.8815
160.52210.75390.89060.8698
320.52210.54170.88800.8841
  • With BN, plain nets still fall to 0.75 and 0.54 at 16 and 32 blocks (seeds at 16: 0.46 to 0.91).
  • Residual nets fit at every depth, with or without BN: 0.87–0.89.

The one added line, out = out + x, fits at every depth. A deeper plain net that fits worse with a healthy gradient is the paper's finding; this miniature cannot isolate it.

The residual block: learn a correction and add the input back

He, Zhang, Ren and Sun (CVPR 2016): the sum \(Y\) (out in code) is branch plus input; a ReLU follows.

\[Y=F(X)+X\]
\(X\)
\((N,C,H,W)\)
→
conv \(3\times3\)→ReLU→conv \(3\times3\)\(=F(X)\)
→
+
→
ReLU
\((N,C,H,W)\)
  • Shapes: \(F(X)\) and \(X\) must have the same shape. The \(3\times3\) convolutions use padding 1 and keep \(C\) channels.
  • Forward default: an untrained plain block applies a random map to its input. A residual block whose branch outputs near zero passes \(X\) through, a near no-op while the branch stays small and well scaled.

The branch only has to learn the difference \(F(X)=Y-X\).

Deriving: the block's backward pass contains an identity term

Block input \(X\), output \(Y\), upstream gradient \(dY=\partial L/\partial Y\) (S05). The block's partial derivatives \(\partial F_j/\partial X_k\), for every output \(j\) and input \(k\), form a matrix, the Jacobian \(J_F\) (reading 1.4; \(X\) flattened to a vector). One symbol for the whole block lets us add \(I\) and multiply blocks (slide 33). Example: for a Linear layer \(F(X)=XW\), \(J_F=W^\top\), so \(dY\,J_F=dY\,W^\top\), S05's rule.

  1. Plain block \(Y=F(X)\): \(dX=dY\,J_F\).
  2. Residual block \(Y=F(X)+X\): \(\partial Y/\partial X=J_F+I\) (the Jacobian of a sum is the sum of the Jacobians; \(\partial X/\partial X=I\)).
  3. \(dX=dY\,(J_F+I)=dY\,J_F+dY\).

Result: the residual block has one extra term, \(dY\), passed through unchanged.

Check \(\partial Y/\partial X=J_F+I\) with autograd

A small branch on a 3-vector (torch as before): \(F(x)=\tanh(xW)\) with a fixed \(3\times3\) matrix \(W\). torch.autograd.functional.jacobian(f, x) returns the matrix of \(\partial f_i/\partial x_j\).


torch.manual_seed(0)
W = torch.randn(3, 3) * 0.5
f = lambda v: torch.tanh(v @ W)        # the branch F
x = torch.randn(3)
J_F = torch.autograd.functional.jacobian(f, x)
J_Y = torch.autograd.functional.jacobian(lambda v: f(v) + v, x)
print(J_F)
print(J_Y)
print(torch.allclose(J_Y, J_F + torch.eye(3)))
    

tensor([[ 0.6367,  0.2349,  0.1667],
        [-0.1197, -0.4423,  0.3418],
        [-0.6162, -0.3955, -0.2034]])
tensor([[ 1.6367,  0.2349,  0.1667],
        [-0.1197,  0.5577,  0.3418],
        [-0.6162, -0.3955,  0.7966]])
True
    
  • Every diagonal entry of \(J_Y\) is the matching entry of \(J_F\) plus 1; every off-diagonal entry is unchanged.
  • torch.eye(3) is the \(3\times3\) identity \(I\).

The identity term is exact: the skip adds \(I\) to the Jacobian, whatever the branch computes.

Across blocks the product expands into paths; one path skips every weight

Block \(k\) has Jacobian \(J_k+I\). Chain two blocks: \(dX_0=dX_2\,(J_2+I)(J_1+I)\).

\[(J_2+I)(J_1+I)=J_2J_1+J_2+J_1+I\]
term in \(dX_0\)block 2block 1weight factors
\(dX_2\,J_2J_1\)branchbranch2
\(dX_2\,J_2\)branchskip1
\(dX_2\,J_1\)skipbranch1
\(dX_2\)skipskip0

\(n\) blocks: \(2^n\) terms, one per choice of branch or skip at each block. Exactly one, \(dX_n\) itself, has no weight factor.

Veit et al. (NeurIPS 2016) studied these paths; the next three optional pages go through what they found. A path's length is the number of branches it takes (the last column above).

A plain stack has one path, through every weight. A residual stack adds paths of every length.

Unraveling: \(n\) blocks are \(2^n\) paths, and a path's length is the number of branches it takes

Veit et al. substitute \(X_k=X_{k-1}+F_k(X_{k-1})\) block by block (their eqs. 4–6); for \(n=3\):

\[\begin{aligned}X_3&=X_2+F_3(X_2)=X_1+F_2(X_1)+F_3\big(X_1{+}F_2(X_1)\big)\\&=X_0+F_1(X_0)+F_2\big(X_0{+}F_1(X_0)\big)+F_3\big(X_0{+}F_1(X_0){+}F_2(X_0{+}F_1(X_0))\big)\end{aligned}\]

A path is a code \(b\in\{0,1\}^n\): at block \(k\) it takes the branch \(F_k\) (\(b_k=1\)) or the skip (\(b_k=0\)). Its length \(x=b_1+\dots+b_n\) counts its branches; \(\binom nx\) paths have length \(x\), for \(n=3\): 1, 3, 3, 1. A plain stack has one path, of length \(n\).


from math import comb
n = 54     # 54 modules: the 110-layer ResNet of Veit et al.
counts = [comb(n, x) for x in range(n + 1)]   # paths of length x
print(sum(counts), sum(x * c for x, c in enumerate(counts)) / 2**n)
print(f"{sum(counts[19:36]) / 2**n:.3f}")     # lengths 19 to 35
    

18014398509481984 27.0
0.980
    

With 54 blocks, 0.980 of the paths take 19 to 35 branches (Veit et al.: more than 95%).

Deleting one block removes half the paths, mostly the long ones

Delete block \(k\) of a trained network: drop its branch, keep its skip (\(X_k=X_{k-1}\)). A path remains if it takes none of the deleted branches.

After \(d\) of \(n\) blocks are deleted, a fraction \(\binom{n-d}{x}/\binom nx\) of the paths of length \(x\) remain (Veit et al., eq. 8); for \(d=1\), \((n-x)/n\). All lengths together: \(2^{n-1}\) of \(2^n\), half.


from math import comb
n, d = 54, 1                   # delete 1 of 54 blocks
lengths = [0, 5, 17, 27, 35]
print([round(comb(n - d, x) / comb(n, x), 3) for x in lengths])
    

[1.0, 0.907, 0.685, 0.5, 0.352]
    

Veit et al. §4, CIFAR-10: deleting any one block of their trained 110-layer ResNet barely changed its error (a downsampling block raised it a little); deleting any one layer of a 15-layer VGG dropped its accuracy to chance. Deleting more blocks, or shuffling their order, raised the ResNet's error smoothly.

The paths behave like an ensemble, in Veit et al.'s sense: they do not strongly depend on each other.

Only short paths carry gradient: the effective paths

Each branch a path takes adds one factor \(J_k\) to its gradient, as each layer did in the plain stack; if the factors shrink it, a path's gradient falls geometrically with its length. Veit et al. §5 measured it (54 modules, CIFAR-10):

  • Per path (Fig. 6b): 1,000 measurements per length in the trained network; a path's gradient at the input decays roughly exponentially with its length.
  • Per length (Fig. 6c): times the \(\binom{54}{x}\) paths of each length, almost all of the gradient comes from lengths 5 to 17, only 0.45% of the paths.
  • Check: training a random 23 of the 54 blocks per batch (lengths centered on 11.5) gave 5.96% error, the full model 6.10%: no statistically significant difference.

from math import comb
n = 54
print(f"{sum(comb(n, x) for x in range(5, 18)) / 2**n:.4f}")   # lengths 5 to 17
    

0.0045
    

A ResNet does not carry its gradient through the whole depth; its short paths carry it. The long paths still vanish: the identity term does not guarantee a large gradient (next page).

The identity term helps; it does not guarantee a large gradient

Two conditions sit behind "passes the gradient through".

  • The ReLU after the sum. The original ResNet block (and the code ahead) returns \(\operatorname{ReLU}(F(X)+X)\). Its Jacobian is \(D\,(J_F+I)\), where \(D\) is diagonal with 1 where the sum is positive and 0 elsewhere: the identity term passes only where the sum is positive.
  • The branch. The factors \(J_k+I\) still multiply across blocks, and a branch can cancel the identity.

Self-check. Suppose a branch has learned \(J_F=-0.9\,I\). What does one block multiply the gradient by, and what is left after 10 such blocks?

\(J_F+I=0.1\,I\): each block multiplies by 0.1, ten blocks by \(0.1^{10}=10^{-10}\). Across many blocks the product can still shrink, explode, or cancel; the identity term improves the path.

A controlled experiment: the same conv blocks, with and without the skip

Reading 2.2's convolutional block (torch, nn as before). This Block uses nn.Conv2d; it replaces the nn.Linear one of the miniature. skip switches the one added line.


class Block(nn.Module):
    """Two 3x3 convs. With skip=True this is a basic ResNet block."""
    def __init__(self, channels, skip):
        super().__init__()
        self.conv1 = nn.Conv2d(channels, channels, 3, padding=1)
        self.conv2 = nn.Conv2d(channels, channels, 3, padding=1)
        self.relu = nn.ReLU()
        self.skip = skip

    def forward(self, x):                            # x: (4, 8, 8, 8)
        out = self.conv2(self.relu(self.conv1(x)))   # F(x): (4, 8, 8, 8)
        if self.skip:
            out = out + x          # the residual connection
        return self.relu(out)
  

padding=1 keeps \(8\times8\) and channels stays 8, so out + x adds two tensors of shape \((4,8,8,8)\).

Measure the first layer's gradient under two initializations

Stack \(n\) copies of Block from the previous page. init="half" halves every conv weight, so each block shrinks its input (pre-2015 under-scaling); init="he" redraws them with Kaiming/He initialization, as on the ReLU page (biases keep their default here).


def grad_at_first_layer(n_blocks, skip, init):
    torch.manual_seed(0)
    blocks = nn.Sequential(*[Block(8, skip) for _ in range(n_blocks)])
    with torch.no_grad():            # edit weights without recording
        for p in blocks.parameters():
            if p.dim() == 4 and init == "he":   # conv weights, (8, 8, 3, 3)
                nn.init.kaiming_normal_(p, nonlinearity="relu")
            elif p.dim() == 4:                  # init == "half"
                p.mul_(0.5)                     # in place: halve every entry
    blocks(torch.randn(4, 8, 8, 8)).pow(2).mean().backward()   # x: (N, C, H, W)
    return blocks[0].conv1.weight.grad.norm().item()
print("blocks  plain 0.5  resid 0.5   plain He   resid He")
for n in [2, 8, 16, 32]:
    g = [grad_at_first_layer(n, s, init) for init in ["half", "he"]
         for s in [False, True]]
    print(f"{n:6d}" + "".join(f" {v:10.2e}" for v in g))
  

Within each initialization, same seed, weights and input: only out + x differs.

The skip keeps the gradient from vanishing; it does not bound its size

The previous page's printout: the first conv layer's gradient norm.


blocks  plain 0.5  resid 0.5   plain He   resid He
     2   2.44e-05   5.23e-02   1.83e-01   1.00e+00
     8   4.39e-14   5.86e-02   3.54e-01   3.73e+02
    16   0.00e+00   5.42e-02   3.11e-01   1.52e+05
    32   0.00e+00   6.36e-02   5.96e-05   1.79e+08
  
  • Scale 0.5: plain prints 0 from 16 blocks; residual stays between 0.052 and 0.064 from 2 to 32 blocks (reading 2.2's table).
  • He: plain stays between 0.18 and 0.35 up to 16 blocks and falls to \(6.0\times10^{-5}\) at 32. Residual grows from 1.0 to \(1.8\times10^8\): each block adds \(F(X)\) to \(X\), and nothing rescales the sum.

One added line, out + x, kept the path open under both initializations; it does not control the gradient's size. Real ResNet blocks add BatchNorm for that (S11 builds one).

What the history teaches

  • At scale: ResNets trained at 152 layers and won ILSVRC 2015 (3.57% top-5 error, an ensemble). Transformers (Module 3) put a residual connection around every attention and MLP sublayer.
  • LeNet to AlexNet: mostly resources, data and compute. VGG to ResNet: trainability, fixed by an architectural change rather than more resources.
  • Size is a separate choice: VGG-16 has 138M parameters at 16 layers; ResNet-152 about 60M at 152.
  • For any new architecture, ask: what failure mode does this design remove?

Depth became usable once each block could default to passing its input through.

Defaults for your work, and five symptoms to recognize

Defaults: a small ResNet-style network, ReLU-family activations, Kaiming/He initialization, normalization.

symptomcausecheck or fix
early layers never change; the loss barely movesvanishing gradientprint per-layer gradient norms; add skips, fix the initialization
gradient norms grow toward the inputexploding gradientper-layer gradient norms; add skips and normalization
more layers, worse training error; dropout and weight decay do not helpdegradationcompare training error; add residual connections
a gradient norm prints exactly 0.00e+00float32 underflow of the norm's squares, or of the entriesredesign: skips, initialization, normalization; no learning rate helps
a BatchNorm net evaluates badly, or changes with batch sizetraining mode at evaluationmodel.eval() before validation, model.train() before training

If a deeper model trains worse than a shallower one, suspect optimization before capacity.

Self-check: diagnose a deeper model

A 20-block plain CNN reaches 70% training accuracy; an 8-block version reaches 90% training accuracy on the same data. Overfitting or degradation? What do you measure first, and what do you change?

Degradation: the deeper model fits its training data worse (overfitting would show lower training error). Print per-layer gradient norms first, where vanishing or exploding shows, then add residual connections; neither regularization nor BatchNorm alone fixes it.

For next time

  • Read: 2.3 Transfer Learning and Data Augmentation
  • S11: the backbone you transfer (where its parameters sit, ResNet-18 built from its parts), then transfer learning and data augmentation
  • Project guide released at S11 (pre-proposal due the end of the S13 week)
  • Coming up: HW2 (CNN + transfer) goes out at S12