ResNet 残差学习 PyTorch 实现详解:Shortcut 连接、残差块结构与 CIFAR-10 训练全流程
【免费下载链接】annotated_deep_learning_paper_implementations🧑🏫 60+ Implementations/tutorials of deep learning papers with side-by-side notes 📝; including transformers (original, xl, switch, feedback, vit, ...), optimizers (adam, adabelief, sophia, ...), gans(cyclegan, stylegan2, ...), 🎮 reinforcement learning (ppo, dqn), capsnet, distillation, ... 🧠项目地址: https://gitcode.com/gh_mirrors/an/annotated_deep_learning_paper_implementations
本篇技术文章基于仓库中的 ResNet 实现文档 及其配套源码展开,完整讲解残差学习(Residual Learning)的数学动机、ShortcutProjection/ResidualBlock/BottleneckResidualBlock/ResNetBase四个核心类的 PyTorch 实现,并结合 CIFAR-10 训练脚本 给出可直接复现的超参数配置与训练流程。读完后,你将能够理解 ResNet 为何能突破深度网络的退化问题、如何在源码层面实现恒等捷径与线性投影捷径,以及如何用仓库提供的 labml 实验框架在 CIFAR-10 上训练一个带 bottleneck 结构的 ResNet。
1. 退化问题:ResNet 要解决的核心矛盾
ResNet 是论文Deep Residual Learning for Image Recognition(即仓库 papers/resnet.pdf 对应的 arXiv 1512.03385)的 PyTorch 实现,核心目标是克服深度网络的退化问题(degradation problem):
- 当网络层数增加时,精度先上升,然后趋于饱和(saturate),继续加深后精度反而会下降;
- 论文指出,更深的模型理论上应该至少不输于更浅的模型,因为新增的层完全可以学成恒等映射(identity mapping),让深层网络“退化为”浅层网络的表现。
然而实验表明,简单地堆叠层数并训练网络去逼近恒等映射并不容易收敛。ResNet 的解法就是残差学习。
2. 残差学习:把 $H(x)$ 改写为 $\mathcal{F}(x) + x$
设某几层网络需要学习的映射为 $\mathcal{H}(x)$,ResNet 不让网络直接拟合 $\mathcal{H}(x)$,而是转而训练残差函数:
$$\mathcal{F}(x) = \mathcal{H}(x) - x$$
原映射改写为 $\mathcal{F}(x) + x$。这样做的关键收益在于:学习恒等映射 $\mathcal{H}(x) = x$ 等价于学习让 $\mathcal{F}(x) = 0$,后者比直接拟合一个恒等变换更容易优化。
写成参数化形式为:
$$\mathcal{F}(x, {W_i}) + x$$
当残差分支输出 $\mathcal{F}(x, {W_i})$ 与输入 $x$ 的特征图尺寸不一致(通道数或空间尺寸不同)时,论文建议使用带学习权重 $W_s$ 的线性投影来对齐二者:
$$\mathcal{F}(x, {W_i}) + W_s x$$
文档中同时记录了论文的两个重要实验结论,源码实现正是据此设计的:
- 论文对比过零填充(zero padding)与线性投影两种对齐方式,发现线性投影效果更好;
- 当两个分支的特征图尺寸恰好一致时,恒等映射比线性投影更好(此时投影只是引入冗余参数)。
另外一个容易被忽略的细节:$\mathcal{F}$ 必须包含不止一层网络,否则 $\mathcal{F}(x, {W_i}) + W_s x$ 这个和本身不包含非线性变换,整个结构退化成一层线性层,失去表达能力。这也是后续ResidualBlock至少包含两个卷积层的原因。
3. 源码实现:从 Shortcut 投影到完整网络
完整实现位于 labml_nn/resnet/init.py,由四个类组成:ShortcutProjection、ResidualBlock、BottleneckResidualBlock、ResNetBase。
3.1 ShortcutProjection:实现 $W_s x$ 线性投影
见 ShortcutProjection:
class ShortcutProjection(nn.Module): def __init__(self, in_channels: int, out_channels: int, stride: int): super().__init__() # 线性投影 $W_s x$:1x1 卷积 + 批量归一化 self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride) self.bn = nn.BatchNorm2d(out_channels) def forward(self, x: torch.Tensor): return self.bn(self.conv(x))三个参数分别对应文档中的定义:
in_channels:输入 $x$ 的通道数;out_channels:残差分支 $\mathcal{F}(x, {W_i})$ 的通道数;stride:与残差分支的第一个卷积相同的步长。捷径分支使用同样的 stride,是为了让 $W_s x$ 与 $\mathcal{F}(x, {W_i})$ 的空间尺寸严格对齐,保证相加合法。
实现上选择kernel_size=1的卷积作为投影(论文中即 1×1 卷积),并在其后紧跟BatchNorm2d——论文建议在每个卷积操作之后都加批归一化,该代码完全遵循了这一建议。
3.2 ResidualBlock:双 3×3 卷积的基本残差块
见 ResidualBlock,它实现了论文中的标准残差块(对应文档中 residual_block.svg 的结构图),包含两个 $3 \times 3$ 卷积层:
class ResidualBlock(nn.Module): def __init__(self, in_channels: int, out_channels: int, stride: int): super().__init__() # 第一个 3x3 卷积:in_channels -> out_channels,携带 stride self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1) self.bn1 = nn.BatchNorm2d(out_channels) self.act1 = nn.ReLU() # 第二个 3x3 卷积:out_channels -> out_channels,stride 恒为 1 self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1) self.bn2 = nn.BatchNorm2d(out_channels) # 捷径连接:stride != 1 或通道数变化时使用投影,否则用恒等 if stride != 1 or in_channels != out_channels: self.shortcut = ShortcutProjection(in_channels, out_channels, stride) else: self.shortcut = nn.Identity() self.act2 = nn.ReLU() def forward(self, x: torch.Tensor): shortcut = self.shortcut(x) # 捷径分支 x = self.act1(self.bn1(self.conv1(x))) # 残差分支第一层 x = self.bn2(self.conv2(x)) # 残差分支第二层 return self.act2(x + shortcut) # 相加后再激活几个与文档严格对应的设计要点:
- 卷积分工:第一个卷积负责通道数映射(
in_channels→out_channels)且携带 stride;第二个卷积只改变特征、out_channels保持不变,stride 恒为 1。当用 stride=2 压缩特征图尺寸时,out_channels会大于in_channels,此时触发投影捷径; - 捷径选择逻辑:
stride != 1 or in_channels != out_channels时启用ShortcutProjection,否则使用nn.Identity()恒等捷径。这与第 2 节中“尺寸一致时用恒等、不一致时用 $W_s x$ 投影”的论文结论一一对应; - 激活位置:ReLU 只出现在第一层卷积之后(
act1)以及两个分支相加之后(act2),第二层卷积与相加之间没有额外激活,符合“$\mathcal{F}(x) + x$ 后统一过非线性”的论文结构; - 输入形状:
[batch_size, in_channels, height, width]。
3.3 BottleneckResidualBlock:1×1 - 3×3 - 1×1 瓶颈块
见 BottleneckResidualBlock(结构图见 bottleneck_block.svg),用于 ResNet-50 等更深的变体,由 $1 \times 1$、$3 \times 3$、$1 \times 1$ 三个卷积组成:
class BottleneckResidualBlock(nn.Module): def __init__(self, in_channels, bottleneck_channels, out_channels, stride): # 第一层 1x1 卷积:压缩通道 in_channels -> bottleneck_channels self.conv1 = nn.Conv2d(in_channels, bottleneck_channels, kernel_size=1) self.bn1, self.act1 = nn.BatchNorm2d(bottleneck_channels), nn.ReLU() # 第二层 3x3 卷积:在压缩后的“瓶颈”空间做卷积,可携带 stride self.conv2 = nn.Conv2d(bottleneck_channels, bottleneck_channels, kernel_size=3, stride=stride, padding=1) self.bn2, self.act2 = nn.BatchNorm2d(bottleneck_channels), nn.ReLU() # 第三层 1x1 卷积:恢复通道 bottleneck_channels -> out_channels self.conv3 = nn.Conv2d(bottleneck_channels, out_channels, kernel_size=1) self.bn3 = nn.BatchNorm2d(out_channels) if stride != 1 or in_channels != out_channels: self.shortcut = ShortcutProjection(in_channels, out_channels, stride) else: self.shortcut = nn.Identity() self.act3 = nn.ReLU()其设计意图(源自 labml_nn/resnet/init.py 的文档注释):
bottleneck_channels小于in_channels,代价较高的 $3 \times 3$ 卷积只在压缩后的低维空间执行,这就是“瓶颈”名称的由来;- 第一层 $1 \times 1$ 卷积降通道,第三层 $1 \times 1$ 卷积升通道,两个 1×1 卷积承担了“先降后升”的通道调度;
- stride 只施加在中间的 $3 \times 3$ 卷积上。当 stride > 1 时
out_channels大于in_channels,否则二者相等; - 与基本残差块不同,每层卷积之后都有 ReLU(
act1、act2),最后一个act3在相加之后。
3.4 ResNetBase:堆叠残差块与全局平均池化
见 ResNetBase,这是不带最终分类层的 ResNet 主干(backbone):
class ResNetBase(nn.Module): def __init__(self, n_blocks: List[int], n_channels: List[int], bottlenecks: Optional[List[int]] = None, img_channels: int = 3, first_kernel_size: int = 7): super().__init__() assert len(n_blocks) == len(n_channels) assert bottlenecks is None or len(bottlenecks) == len(n_channels) # 初始卷积:img_channels -> n_channels[0],stride=2 self.conv = nn.Conv2d(img_channels, n_channels[0], kernel_size=first_kernel_size, stride=2, padding=first_kernel_size // 2) self.bn = nn.BatchNorm2d(n_channels[0]) blocks = [] prev_channels = n_channels[0] for i, channels in enumerate(n_channels): # 每个新特征图尺度的第一个块 stride=2,其余块 stride=1 stride = 2 if len(blocks) == 0 else 1 if bottlenecks is None: blocks.append(ResidualBlock(prev_channels, channels, stride=stride)) else: blocks.append(BottleneckResidualBlock(prev_channels, bottlenecks[i], channels, stride=stride)) prev_channels = channels for _ in range(n_blocks[i] - 1): # 同一尺度内剩余块:尺寸/通道均不变 if bottlenecks is None: blocks.append(ResidualBlock(channels, channels, stride=1)) else: blocks.append(BottleneckResidualBlock(channels, bottlenecks[i], channels, stride=1)) self.blocks = nn.Sequential(*blocks) def forward(self, x: torch.Tensor): x = self.bn(self.conv(x)) # 初始卷积 + BN x = self.blocks(x) # 残差(瓶颈)块堆叠 x = x.view(x.shape[0], x.shape[1], -1) # [B, C, H, W] -> [B, C, H*W] return x.mean(dim=-1) # 全局平均池化 -> [B, C]从源码结构看,主干的组织规则是:
| 参数 | 含义 |
|---|---|
n_blocks | 每个特征图尺度下堆叠多少个块 |
n_channels | 每个特征图尺度的输出通道数 |
bottlenecks | 瓶颈块中间 $3 \times 3$ 卷积的通道数;为None时使用基本残差块 |
img_channels | 输入图像通道数(RGB 为 3) |
first_kernel_size | 初始卷积核大小(默认 7,对应 ImageNet 风格的大图输入) |
- 下采样节奏:初始卷积 stride=2 先减半;随后每个特征图尺度的第一个块再使用 stride=2,其余块 stride=1,从而实现“每过几个块,特征图尺寸减半、通道数增加”的金字塔结构;
- 块间衔接:同一尺度内块与块之间
in_channels == out_channels且 stride=1,因此走恒等捷径,投影捷径只在尺度切换点出现,参数开销被控制在最小; - 输出表示:最后用
x.view(...)展平并mean(dim=-1)做全局平均池化,输出[batch_size, n_channels[-1]]的向量表示,替代传统的大全连接层,作为分类头的输入。
4. CIFAR-10 训练实验:完整可复现的配置
训练脚本位于 labml_nn/resnet/experiment.py,基于 labml 实验框架运行。实验配置继承自 CIFAR10Configs,后者又组合了 数据集配置 与 MNISTConfigs 训练器。
4.1 模型超参数默认值
Configs 中定义了 ResNet 的默认结构参数:
class Configs(CIFAR10Configs): # 每个特征图尺度的块数 n_blocks: List[int] = [3, 3, 3] # 每个特征图尺度的通道数 n_channels: List[int] = [16, 32, 64] # 瓶颈块中间层通道数(None 表示使用基本残差块) bottlenecks: Optional[List[int]] = None # 初始卷积核大小(CIFAR-10 的 32x32 小图用 3 而非默认的 7) first_kernel_size: int = 3CIFAR-10 图像只有 32×32,因此初始卷积核从默认的 7 改为 3,避免过大的下采样损失。
4.2 分类头构建
模型由ResNetBase主干加一个线性分类层组成(见 _resnet):
@option(Configs.model) def _resnet(c: Configs): base = ResNetBase(c.n_blocks, c.n_channels, c.bottlenecks, img_channels=3, first_kernel_size=c.first_kernel_size) classification = nn.Linear(c.n_channels[-1], 10) # 10 类 CIFAR-10 model = nn.Sequential(base, classification) return model.to(c.device)这正好印证了第 3.4 节的设计:ResNetBase全局平均池化后输出[batch, 64]向量(n_channels[-1] = 64),直接接入nn.Linear(64, 10)产生 10 类 logits,送入训练器中的nn.CrossEntropyLoss(定义于 labml_nn/experiments/mnist.py)。
4.3 实际训练用的覆盖配置
main 函数 中通过experiment.configs覆盖了部分默认值,得到实验实际使用的完整配置:
experiment.configs(conf, { 'bottlenecks': [8, 16, 16], # 启用瓶颈块:中间层通道 8/16/16 'n_blocks': [6, 6, 6], # 每个尺度堆叠 6 个瓶颈块 'optimizer.optimizer': 'Adam', 'optimizer.learning_rate': 2.5e-4, 'epochs': 500, 'train_batch_size': 256, 'train_dataset': 'cifar10_train_augmented', 'valid_dataset': 'cifar10_valid_no_augment', })各配置项的落地位置:
bottlenecks: [8, 16, 16]:一旦该值非None,ResNetBase即切换为BottleneckResidualBlock。此时每个块为ResNet-50 风格的 1×1/3×3/1×1 结构:第 1 尺度从 16 通道压缩到 8 再升到 32,第 2 尺度从 32 压缩到 16 再升到 32,第 3 尺度从 64 压缩到 16 再升到 64。三个尺度的总块数为 6+6+6=18 个瓶颈块,每个瓶颈块含 3 个卷积加 1 个投影卷积(尺度切换处),主干总深度远超默认的[3,3,3]基本块配置;optimizer.learning_rate: 2.5e-4:学习率经由 OptimizerConfigs 解析,optimizer: 'Adam'会构造仓库自实现的Adam(labml_nn/optimizers/adam.py),其betas=(0.9, 0.999)、eps=1e-08为该类默认值;epochs: 500、train_batch_size: 256:训练 500 个 epoch,训练 batch 为 256(数据集配置中valid_batch_size默认为 1024,见 labml_nn/helpers/datasets.py);- 数据增强:
cifar10_train_augmented对应 labml_nn/experiments/cifar10.py 中的训练集选项——RandomCrop(32, padding=4)随机裁剪 +RandomHorizontalFlip()随机水平翻转 + 以均值/标准差均为 0.5 的Normalize;验证集cifar10_valid_no_augment不做增强,仅做ToTensor与相同归一化,保证验证指标不被增强噪声污染。
4.4 训练循环底层机制
conf.run()调用的是 TrainValidConfigs.run:每轮先以is_train=True模式跑trainer(),再跑validator()验证。每个 batch 的具体前向/反向逻辑继承自 MNISTConfigs.step:
self.model.train(self.mode.is_train)切换训练/评估模式(影响 BatchNorm 的统计量行为);- 前向得到
output,计算CrossEntropyLoss并写入 tracker; - 仅训练模式下执行
loss.backward()、optimizer.step()、optimizer.zero_grad(),并在每个 epoch 的最后一个 batch 记录模型参数与梯度(tracker.add('model', self.model)); inner_iterations = 10(MNISTConfigs)表示每个 epoch 内训练/验证交替 10 次,即每个 epoch 会把数据集切成 10 段轮流训练与验证,让验证曲线更平滑地反映训练进程。
实验入口为:
python -m labml_nn.resnet.experiment(需安装labml框架与torchvision,数据集在lab.get_data_path()指定路径自动下载。)
5. 小结与延伸阅读
本篇围绕 labml_nn/resnet/readme.md 的完整内容展开:
- 动机:退化问题——层数加深后精度饱和甚至下降,而更深的网络本可通过恒等映射保持更浅网络的水平;
- 方法:把 $H(x)$ 改学为残差 $\mathcal{F}(x) = H(x) - x$,令恒等映射对应 $\mathcal{F} = 0$ 更易优化;尺寸不一致时以 $W_s x$(1×1 卷积 + BN)投影对齐,一致时用恒等捷径;$\mathcal{F}$ 必须至少两层以保证非线性;
- 实现:
ShortcutProjection(投影捷径)、ResidualBlock(双 3×3 卷积)、BottleneckResidualBlock(1×1-3×3-1×1 瓶颈结构)、ResNetBase(尺度切换处 stride=2 下采样 + 全局平均池化)四个类共同构成可直接替换的模块; - 实验:CIFAR-10 上使用 18 个瓶颈块(
[6,6,6])、bottlenecks=[8,16,16]、Adam 学习率 2.5e-4、500 epoch、batch 256,训练集带随机裁剪/翻转增强。
相关源码路径汇总:
| 文件 | 内容 |
|---|---|
| labml_nn/resnet/init.py | 四个核心类的完整实现与逐行注释 |
| labml_nn/resnet/experiment.py | CIFAR-10 训练入口与全部超参数 |
| labml_nn/experiments/cifar10.py | CIFAR-10 数据集与增强管线 |
| labml_nn/experiments/mnist.py | 训练/验证步函数、损失与指标 |
| labml_nn/helpers/trainer.py | TrainValidConfigs训练循环 |
| labml_nn/optimizers/configs.py | 优化器选项解析(含 Adam 构造) |
【免费下载链接】annotated_deep_learning_paper_implementations🧑🏫 60+ Implementations/tutorials of deep learning papers with side-by-side notes 📝; including transformers (original, xl, switch, feedback, vit, ...), optimizers (adam, adabelief, sophia, ...), gans(cyclegan, stylegan2, ...), 🎮 reinforcement learning (ppo, dqn), capsnet, distillation, ... 🧠项目地址: https://gitcode.com/gh_mirrors/an/annotated_deep_learning_paper_implementations
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考