- 人工智能
- 深度学习
- 机器学习
- 教程
【免费下载链接】d2l-zh
《动手学深度学习》:面向中文读者、能运行、可讨论。中英文版被70多个国家的500多所大学用于教学。
本文以《动手学深度学习》(D2L)开源仓库 chapter_convolutional-modern/resnet_origin.md 为核心骨架,系统讲解残差网络(ResNet)的数学动机、残差块(Residual Block)的实现原理,以及 ResNet-18 从零搭建到训练的全过程。读完本文,你将理解"为什么网络越深越难训练"的理论根源,掌握恒等映射与残差映射的区别,并能基于仓库提供的 MXNet / PyTorch / TensorFlow / PaddlePaddle 四套后端代码,独立实现并训练自己的 ResNet 模型。
从函数类谈起:为什么"更深的网络"不总是"更好的网络"
在设计越来越深的网络时,一个核心问题浮出水面:新添加的层究竟如何提升网络的性能?要严谨地回答这个问题,需要一点数学工具——函数类(Function Classes)的概念。
假设 $\mathcal{F}$ 是某个特定网络架构(连同学习率等超参数设置)所能达到的函数类集合,即对于任意 $f \in \mathcal{F}$,都存在一组参数(权重和偏置),可以通过在合适的数据集上训练获得。设 $f^$ 是我们真正想找到的"真"函数,若 $f^\in \mathcal{F}$,我们自然可以轻松训练得到它;但通常我们没那么幸运,只能退而求其次,在 $\mathcal{F}$ 中寻找最优近似 $f^*_\mathcal{F}$。例如,给定特征 $\mathbf{X}$ 与标签 $\mathbf{y}$ 的数据集,可通过求解如下优化问题得到:
$$f^*_\mathcal{F} \stackrel{\mathrm{def}}{=} \mathop{\mathrm{argmin}}_f L(\mathbf{X}, \mathbf{y}, f) \text{ subject to } f \in \mathcal{F}.$$
直觉上,设计一个更强大的架构 $\mathcal{F}'$ 应当得到更优的结果,即 $f^_{\mathcal{F}'}$ 比 $f^{\mathcal{F}}$ 更接近 $f^$。但关键前提是:必须满足 $\mathcal{F} \subseteq \mathcal{F}'$(函数类嵌套)。如果函数类不嵌套,$f^{\mathcal{F}'}$ 甚至可能更差。如 img/functionclasses.svg 所示,对于非嵌套函数类,更大的函数类(图中以更大区域表示)并不保证更接近真函数 $f^$——左侧示例中 $\mathcal{F}_3$ 比 $\mathcal{F}_1$ 更接近 $f^$,但 $\mathcal{F}_6$ 反而离得更远;而右侧的嵌套函数类 $\mathcal{F}_1 \subseteq \ldots \subseteq \mathcal{F}_6$ 则避免了这一隐患。
因此结论是:只有当较大的函数类包含较小的函数类时,增加复杂度才能严格提升网络的表达能力。对应到深度神经网络:如果能把新添加的层训练成恒等映射 $f(\mathbf{x}) = \mathbf{x}$,新模型至少与原模型同样有效;同时,由于新模型可能得到更优的解来拟合训练数据集,添加层也就更容易降低训练误差。
这正是何恺明等人在研究极深计算机视觉模型时思考的问题(参见论文 He.Zhang.Ren.ea.2016,即 d2l.bib 中的引用条目)。他们提出的残差网络(ResNet)的核心思想是:每一个附加层都应该更容易地将恒等函数作为其元素之一包含进来。这一深邃的思考最终导向了一个惊人简洁的解决方案——残差块(Residual Block)。凭借它,ResNet 赢得了 2015 年 ImageNet 大规模视觉识别挑战赛,并对后来深度神经网络的设计产生了深远影响。
残差块:把"学习目标"从映射 f(x) 换成残差 f(x)−x
聚焦神经网络的一个局部:设输入为 $\mathbf{x}$,希望学习到的理想映射为 $f(\mathbf{x})$(作为上层激活函数的输入)。常规块(左图)虚线框中的部分需要直接拟合映射 $f(\mathbf{x})$;而残差块(右图)虚线框中的部分只需拟合残差映射$f(\mathbf{x}) - \mathbf{x}$——这正是"残差块"名称的由来。
残差映射为什么更容易优化?考虑恒等映射 $f(\mathbf{x}) = \mathbf{x}$ 是理想映射的情形:此时只需要把虚线框内上层加权运算(全连接层或卷积层)的权重和偏置推到 0,就能得到恒等映射。即使理想映射只是"极接近"恒等映射,残差映射也更容易捕捉到恒等映射之上的细微波动。右图中承载输入 $\mathbf{x}$ 直达加法运算的实线路径被称为残差连接(residual connection)或捷径连接(shortcut connection)。借助它,输入可以跨层更快地向前传播。
残差块的结构设计
ResNet 沿用了 VGG 完整的 $3\times 3$ 卷积层设计。残差块包含两个输出通道数相同的 $3\times 3$ 卷积层,每个卷积层后接一个批量规范化(BatchNorm)层和 ReLU 激活函数;随后通过跨层数据通路跳过这两个卷积运算,把输入直接加在最后的 ReLU 激活函数之前。这种设计要求两个卷积层的输出与输入形状一致才能相加;若想改变通道数,则需要引入一个额外的 $1\times 1$ 卷积层,先把输入变换成目标形状再做加法。
四个框架下的残差块实现
仓库在 chapter_convolutional-modern/resnet_origin.md 中给出了 MXNet、PyTorch、TensorFlow 三种实现(中文版 chapter_convolutional-modern/resnet.md 还补充了 PaddlePaddle 实现;同时Residual类也以#@save形式沉淀在 d2l/torch.py、d2l/tensorflow.py、d2l/mxnet.py 等工具模块中)。核心逻辑完全一致,下面逐一说明。
PyTorch 版:
from d2l import torch as d2l import torch from torch import nn from torch.nn import functional as F class Residual(nn.Module): #@save """The Residual block of ResNet.""" def __init__(self, input_channels, num_channels, use_1x1conv=False, strides=1): super().__init__() self.conv1 = nn.Conv2d(input_channels, num_channels, kernel_size=3, padding=1, stride=strides) self.conv2 = nn.Conv2d(num_channels, num_channels, kernel_size=3, padding=1) if use_1x1conv: self.conv3 = nn.Conv2d(input_channels, num_channels, kernel_size=1, stride=strides) else: self.conv3 = None self.bn1 = nn.BatchNorm2d(num_channels) self.bn2 = nn.BatchNorm2d(num_channels) self.relu = nn.ReLU(inplace=True) def forward(self, X): Y = F.relu(self.bn1(self.conv1(X))) Y = self.bn2(self.conv2(Y)) if self.conv3: X = self.conv3(X) Y += X return F.relu(Y)MXNet(Gluon)版:
from d2l import mxnet as d2l from mxnet import np, npx from mxnet.gluon import nn npx.set_np() class Residual(nn.Block): #@save """The Residual block of ResNet.""" def __init__(self, num_channels, use_1x1conv=False, strides=1, **kwargs): super().__init__(**kwargs) self.conv1 = nn.Conv2D(num_channels, kernel_size=3, padding=1, strides=strides) self.conv2 = nn.Conv2D(num_channels, kernel_size=3, padding=1) if use_1x1conv: self.conv3 = nn.Conv2D(num_channels, kernel_size=1, strides=strides) else: self.conv3 = None self.bn1 = nn.BatchNorm() self.bn2 = nn.BatchNorm() def forward(self, X): Y = npx.relu(self.bn1(self.conv1(X))) Y = self.bn2(self.conv2(Y)) if self.conv3: X = self.conv3(X) return npx.relu(Y + X)TensorFlow(Keras)版:
from d2l import tensorflow as d2l import tensorflow as tf class Residual(tf.keras.Model): #@save """The Residual block of ResNet.""" def __init__(self, num_channels, use_1x1conv=False, strides=1): super().__init__() self.conv1 = tf.keras.layers.Conv2D( num_channels, padding='same', kernel_size=3, strides=strides) self.conv2 = tf.keras.layers.Conv2D( num_channels, kernel_size=3, padding='same') self.conv3 = None if use_1x1conv: self.conv3 = tf.keras.layers.Conv2D( num_channels, kernel_size=1, strides=strides) self.bn1 = tf.keras.layers.BatchNormalization() self.bn2 = tf.keras.layers.BatchNormalization() def call(self, X): Y = tf.keras.activations.relu(self.bn1(self.conv1(X))) Y = self.bn2(self.conv2(Y)) if self.conv3 is not None: X = self.conv3(X) Y += X return tf.keras.activations.relu(Y)PaddlePaddle 版(来自中文版 chapter_convolutional-modern/resnet.md):
from d2l import paddle as d2l import paddle import paddle.nn as nn from paddle.nn import functional as F class Residual(nn.Layer): #@save def __init__(self, input_channels, num_channels, use_1x1conv=False, strides=1): super(Residual, self).__init__() self.conv1 = nn.Conv2D(input_channels, num_channels, kernel_size=3, padding=1, stride=strides) self.conv2 = nn.Conv2D(num_channels, num_channels, kernel_size=3, padding=1) if use_1x1conv: self.conv3 = nn.Conv2D(input_channels, num_channels, kernel_size=1, stride=strides) else: self.conv3 = None self.bn1 = nn.BatchNorm2D(num_channels) self.bn2 = nn.BatchNorm2D(num_channels) self.relu = nn.ReLU() def forward(self, X): Y = F.relu(self.bn1(self.conv1(X))) Y = self.bn2(self.conv2(Y)) if self.conv3: X = self.conv3(X) Y += X return F.relu(Y)上述代码生成了两种网络形态:当use_1x1conv=False时,在应用 ReLU 非线性之前直接把输入加到输出上;当use_1x1conv=True时,先通过 $1\times 1$ 卷积调整通道数与分辨率再相加,如 img/resnet-block.svg 所示。
参数说明
| 参数 | 含义 | 默认值 | 说明 |
|---|---|---|---|
input_channels | 输入通道数 | — | 仅在 PyTorch / Paddle 版显式传入;MXNet / TensorFlow 版由首层Conv2D自动推断 |
num_channels | 输出通道数 | — | 两个 $3\times 3$ 卷积层的输出通道数保持一致,保证可相加 |
use_1x1conv | 是否使用 $1\times 1$ 卷积调整形状 | False | 为True时通过conv3将输入变换到目标通道数/分辨率 |
strides | 首层卷积步幅 | 1 | 设为2时输出高宽减半,实现下采样 |
用形状验证残差块的两种模式
先看输入输出形状一致的情况(PyTorch 版,通道数 3,输入(4, 3, 6, 6)):
blk = Residual(3, 3) X = torch.rand(4, 3, 6, 6) Y = blk(X) Y.shape # torch.Size([4, 3, 6, 6])再看"增通道、减半高宽"的情况(输入不变,输出通道 6,strides=2):
blk = Residual(3, 6, use_1x1conv=True, strides=2) blk(X).shape # torch.Size([4, 6, 3, 3])可见use_1x1conv=True配合strides=2正是 ResNet 各阶段之间进行空间下采样与通道扩张的标准手段。MXNet 与 TensorFlow 的等价验证代码(Residual(6, use_1x1conv=True, strides=2))也包含在原文档中。
ResNet-18 模型:GoogLeNet 的骨架,更简单的结构
ResNet 的前两层与 GoogLeNet 一致:输出通道数为 64、步幅为 2 的 $7\times 7$ 卷积层,后接步幅为 2 的 $3\times 3$ 最大汇聚层。不同之处在于ResNet 在每个卷积层之后都加了批量规范化层(这正是上一章 chapter_convolutional-modern/batch-norm.md 所讲内容的直接应用)。
PyTorch 版前两层:
b1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(kernel_size=3, stride=2, padding=1))MXNet 版前两层:
net = nn.Sequential() net.add(nn.Conv2D(64, kernel_size=7, strides=2, padding=3), nn.BatchNorm(), nn.Activation('relu'), nn.MaxPool2D(pool_size=3, strides=2, padding=1))TensorFlow 版前两层:
b1 = tf.keras.models.Sequential([ tf.keras.layers.Conv2D(64, kernel_size=7, strides=2, padding='same'), tf.keras.layers.BatchNormalization(), tf.keras.layers.Activation('relu'), tf.keras.layers.MaxPool2D(pool_size=3, strides=2, padding='same')])四个残差模块:通道翻倍、高宽减半
GoogLeNet 使用 4 个由 Inception 块组成的模块,ResNet 则使用4 个由残差块组成的模块,每个模块包含若干个输出通道数相同的残差块。第一个模块的通道数与输入通道数一致;由于前面已经使用了步幅为 2 的最大汇聚层,无需再减小高宽。之后的每个模块,在其第一个残差块中把通道数较上一模块翻倍,并把高宽减半。
实现时对第一个模块做了特别处理(first_block=True时首块不做下采样)。PyTorch 版模块生成函数:
def resnet_block(input_channels, num_channels, num_residuals, first_block=False): blk = [] for i in range(num_residuals): if i == 0 and not first_block: blk.append(Residual(input_channels, num_channels, use_1x1conv=True, strides=2)) else: blk.append(Residual(num_channels, num_channels)) return blkMXNet 版:
def resnet_block(num_channels, num_residuals, first_block=False): blk = nn.Sequential() for i in range(num_residuals): if i == 0 and not first_block: blk.add(Residual(num_channels, use_1x1conv=True, strides=2)) else: blk.add(Residual(num_channels)) return blkTensorFlow 版将同样的逻辑封装为ResnetBlock层类(源码见原文档),这里给出call部分的关键逻辑:遍历self.residual_layers依次前向传播,self.residual_layers.layers实为tf.keras.Model的层集合。
随后组装四个模块(每个模块使用 2 个残差块,对应 PyTorch 的b2~b5;MXNet 版用net.add顺序追加):
# PyTorch b2 = nn.Sequential(*resnet_block(64, 64, 2, first_block=True)) b3 = nn.Sequential(*resnet_block(64, 128, 2)) b4 = nn.Sequential(*resnet_block(128, 256, 2)) b5 = nn.Sequential(*resnet_block(256, 512, 2))收尾:全局平均汇聚 + 全连接输出
与 GoogLeNet 一样,最后接一个全局平均汇聚层,将每个特征图压缩为单值,再接全连接层输出 10 类预测。
PyTorch 版完整网络:
net = nn.Sequential(b1, b2, b3, b4, b5, nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(512, 10))MXNet 版:
net.add(nn.GlobalAvgPool2D(), nn.Dense(10))TensorFlow 版将整个网络封装为net()函数返回的tf.keras.Sequential(便于日后在tf.distribute.MirroredStrategy作用域内复用以利用 GPU 等计算资源;尽管此前已创建 b1~b5,函数作用域内会重新创建它们)。PaddlePaddle 版与 PyTorch 版结构一致。
为什么叫 ResNet-18?
每个模块含 4 个卷积层(不包括用于恒等映射的 $1\times 1$ 卷积),加上第一个 $7\times 7$ 卷积层和最后一个全连接层,共 $4 \times 4 + 1 + 1 = 18$ 层,因此称为ResNet-18。通过配置不同的通道数与每个模块的残差块数量,可以生成不同的 ResNet 变体,例如含 152 层的 ResNet-152。虽然 ResNet 的主体架构与 GoogLeNet 类似,但结构更简单、修改更方便,这些因素促成了 ResNet 的迅速普及。img/resnet18.svg 给出了完整的 ResNet-18 架构示意。
逐层打印输入形状:观察特征图如何收缩
训练之前,先观察输入形状在各模块间的变化——与以往所有架构一致,分辨率逐步降低、通道数逐步增加,直到全局平均汇聚层聚合所有特征。
PyTorch 版(输入为单通道224×224):
X = torch.rand(size=(1, 1, 224, 224)) for layer in net: X = layer(X) print(layer.__class__.__name__, 'output shape:\t', X.shape)MXNet 版与TensorFlow 版(输入(1, 224, 224, 1))的验证代码也包含在原文档中,此处不再重复列出。可以预期:经过 b1 的 $7\times 7$ 卷积(步幅 2)与 $3\times 3$ 最大汇聚(步幅 2)后尺寸降为 $56\times 56$;随后 b2~b5 每经过一个模块,通道数依次翻倍(64→128→256→512),空间尺寸依次减半(56→28→14→7);最终AdaptiveAvgPool2d((1, 1))将每个通道压缩为 $1\times 1$,nn.Linear(512, 10)输出 10 类 logits。
在 Fashion-MNIST 上训练 ResNet-18
与本书前面各章一致,使用 Fashion-MNIST 数据集训练(可通过d2l.load_data_fashion_mnist加载,其实现位于 d2l/torch.py 等各后端模块,支持resize参数把图片缩放后再送入网络):
#@tab all lr, num_epochs, batch_size = 0.05, 10, 256 train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96) d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr)这里train_ch6是本书第 6 章定义的通用训练函数(见 d2l/torch.py、d2l/mxnet.py、d2l/tensorflow.py),它负责 Xavier 初始化、SGD 优化器、交叉熵损失、每轮在训练集上更新参数并在测试集上评估精度。要点如下:
- 超参数:学习率
lr=0.05,训练num_epochs=10轮,batch_size=256;图片经resize=96缩放后送入网络(原始 Fashion-MNIST 图片为 $28\times 28$ 单通道); - 初始化:PyTorch 版在
train_ch6内对所有Linear与Conv2d层执行xavier_uniform_初始化(d2l/torch.py),MXNet 版使用init.Xavier()(d2l/mxnet.py); - 设备:中文版训练代码额外传入
d2l.try_gpu(),将模型与数据迁移到 GPU 上加速(TensorFlow 版则通过tf.distribute.OneDeviceStrategy实现,见 d2l/tensorflow.py)。
小结
- 学习嵌套函数类(nested function classes)是训练神经网络的理想情形;在深层网络中,应尽量让新增层容易学习成恒等映射(尽管这是极端情况)。
- 残差映射比原始映射更容易学习,例如把权重层参数推至近似 0 即可逼近恒等函数。
- 利用残差块可以训练出有效的深层网络:输入可通过跨层的残差连接更快地向前传播。
- ResNet 对后续深度神经网络(无论卷积类还是序列类)的设计产生了深远影响。
练习与延伸
- 对比 chapter_convolutional-modern/googlenet.md 中的 Inception 块与残差块的主要区别;在删除 Inception 块的某些路径后,两者如何关联?
- 参考 ResNet 论文(见 d2l.bib 中
He.Zhang.Ren.ea.2016条目)表 1,实现 ResNet 的不同深度变体。 - 对更深的网络,ResNet 引入了 "bottleneck" 瓶颈架构以降低模型复杂度,尝试实现它。
- 在 ResNet 的后续版本中,作者将"卷积→批量规范化→激活"的顺序改为"批量规范化→激活→卷积",请自行验证这一改进的影响。
- 思考题:即使函数类是嵌套的,为什么我们仍然不能无限制地增加函数复杂度?
本文内容以 chapter_convolutional-modern/resnet_origin.md(及中文版 chapter_convolutional-modern/resnet.md)为骨架,结合 d2l 工具模块中的Residual类与train_ch6训练函数源码展开。读者可直接对照仓库中的代码单元运行验证,进一步可阅读同章 vgg.md 与 googlenet.md 对比各经典 CNN 架构的设计取舍。
- 人工智能
- 深度学习
- 机器学习
- 教程
【免费下载链接】d2l-zh
《动手学深度学习》:面向中文读者、能运行、可讨论。中英文版被70多个国家的500多所大学用于教学。
相关推荐
动手学深度学习(d2l-zh)残差网络(ResNet)精讲:从残差块原理到 ResNet-18 四框架实战
动手学深度学习(d2l zh)残差网络(ResNet)精讲:从残差块原理到 ResNet 18 四框架实战 导读 本文围绕《动手学深度学习》(d2l zh)仓库
人工智能深度学习机器学习教程深度残差网络(ResNet)原理与实现详解
深度残差网络 ResNet 原理与实现详解 深度残差网络 ResNet 是深度学习发展历程中具有里程碑意义的架构,它通过引入残差连接 residual conn
人工智能深度学习机器学习教程深度残差网络(ResNet)原理与Keras实现详解
深度残差网络 ResNet 原理与Keras实现详解 1. 深度神经网络的梯度消失问题 在深度学习领域,网络深度对于模型性能至关重要。理论上,更深的网络可以表示
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考