← Retour à l'épreuve

USA USAAIO 2026 · Correction IOAI Training

ResNet-50 Parameters and a Frozen Backbone

Cette page donne toute la méthode. Lisez-la une fois que vous avez cherché.

Métrique
written answers + code (25 points)
Évaluée sur
written answers
Notre score
25 557 032 params; all parts checked
Niveau attendu
correct counts, shape and frozen 5-class model

Niveau attendu atteint

Nous avons résolu l'épreuve nous-mêmes : téléchargé les données, mené les expériences et mesuré le score ci-dessous. L'énoncé et les données appartiennent à leurs organisateurs. Cette correction est rédigée en anglais.

The problem in brief

Problem 8 of the 2026 USA AI Olympiad Round 1 asks you to get your hands on a real, large network: torchvision's ImageNet-pretrained ResNet-50. You must count its learnable parameters, give the shape of an intermediate output, count the convolution parameters of one residual block by hand, and finally reuse the network, cut after block 4 of stage 3, as a frozen feature extractor for a new 5-class classification task. Everything is checked in solution.py. The counts only depend on the architecture, so we build the model without downloading weights; the pretrained model gives the same numbers.

How you are scored

5 + 5 + 5 + 10 = 25 points. Reasoning is required for 8.3, and in 8.3 you may not call numel() on parameters. No official answers are published with the problem PDF.

partanswer
8.125 557 032 learnable parameters
8.2(B, 512, 28, 28)
8.31 114 112 convolution parameters in layer3[2]
8.4the backbone up to layer3[4], frozen, then global average pooling and Linear(1024, 5)

Question by question

8.1 Total number of learnable parameters

total = sum(p.numel() for p in model.parameters() if p.requires_grad)   # 25557032

Where they are: the stem convolution has 9 408, stage 1 has 0.22 M, stage 2 has 1.22 M, stage 3 has 7.10 M, stage 4 has 14.96 M, and the final fully connected layer has 2048×1000+1000=2 049 0002048 \times 1000 + 1000 = 2\,049\,000. Two things to notice. Most of the weights sit in the deep stages, where there are many channels, even though they work on tiny 14×1414\times14 or 7×77\times7 grids. And the BatchNorm γ,β\gamma, \beta (53 120 values) are learnable, while the BatchNorm running means and variances are buffers, not parameters, and are not counted.

8.2 Shape after layer2

Follow the spatial size: the stem convolution has stride 2 (224→112224 \to 112), max-pooling has stride 2 (→56\to 56), layer1 keeps 56, and the first block of layer2 has stride 2 (→28\to 28). For channels, every bottleneck block outputs 4×4\times its inner width: layer1 has inner width 64 and outputs 256, layer2 has inner width 128 and outputs 512. So the output is (B,512,28,28)(B, 512, 28, 28), which a forward pass confirms.

8.3 Convolution parameters of layer3[2], by hand

layer3[2] is a bottleneck block that is not the first of its stage, so it has no downsampling shortcut: input and output both have 1024 channels and the shortcut is the identity. Its three convolutions have no bias (a BatchNorm follows each of them, and its β\beta would make a bias redundant). A convolution with CinC_\text{in} inputs, CoutC_\text{out} outputs and a k×kk\times k kernel has Cout Cin k2C_\text{out}\,C_\text{in}\,k^2 weights:

conv1 (1×1, 1024→256):256⋅1024⋅1=262 144conv2 (3×3, 256→256):256⋅256⋅9=589 824conv3 (1×1, 256→1024):1024⋅256⋅1=262 144\begin{aligned} \texttt{conv1}\ (1\times1,\ 1024 \to 256) &: 256 \cdot 1024 \cdot 1 = 262\,144\\ \texttt{conv2}\ (3\times3,\ 256 \to 256) &: 256 \cdot 256 \cdot 9 = 589\,824\\ \texttt{conv3}\ (1\times1,\ 256 \to 1024) &: 1024 \cdot 256 \cdot 1 = 262\,144 \end{aligned}

Total: 262 144+589 824+262 144=1 114 112262\,144 + 589\,824 + 262\,144 = \mathbf{1\,114\,112}. In the code we read the shapes from the module attributes (in_channels, out_channels, kernel_size, bias), not from numel(), and the cross-check with numel() agrees.

This is the point of the "bottleneck" design: the 1×11\times1 convolutions shrink 1024 channels to 256 before the expensive 3×33\times3 convolution. A plain 3×33\times3 convolution from 1024 to 1024 channels alone would cost 10242⋅9≈9.41024^2 \cdot 9 \approx 9.4 M parameters, 8.5 times more.

8.4 A frozen truncated backbone for 5 classes

"Up to the output of layer3[4]" means: stem, layer1, layer2, and the first five blocks (indices 0 to 4) of layer3, which has six. That output has 1024 channels on a 14×1414\times14 grid for a 224-pixel input. Then pool and add a new linear head:

class FrozenBackboneClassifier(nn.Module):
    def __init__(self, base, num_classes=5):
        super().__init__()
        self.backbone = nn.Sequential(base.conv1, base.bn1, base.relu, base.maxpool,
                                      base.layer1, base.layer2, base.layer3[:5])
        for p in self.backbone.parameters():
            p.requires_grad = False                     # frozen
        self.pool = nn.AdaptiveAvgPool2d(1)
        self.fc = nn.Linear(1024, num_classes)          # the only trainable part
    def train(self, mode=True):                         # keep BatchNorm statistics frozen too
        super().train(mode); self.backbone.eval(); return self
    def forward(self, x):
        return self.fc(torch.flatten(self.pool(self.backbone(x)), 1))

Built on resnet50(weights="IMAGENET1K_V1") (or pretrained=True in older torchvision), it outputs (B,5)(B, 5). Only 1024⋅5+5=5 1251024 \cdot 5 + 5 = 5\,125 parameters are trainable, and 7 426 112 are frozen. Two details separate a good answer from a merely working one:

  • requires_grad = False is not the whole story. BatchNorm layers update their running mean and variance whenever the module is in training mode, even when their weights are frozen. Overriding train() so that the backbone always stays in eval() mode keeps the pretrained statistics intact.
  • nn.AdaptiveAvgPool2d(1) makes the head independent of the input resolution.

Pass only the trainable parameters to the optimiser, e.g. torch.optim.Adam(clf.fc.parameters()).

What to remember

  • Count parameters with sum(p.numel() for p in model.parameters()); BatchNorm running statistics are buffers, not parameters.
  • A convolution has Cout⋅Cin⋅khkwC_\text{out} \cdot C_\text{in} \cdot k_h k_w weights (plus CoutC_\text{out} biases if any). ResNet convolutions have no bias because BatchNorm follows them.
  • Spatial size halves at each stride-2 stage: 224→112→56→56→28→14→7224 \to 112 \to 56 \to 56 \to 28 \to 14 \to 7.
  • Bottleneck blocks (1×11\times1, 3×33\times3, 1×11\times1) make deep networks affordable.
  • Freezing a backbone means requires_grad = False and keeping its BatchNorm layers in eval mode.
Code complet (solution.py)
"""USAAIO 2026 Round 1, Problem 8: ResNet-50 parameter counts, shapes and a frozen backbone.

Parameter counts and shapes depend only on the architecture, so we build resnet50 without
downloading weights (pass weights='IMAGENET1K_V1' to get the pretrained ones; the counts are
identical). Part 8.3 is answered by hand (see correction) and checked here.
"""
import torch, torch.nn as nn
from torchvision.models import resnet50

model = resnet50(weights=None)

# 8.1 total learnable parameters
total = sum(p.numel() for p in model.parameters() if p.requires_grad)
print('8.1 total learnable parameters:', total)

# 8.2 output shape of layer2 for a (B,3,224,224) input
B = 2
x = torch.zeros(B, 3, 224, 224)
with torch.no_grad():
    h = model.maxpool(model.relu(model.bn1(model.conv1(x))))
    h1 = model.layer1(h); h2 = model.layer2(h1)
print('8.2 layer2 output shape:', tuple(h2.shape))

# 8.3 conv parameters in layer3[2], by hand from kernel shapes (no numel on Parameters)
blk = model.layer3[2]
by_hand = 0
for name, m in blk.named_modules():
    if isinstance(m, nn.Conv2d):
        k = m.out_channels * (m.in_channels // m.groups) * m.kernel_size[0] * m.kernel_size[1]
        k += m.out_channels if m.bias is not None else 0
        print(f'    {name}: {m.in_channels}->{m.out_channels}, kernel {m.kernel_size}, bias {m.bias is not None}: {k}')
        by_hand += k
print('8.3 conv parameters in layer3[2]:', by_hand,
      '(check with numel:', sum(p.numel() for m in blk.modules() if isinstance(m, nn.Conv2d) for p in m.parameters()), ')')


# 8.4 truncated frozen backbone (up to layer3[4]) + trainable head for 5 classes
class FrozenBackboneClassifier(nn.Module):
    def __init__(self, base, num_classes=5):
        super().__init__()
        self.backbone = nn.Sequential(base.conv1, base.bn1, base.relu, base.maxpool,
                                      base.layer1, base.layer2, base.layer3[:5])   # blocks 0..4
        for p in self.backbone.parameters():
            p.requires_grad = False
        self.pool = nn.AdaptiveAvgPool2d(1)
        self.fc = nn.Linear(1024, num_classes)          # layer3 outputs 1024 channels

    def train(self, mode=True):                         # keep frozen BatchNorm statistics fixed
        super().train(mode); self.backbone.eval(); return self

    def forward(self, x):
        return self.fc(torch.flatten(self.pool(self.backbone(x)), 1))


clf = FrozenBackboneClassifier(model)
out = clf(torch.zeros(B, 3, 224, 224))
trainable = sum(p.numel() for p in clf.parameters() if p.requires_grad)
frozen = sum(p.numel() for p in clf.parameters() if not p.requires_grad)
print('8.4 output shape', tuple(out.shape), '| trainable', trainable, '| frozen', frozen)
clf.train()
print('    backbone BN in eval mode after .train():', not clf.backbone[1].training)