Skip to content

s11 经典CNN架构演进 — exercise.py 练习指南

Download exercise.py

练习目标

通过手写 ResNet 的核心组件,深入理解残差学习机制:

  1. 理解 BasicBlock 的前向传播 —— 残差公式 y=F(x)+x 在代码中如何落地
  2. 理解 BatchNorm 的位置 —— 为什么 Conv BN ReLU 这个顺序是标准做法
  3. 构建完整 ResNet —— 从 BasicBlock 组装出 ResNet-34
  4. 手动估算参数量 —— 不靠 torch.summary,用公式计算不同 ResNet 变体的参数

预备知识

  • 残差连接y=F(x)+x,其中 F 是两层 3×3 卷积
  • BatchNorm:对 mini-batch 内各通道做归一化 xμBσBγ+β
  • ReLUReLU(x)=max(0,x),最常用的 CNN 激活函数
  • Kaiming 初始化:适用于 ReLU 的权重初始化,std=2/fan_out

任务清单

练习 1:实现 BasicBlock 的前向传播(含跳跃连接)

任务:在 BasicBlockExercise.forward() 中实现完整的残差前向传播。

残差块公式

out=ReLU(BN(Conv3x3(BN(Conv3x3(x))))+shortcut(x))

步骤提示

1. identity = self.shortcut(x)                    # 跳跃连接分支(恒等或投影)
2. out = F.relu(self.bn1(self.conv1(x)))          # Conv→BN→ReLU(第一层)
3. out = self.bn2(self.conv2(out))                # Conv→BN(第二层,不加ReLU!)
4. out += identity                                 # 🔑 残差加法 H(x) = F(x) + x
5. out = F.relu(out)                              # 加法后才做 ReLU
6. return out

关键细节:第二步的 ReLU 为什么不放在加法之后?因为两个卷积中间需要一个非线性来增强表达能力。但第二个 BN 之后不能加 ReLU,否则 F(x) 被截断到 0,限制了残差的表达范围。

TODO 提示:还需要补全 __init__ 中 shortcut 的条件构建:

python
if stride != 1 or in_planes != planes:
    self.shortcut = nn.Sequential(
        nn.Conv2d(in_planes, planes, kernel_size=1, stride=stride, bias=False),
        nn.BatchNorm2d(planes),
    )

预期输出

  • 输入 (2, 64, 32, 32) → 输出 (2, 128, 32, 32) (stride=1)
  • 输入 (2, 64, 32, 32) → 输出 (2, 128, 16, 16) (stride=2)

练习 2:为残差块添加 BatchNorm 并理解其位置

任务:补全 ResidualBlockWithBN 的构建和前向传播。

BatchNorm 在残差块中的正确位置

x → Conv → BN → ReLU → Conv → BN → + shortcut → ReLU

思考题:如果把 BN 放在加法之后会有什么问题?

答案:shortcut 分支输出 x 的分布和主路径输出 F(x) 的分布在加法后混合,对这个混合结果做 BN 会破坏恒等映射的"纯度"。更重要的是,shortcut 路径没有经过 BN,而主路径经过了 BN,两者在 BN 后的分布已经不同——再加一次 BN 并没有消除而是放大了这种不一致。正确的做法是两个分支各自做完处理后直接相加。

TODO 提示

python
self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)

# shortcut:维度匹配时恒等,不匹配时 1×1 Conv + BN
if stride != 1 or in_channels != out_channels:
    self.shortcut = nn.Sequential(
        nn.Conv2d(in_channels, out_channels, 1, stride, bias=False),
        nn.BatchNorm2d(out_channels),
    )
else:
    self.shortcut = nn.Identity()

练习 3:构建 ResNet-34

任务:参考 demo.py 中的 ResNet 类,完成 ResNet34

ResNet-34 的 block 配置[3, 4, 6, 3](共 16 个 BasicBlock,34 层)

Layer输出大小 (CIFAR-10)通道数Block 数
conv132×3264
layer132×32643
layer216×161284
layer38×82566
layer44×45123
avgpool+FC1×1512→10

对比 ResNet-18[2, 2, 2, 2]。ResNet-34 在 layer2 和 layer3 增加了深度,总层数从 18 增加到 34。

TODO 提示

python
self.conv1 = nn.Conv2d(3, 64, 3, 1, 1, bias=False)
self.bn1 = nn.BatchNorm2d(64)
self.layer1 = self._make_layer(64,  3, stride=1)
self.layer2 = self._make_layer(128, 4, stride=2)
self.layer3 = self._make_layer(256, 6, stride=2)
self.layer4 = self._make_layer(512, 3, stride=2)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512, num_classes)

_make_layer 方法

python
def _make_layer(self, planes, num_blocks, stride):
    layers = []
    layers.append(BasicBlockExercise(self.in_planes, planes, stride))
    self.in_planes = planes  # 更新全局通道数
    for _ in range(1, num_blocks):
        layers.append(BasicBlockExercise(self.in_planes, planes, stride=1))
    return nn.Sequential(*layers)

forward 方法conv1→bn1→relu → layer1→layer2→layer3→layer4 → avgpool→flatten→fc

预期输出:输入 (1, 3, 32, 32) → 输出 (1, 10),参数量约 21.28M。

练习 4:分析参数量和 FLOPs

任务:手动计算三种 ResNet 的参数量。

BasicBlock 参数量公式(忽略 BN 的 γ,β):

  • 两个 3×3 卷积:in_planes × planes × 9 + planes × planes × 9
  • 如果 shortcut 不是恒等:再加上 in_planes × planes × 11×1 卷积)

Bottleneck 参数量公式

  • 三个卷积:in_planes × planes × 1 + planes × planes × 9 + planes × (4×planes) × 1
  • 如果 shortcut 不是恒等:再加上 in_planes × (4×planes) × 1

各 ResNet 的配置

模型Block 类型num_blocks通道数序列参数量(约)
ResNet-18BasicBlock[2,2,2,2]64→128→256→51211.17M
ResNet-34BasicBlock[3,4,6,3]64→128→256→51221.28M
ResNet-50Bottleneck[3,4,6,3]256→512→1024→204823.52M

提示:别忘了初始卷积 conv13 × 64 × 9 = 1,728)和最后的全连接层 fc512 × num_classes)。

期望值(供校验):

python
{
    "resnet18": 11173962,  # ~11.17M
    "resnet34": 21282122,  # ~21.28M
    "resnet50": 23520842,  # ~23.52M (with expansion=4)
}

完整代码

py
# -*- coding: utf-8 -*-
"""
s11 经典架构演进 练习
=====================
完成以下 TODO 练习来加深对 ResNet 架构的理解。
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import List

# ============================================================
# 练习 1:实现 BasicBlock 前向传播(含跳跃连接)
# ============================================================

class BasicBlockExercise(nn.Module):
    """
    TODO: 完成 BasicBlock 的前向传播实现

    残差块公式: out = ReLU( Conv→BN→ReLU→Conv→BN(x) + shortcut(x) )

    这是 ResNet 最核心的计算单元。你需要理解:
    1. 主路径: 两个 3×3 卷积,每个后面跟 BN
    2. 跳跃连接: 如果 in_planes != planes 或 stride != 1,用 1×1 卷积对齐
    3. 残差加法: F(x) + identity 在 ReLU 之前
    """

    expansion = 1

    def __init__(self, in_planes: int, planes: int, stride: int = 1):
        """
        初始化 BasicBlock

        参数:
            in_planes: 输入通道数
            planes: 输出通道数(中间和目标通道都是它)
            stride: 步长(下采样用)
        """
        super(BasicBlockExercise, self).__init__()

        # 主路径的两个卷积和 BN
        self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=3,
                               stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(planes)
        self.conv2 = nn.Conv2d(planes, planes, kernel_size=3,
                               stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(planes)

        # 跳跃连接:如果维度不匹配,需要用 1x1 卷积调整
        self.shortcut = nn.Sequential()
        # TODO: 当 in_planes != planes 或 stride != 1 时,
        #       添加 1×1 Conv + BN 来匹配维度
        # if stride != 1 or in_planes != planes:
        #     self.shortcut = nn.Sequential(
        #         nn.Conv2d(???, ???, kernel_size=1, stride=???, bias=False),
        #         nn.BatchNorm2d(???),
        #     )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        TODO: 实现残差块的前向传播

        参数:
            x: 输入张量,形状 (N, in_planes, H, W)
        返回:
            out: 输出张量,形状 (N, planes, H//stride, W//stride)

        步骤提示:
        1. identity = self.shortcut(x)  # 恒等映射或投影
        2. out = F.relu(self.bn1(self.conv1(x)))  # 第一个 Conv → BN → ReLU
        3. out = self.bn2(self.conv2(out))  # 第二个 Conv → BN(暂不加 ReLU)
        4. out += identity  # 跳跃连接加法: H(x) = F(x) + x
        5. out = F.relu(out)  # 最后的 ReLU
        6. return out
        """
        # TODO: 写下你的代码
        pass


# ============================================================
# 练习 2:为残差块添加 BatchNorm 并理解其位置
# ============================================================

class ResidualBlockWithBN(nn.Module):
    """
    TODO: 分析 BatchNorm 在残差块中的正确位置

    BN 通常放在卷积之后、ReLU 之前,即 Conv → BN → ReLU。
    请完成以下 block 的构建,回答:为什么 BN 要放在加法之前而不是之后?

    残差块中 BN 的正确位置:
        x → Conv → BN → ReLU → Conv → BN → + shortcut → ReLU

    如果把 BN 放在加法之后会有什么问题?
    (提示: 考虑 shortcut 路径的 BN 对恒等映射的影响)
    """

    def __init__(self, in_channels: int, out_channels: int, stride: int = 1):
        super(ResidualBlockWithBN, self).__init__()

        # TODO: 补全以下构建代码
        self.conv1 = None  # TODO: 3×3 Conv, in_channels→out_channels
        self.bn1 = None    # TODO: BatchNorm2d(out_channels)
        self.conv2 = None  # TODO: 3×3 Conv, out_channels→out_channels
        self.bn2 = None    # TODO: BatchNorm2d(out_channels)

        # TODO: 跳跃连接(维度匹配时用恒等,不匹配时用 1×1 Conv + BN)
        self.shortcut = nn.Identity()  # 占位,替换为正确的 shortcut

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        TODO: 实现带正确 BN 位置的前向传播
        """
        pass


# ============================================================
# 练习 3:构建 ResNet-34
# ============================================================

def build_resnet34(num_classes: int = 10) -> nn.Module:
    """
    TODO: 参考 ResNet-18 的构建方式,完成 ResNet-34

    ResNet-34 使用 BasicBlock,每个 layer 的 block 数量为:
        [3, 4, 6, 3]  (总计 16 个残差块,34 层)

    对比 ResNet-18 的 [2, 2, 2, 2]:
        - layer1: 2→3 个 block,通道数 64
        - layer2: 2→4 个 block,通道数 128
        - layer3: 2→6 个 block,通道数 256
        - layer4: 2→3 个 block,通道数 512

    需要实现的组件:
        1. 初始卷积: Conv2d(3, 64, kernel=3, stride=1, padding=1, bias=False) + BN + ReLU
        2. _make_layer 方法: 构建一个包含多个 BasicBlock 的 layer
        3. 全局平均池化: AdaptiveAvgPool2d((1, 1))
        4. 分类头: Linear(512, num_classes)

    提示: 参考 demo.py 中的 ResNet 类实现
    """

    class ResNet34(nn.Module):
        def __init__(self):
            super(ResNet34, self).__init__()
            self.in_planes = 64  # 初始通道数

            # TODO: 初始卷积
            self.conv1 = None  # Conv2d(3, 64, 3, 1, 1)
            self.bn1 = None    # BatchNorm2d(64)

            # TODO: 4 个残差层
            # self.layer1 = self._make_layer(64, 3, stride=1)
            # self.layer2 = self._make_layer(128, 4, stride=2)
            # self.layer3 = self._make_layer(256, 6, stride=2)
            # self.layer4 = self._make_layer(512, 3, stride=2)

            # TODO: 全局平均池化 + 全连接
            self.avgpool = None  # AdaptiveAvgPool2d((1, 1))
            self.fc = None       # Linear(512, num_classes)

        def _make_layer(self, planes: int, num_blocks: int,
                        stride: int) -> nn.Sequential:
            """
            TODO: 构建一个残差层

            参数:
                planes: 该层的输出通道数
                num_blocks: 该层包含的 block 个数
                stride: 第一个 block 的步长

            返回:
                nn.Sequential 包装的残差层

            提示:
                1. 第一个 block 使用给定 stride 和 in_planes→planes
                2. 更新 self.in_planes = planes * BasicBlock.expansion (= planes)
                3. 后续 block 使用 stride=1
                4. 层层包装到 layers 列表中
            """
            layers = []
            # TODO: 添加第一个 block (stride 可能需要下采样)
            # TODO: 添加剩余 num_blocks-1 个 block (stride=1)
            return nn.Sequential(*layers)

        def forward(self, x):
            """TODO: 实现前向传播"""
            # x = F.relu(self.bn1(self.conv1(x)))
            # x = self.layer1(x)
            # x = self.layer2(x)
            # x = self.layer3(x)
            # x = self.layer4(x)
            # x = self.avgpool(x)
            # x = x.view(x.size(0), -1)
            # x = self.fc(x)
            # return x
            pass

    return ResNet34()


# ============================================================
# 练习 4:分析参数量和 FLOPs
# ============================================================

def analyze_resnet_parameters() -> dict:
    """
    TODO: 计算并返回不同 ResNet 变体的参数量

    基于以下信息(忽略 BN 参数,近似计算):

    BasicBlock (in_planes, planes, stride=1):
        参数 = in_planes*planes*9 + planes*planes*9

    BasicBlock 带下采样 (stride=2 或 in_planes != planes):
        参数 = 上述 + shortcut 1×1 Conv 的 in_planes*planes*1

    各 ResNet 的 block 配置:
        ResNet-18: [2, 2, 2, 2], 通道数 [64, 128, 256, 512]
        ResNet-34: [3, 4, 6, 3], 通道数 [64, 128, 256, 512]
        ResNet-50: [3, 4, 6, 3], 通道数 [256, 512, 1024, 2048] (用 Bottleneck)

    返回:
        dict: {"resnet18": 参数数量, "resnet34": 参数数量, "resnet50": 参数数量}

    提示: 也要记得加上初始 conv1 (3*64*9) 和最后的 fc (512*num_classes)
    """
    # TODO: 逐层计算参数量
    params = {
        "resnet18": 0,  # 期望值: ~11.17M
        "resnet34": 0,  # 期望值: ~21.28M
        "resnet50": 0,  # 期望值: ~23.52M (含 bottleneck expansion)
    }
    return params


# ============================================================
# 测试代码
# ============================================================

if __name__ == "__main__":
    print("=" * 50)
    print("s11 经典架构演进 — 练习测试")
    print("=" * 50)

    # ---- 测试练习 1:BasicBlock 前向传播 ----
    print("\n[练习 1] BasicBlock 前向传播测试:")
    try:
        block = BasicBlockExercise(64, 128, stride=1)
        x = torch.randn(2, 64, 32, 32)
        out = block(x)
        print(f"  输入形状: {x.shape}")
        print(f"  输出形状: {out.shape}")
        print(f"  期望输出形状: torch.Size([2, 128, 32, 32])")
        print(f"  {'✓ 通过' if out.shape == (2, 128, 32, 32) else '✗ 失败'}")
    except Exception as e:
        print(f"  测试异常: {e}")
        print("  (请完成 forward 方法中的 TODO)")

    # ---- 测试下采样情况 ----
    print("\n  BasicBlock 下采样测试 (stride=2):")
    try:
        block = BasicBlockExercise(64, 128, stride=2)
        x = torch.randn(2, 64, 32, 32)
        out = block(x)
        print(f"  输入形状: {x.shape}")
        print(f"  输出形状: {out.shape}")
        print(f"  期望输出形状: torch.Size([2, 128, 16, 16])")
        print(f"  {'✓ 通过' if out.shape == (2, 128, 16, 16) else '✗ 失败'}")
    except Exception as e:
        print(f"  测试异常: {e}")

    # ---- 测试练习 3:ResNet-34 构建 ----
    print("\n[练习 3] ResNet-34 构建测试:")
    try:
        model = build_resnet34(num_classes=10)
        total_params = sum(p.numel() for p in model.parameters())
        print(f"  ResNet-34 总参数量: {total_params:,}")
        print(f"  期望约: ~21,280,000")
        x = torch.randn(1, 3, 32, 32)
        out = model(x)
        print(f"  输入形状: {x.shape}")
        print(f"  输出形状: {out.shape}")
        print(f"  期望输出形状: torch.Size([1, 10])")
        print(f"  {'✓ 通过' if out.shape == (1, 10) else '✗ 失败'}")
    except Exception as e:
        print(f"  测试异常: {e}")
        print("  (请完成 build_resnet34 中的 TODO)")

    # ---- 练习 4:参数计算 ----
    print("\n[练习 4] 参数量分析:")
    param_estimates = analyze_resnet_parameters()
    for name, count in param_estimates.items():
        print(f"  {name}: {count:,} 参数")

    print("\n" + "=" * 50)
    print("完成所有练习后,运行 demo.py 查看完整的训练对比实验。")
    print("=" * 50)