文献精读这个系列走到第六篇,终于轮到ResNet。说实话,我一直有点抗拒写它,因为写的人太多了,网上从论文翻译到代码复现比比皆是。但精读系列如果绕过它,后面的路全都不好走。ResNet全称《Deep Residual Learning for Image Recognition》,2015年横空出世,直接把视觉模型可训练的深度从几十层拉到了一百多层,ImageNet分类错误率一路压到3.5%上下。对搞深度学习的人来说,不管你做CV、做NLP还是做语音,残差连接这个概念迟早会遇到。这篇文章适合刚读完基础网络的学生,也适合想系统补课、准备复现经典论文的工程师。这篇精读不会只复述公式,而是从设计动机、架构细节、实验证据到复现坑点,尽量把“为什么”讲透。
1. 为什么精读序列到了这里,必须绕不开这个“残差”网络
1.1 在它出现之前,深层网络到底卡在哪里
2012年之后,卷积神经网络其实已经展现出很强的能力,但大家很快撞上一个天花板:网络一旦加深,训练就变得非常不稳定。刚开始人们归因于梯度消失和梯度爆炸。梯度经过链式法则一层层相乘,激活值小于1就一直缩,大于1就一直涨,几十层之后要么变成0要么变成无穷。这个阶段有一些缓解办法,比如合理的初始化、梯度裁剪、中间层归一化,但治标不治本。
真正让人头疼的是一个更隐蔽的现象:网络加深之后,训练误差不降反升。论文里做了一个很直观的实验,在CIFAR-10上用20层和56层的普通网络对照,结果56层的训练误差比20层还高。注意,这是训练误差,不是测试误差,所以问题不是过拟合,而是深层网络的优化本身变得极其困难。你可以把它理解成一座山,表层越深,地形越复杂,梯度下降根本找不到靠谱的下山路径。这个现象被称为“退化问题”(degradation problem),在ResNet之前几乎没有好办法。
1.2 一篇论文同时干成了三件事
ResNet能成为经典,不是因为它只提出了一个花哨的trick,而是同时完成了三件相互关联的事。
第一,它提出了残差学习框架。与其让堆叠的网络层直接学习目标映射,不如学习目标映射与输入的差值。这个改动看似只是数学形式上的变化,却把优化目标从“学习一个复杂非线性映射”变成了“在恒等映射附近做微调”,难度完全不一样。
第二,它引入了快捷连接(shortcut connection),实现恒等映射的跨层直连。这个连接不引入额外参数、不增加计算量,却给梯度回传打开了一条高速公路。
第三,它用实验证明超深网络是可以被训练的。152层的ResNet在ImageNet上拿到当年最好的成绩,而且计算复杂度比VGG-19还低。随后,残差思想迅速扩散到语义分割、目标检测、姿态估计,再到NLP领域的Transformer,几乎所有后续模型的骨架里都能找到它的影子。这也是为什么我坚持把它放进精读序列——它不仅是视觉领域的里程碑,更是整个深度学习架构设计的一个分水岭。
2. 残差思想拆解:一个公式和一个“跳过连接”的哲学
2.1 从“直接学映射”到“学残差”,差别比你想象的大
假设我们希望堆叠的几层网络去逼近某个底层映射 H(x),传统做法是让这些层直接拟合 H(x)。ResNet的做法是让它们去拟合 F(x) = H(x) - x,然后把原始映射改写成 H(x) = F(x) + x。也就是说,网络中每一层的输出不再是纯粹由当前层计算出来的新特征,而是“输入特征加上当前层的计算增量”。
为什么要这样绕一下?我给你一个生活化的类比。假设你要预测一个人的年龄,已知他去年是99岁,今年大概率还是100岁附近。如果让模型直接预测“他今年100岁”,它得记忆一大堆关于100岁长什么样的复杂模式;但如果让模型预测“他比去年大1岁”,那它只需要学到一个很简单的增量关系。当输入和输出本身已经非常接近时,学习“差值”比学习“绝对值”容易得多。
放到神经网络里也一样。如果一个残差块已经接近最优,那么 F(x) 会逼近0,网络只需要把这一层的权重压到接近于0,就能实现恒等映射。相比之下,让一堆非线性层硬生生去拟合一模一样的映射,这个操作对参数的精度要求极高,极难优化。所以残差结构不是在数学上改变了什么万能表达力,而是把优化问题变得友好。
2.2 恒等快捷连接:为什么“什么都不做”反而最有用
残差块里的快捷连接就是一条从输入直接加到输出的通路。论文中有个细节值得反复品味:作者比较了三种捷径实现——恒等映射、零填充补齐维度、1x1卷积投影,结果恒等映射的效果最好。
这看起来反直觉,因为 1x1 投影好歹多了一些可学习参数,容量更大,为什么反而打不过“什么都不做”?原因在于,额外的参数会把信息流带偏。投影捷径的本质是在跨层相加之前对输入做一次变换,即使这个变换很小,也会给梯度回传增加一层干扰。恒等捷径则像一条完全透明的管道,让梯度可以无损、无阻碍地流回浅层。论文里有一句很关键的话:如果 shortcut 是恒等映射,那么梯度可以直接从深层传到浅层,这也解释了为什么残差网络比普通网络更容易优化。
很多初学者第一次看到残差块时,会把它和更早的“高速公路网络”联系起来。高速公路网络同样有跨层连接,但里面加了门控机制去控制信息流量。ResNet的贡献在于证明了一个更简单的结论:门控不是必需的,甚至门控可能带来冗余和优化困难。这种“少即是多”的设计哲学,后来反复出现在各种优秀网络中。
2.3 两种残差块:Basic Block 和 Bottleneck
ResNet里其实有两种残差块,很多人看代码时没注意,导致复现时对不上参数量。
第一种叫 Basic Block,用于34层及以下的网络。它由两个 3x3 卷积组成,通道数前后不变,输入通过恒等捷径直接加到第二个卷积的输出上。结构简单,计算直接。
第二种叫 Bottleneck,用于50层及以上的网络。它由三个卷积组成:1x1 卷积先把通道数降下来,中间接一个 3x3 卷积,最后再用 1x1 卷积把通道数升回去。中间的 3x3 卷积在低维空间工作,整体计算量大幅下降。这里捷径连接不是跨两层,而是跨三层。举个具体例子,在一个输入通道256、输出通道256的 Bottleneck 里,计算量大概是 Basic Block 的 1/4 左右,但表达深度更强。
| 残差块类型 | 结构 | 适用网络 | 特点 |
|---|---|---|---|
| Basic Block | 3x3 -> 3x3 | ResNet-18/34 | 简单直接,适合浅层 |
| Bottleneck | 1x1 -> 3x3 -> 1x1 | ResNet-50/101/152 | 降维计算,适合深层 |
这个设计带出一个重要经验:当你把网络做深时,不能只无脑叠加卷积,还要考虑每一层的计算密度是不是合理。Bottleneck 用 1x1 卷积控制维度,让 3x3 卷积专注空间特征,这个“先降维、后升维”的思路后来被大量轻量化网络继承。
3. 网络结构与参数:从18层到152层到底改了什么
3.1 四种经典配置的对比
ResNet论文里给出了一张非常出名的配置表,把网络从18层到152层排了四种方案。它们遵循同一个设计范式,区别只在于每个阶段堆叠的残差块数量不同。
整个网络可以看成五个大模块:开头一个 7x7 卷积加最大池化做快速降采样,然后依次是四个卷积阶段(论文里叫 conv2_x 到 conv5_x),每个阶段的通道数从64、128、256到512翻倍增长,最后接全局平均池化和全连接输出。
我把核心配置整理一下:
| 网络 | conv2_x | conv3_x | conv4_x | conv5_x | 参数量约 | FLOPs约 |
|---|---|---|---|---|---|---|
| ResNet-18 | 2个Basic | 2个Basic | 2个Basic | 2个Basic | 11M | 1.8B |
| ResNet-34 | 3个Basic | 4个Basic | 6个Basic | 3个Basic | 21M | 3.6B |
| ResNet-50 | 3个Bottleneck | 4个Bottleneck | 6个Bottleneck | 3个Bottleneck | 25M | 3.8B |
| ResNet-101 | 3个Bottleneck | 4个Bottleneck | 23个Bottleneck | 3个Bottleneck | 44M | 7.6B |
| ResNet-152 | 3个Bottleneck | 8个Bottleneck | 36个Bottleneck | 3个Bottleneck | 60M | 11.3B |
注意几个细节:第一,conv2_x 的第一个残差块通常需要处理输入通道变化,所以捷径上要做一个下采样;第二,每个阶段的下一次降采样是通过残差块里的 stride=2 完成的,不是通过池化层;第三,输出尺寸从 224 降到 7,靠的是这些 stride 卷积逐步实现的。
3.2 输入预处理与全局平均池化
ResNet在ImageNet上的输入是 224x224 的RGB图像。网络第一层是 7x7 卷积,步长为2,输出通道64,紧接着是 3x3 最大池化,步长也是2。这一套组合拳把图像从 224 快速压到 56,保留了高频边缘和纹理,同时压缩了空间冗余。
这里有个经常被忽略的设计:ResNet没有用全连接层堆在最后,而是用全局平均池化把每个通道的空间维度压成1x1,再接一个1000维的全连接分类。全局平均池化的好处非常直接——它把最后一个卷积层的每个通道当成一个“类别响应图”,取平均得到该类别对应的响应强度,参数量几乎为零,还能天然缓解过拟合。对比VGG结尾那三个巨大的全连接层,ResNet在参数效率上确实高了一个档次。
3.3 用手写代码串一遍残差块的前向逻辑
读论文光靠看结构图容易飘,最好的方式是自己写一个简化版。我用PyTorch风格写一个 Basic Block 和完整的 ResNet-18 骨架,方便你看清捷径到底加在哪里。
import torch import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, 3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) # 捷径需要调整维度或者降采样时,用 1x1 卷积处理 if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) else: self.shortcut = nn.Identity() def forward(self, x): identity = self.shortcut(x) out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) out += identity # 残差相加:F(x) + x out = self.relu(out) return out class SimpleResNet(nn.Module): def __init__(self, block, num_classes=10): super().__init__() self.conv1 = nn.Conv2d(3, 64, 7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(3, stride=2, padding=1) self.layer1 = self._make_layer(block, 64, 64, 2, stride=1) self.layer2 = self._make_layer(block, 64, 128, 2, stride=2) self.layer3 = self._make_layer(block, 128, 256, 2, stride=2) self.layer4 = self._make_layer(block, 256, 512, 2, stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512, num_classes) def _make_layer(self, block, in_ch, out_ch, blocks, stride): layers = [] layers.append(block(in_ch, out_ch, stride)) for _ in range(1, blocks): layers.append(block(out_ch, out_ch, 1)) return nn.Sequential(*layers) def forward(self, x): x = self.conv1(x) x = self.bn1(x) x = self.relu(x) x = self.maxpool(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = self.fc(x) return x代码里最关键的一点是out += identity这一行。它把输入直接加到卷积输出上,再做ReLU。这个顺序也就是所谓的“后激活”结构。后来很多变体把ReLU挪到残差相加之前,效果更好,但那是后话。你在自己实现时,务必确认捷径的维度处理和这个加法位置,这两处最容易和原论文对不上。
4. 实验结果解读:论文是用哪几个实验说服全世界的
4.1 ImageNet上的核心对比:退化问题的实证
ResNet论文最有说服力的部分,就是它老老实实把“普通网络”和“残差网络”放在同一条件下对比。在ImageNet上,34层的普通网络训练误差比18层还高,而且差距不小。这就是退化问题的直接现场。换成残差网络之后,同样的34层结构训练误差显著下降,并且在测试集上也把top-1错误率从普通34层的约8.8%压到了约6.7%左右。
这个对比告诉我们一个关键事实:同样深度的网络,参数量接近,计算量接近,唯一的区别就是有没有残差连接,结果却天差地别。换句话说,ResNet带来的提升不来自“更多参数”,而来自“更好的优化条件”。这也是为什么残差结构后来能泛化到那么多不同任务里的根本原因——它是一个优化层面的改进,而不是单纯的模型容量扩充。
4.2 CIFAR-10上的消融实验:谁的捷径更好
为了把问题研究得更干净,作者在CIFAR-10上做了一组非常细致的消融实验。他们把网络设计成不使用全连接层的全卷积结构,输入图像是32x32,然后对比不同深度(20层、32层、44层、56层、110层)的残差网络表现。
另外一个细节是捷径设计的比较。论文里明确对比了三种方案:恒等捷径、维度变化时用投影捷径、所有捷径都用投影。结果恒等捷径效果最好,投影反而略差。作者给的解释是投影捷径引入了额外参数,但带来的信息增益没有超过它造成的优化干扰。这种“用最简方案拿最好结果”的结论,很有工程美感。
4.3 1202层实验的意义:过拟合而非退化
论文里还有一个容易被人忽略的实验:作者硬生生把网络堆到了1202层,在CIFAR-10上训练。结果训练误差比110层更低,说明深层网络依然能够继续拟合训练数据;但测试误差比110层略高。这个结果极其重要,因为它证明了当网络足够深时,残差结构已经把优化问题解决到位,剩下的瓶颈变成了过拟合。
这个实验相当于是给“深=好”的信念做了一次压力测试。1202层在今天看来依然很恐怖,要知道当时主流模型都是几十层,能训练到一千层以上本身就是一个极强的信号。它让整个社区意识到,只要优化问题可控,网络深度还有很大的探索空间。后来各种几千层的变体、以及各种超深Transformer,底气都来自于这里。
5. 训练细节与复现避坑:论文里没明说的潜规则
5.1 超参数与训练策略清单
很多复现ResNet翻车的人,不是没读懂结构,而是忽略了训练配置。论文在ImageNet上的训练配置可以整理成一份清单:
- 优化器:SGD,momentum=0.9,weight decay=1e-4
- 初始学习率:0.1,每30个epoch除以10,总共训练约90个epoch(原论文在Caffe实现里跑了60万次迭代,对应不同epoch策略,但节奏类似)
- Batch size:256左右
- 数据增强:随机裁剪、随机水平翻转、缩放增强(scale augmentation)和色彩扰动;没有用复杂的光学畸变
- 权重初始化:采用较简单的高斯初始化配合BN
注意一个细节:ResNet论文发布时,专门针对ReLU网络的“Kaiming初始化”是后续跟着出现的,也就是说原论文里的训练策略在当时并不算特别精细,但它依然能稳定收敛到很好效果。这反过来验证了残差结构对初始化的容忍度比普通网络高很多。
5.2 残差块里的下采样位置:一个非常容易错的点
当特征图尺寸需要减半时,比如从 56x56 降到 28x28,ResNet不是单独用一个下采样层,而是把 stride=2 放进了残差块里的卷积。Basic Block 里 stride=2 放在第一个 3x3 卷积上;Bottleneck 里 stride=2 放在中间的 3x3 卷积上。如果你把 stride 放错了位置,比如放在 1x1 卷积上,空间信息会过早被压缩,精度会出现可感知的下降。
另外,捷径也要同步做维度匹配。一种办法是用 1x1 卷积加 stride=2 做投影下采样,另一种是论文里强调的更推荐做法:在需要匹配维度时,用 1x1 卷积调整通道,但尽量保持恒等思想,能不投影就不投影。很多代码库为了简洁,会在每个阶段的第一个残差块无条件使用 1x1 捷径,其实这样会在某些层引入不必要的参数扰动。
5.3 复现时最容易翻车的三个魔鬼细节
第一个是BN层的使用。训练和测试时BN的行为不一样。训练时用当前batch的均值和方差,测试时用训练阶段累计的全局统计量。如果你在验证时不切换到eval()模式,或者多卡训练时没有同步BN统计量,小batch下测试结果会抖动。这在复现ResNet时尤其明显,因为残差结构对BN的依赖比VGG更重。
第二个是残差加法后人ReLU顺序。原论文是out += identity之后再做ReLU。但如果你读的是后续变体或者某些框架的官方实现,可能会看到先ReLU再加、加法后不激活等不同写法。复现原论文结果时,请先确认自己用的代码是哪个变体,否则你调出来的曲线会和论文对不上。
第三个是weight decay对深层网络特别敏感。很多人直接把别的任务里的1e-4搬过来,发现训不上去甚至直接发散。我个人的经验是,在数据量不那么大的任务里,weight decay调到5e-5左右往往更稳。另外,全连接层和卷积层的weight decay可以分开设置,这个技巧在精调和迁移学习里非常实用。
6. 读完之后再看:ResNet给后续研究埋了多少伏笔
6.1 它启发了哪些新的网络范式
残差思想最直接的传人是ResNeXt。ResNeXt在残差块内部引入“分组卷积”,把单一路径拆成多条并行路径,也就是所谓的cardinality。这个设计和残差结构的组合,让模型在不增加深度的情况下进一步提升了表达力。DenseNet则是把残差连接的“加法”变成“拼接”,让每一层都能直接看到前面所有层的特征,相当于把残差思想发挥到了极致。MobileNet系列里的倒残差结构,也依然保留着“先扩展、再卷积、再压缩”的Bottleneck痕迹。就连Transformer里的Add&Norm层,本质上也还是残差连接。
你往后读论文时会发现,很多新结构其实都是在回答ResNet留下的问题:残差连接为什么有效?可不可以不用残差?怎么让梯度流动得更顺畅?这些问题到今天依然没有完全关闭。
6.2 为什么残差结构能缓解优化困难:一种直观理解
从反向传播的角度看,残差连接让损失对浅层输入的梯度多了一条直接的通路。如果不经过残差连接,梯度要逐层穿过卷积、BN、ReLU,折磨一圈之后可能已经衰减得不成样子。有了捷径,梯度可以“抄近道”,即使深层网络的非线性层梯度很小,浅层依然能拿到有效的学习信号。
从函数空间的角度看,残差块像是在恒等映射附近加了一个小扰动。这意味着网络在初始化时,整个模型至少不会退化成无意义的随机映射,而是从一个“差别不大”的状态出发学习。这种“初始即在最优附近”的属性,让SGD更容易找到好的解。有意思的是,后来很多研究试图去掉残差连接、用更复杂的归一化手段替代,结果都发现残差连接几乎不可替代。它已经成为现代深度学习架构里默认的基础设施。
6.3 从这一篇能学到的论文精读方法
精读论文不是把每个公式抄一遍就结束。我的做法是四步走:第一,先不看实验,只看摘要和结论,用自己的话复述这篇论文的贡献;第二,画一张结构图,把网络的数据流和梯度流标出来,直到你能不看原图把网络复现出来;第三,去代码库亲自跑一遍,改几个关键变量(比如去掉捷径、换投影捷径),观察精度变化;第四,带着怀疑态度回去重读论文,重点看作者怎么解释实验结果、有没有未回答的问题。
用这个方法回看ResNet,你会发现论文里很多看似随意的设计其实都经过了实验筛选。比如捷径设计、残差块层数、深层网络的学习率策略,处处都有对比依据。这种“每个决定都有数据支撑”的做法,本身就是值得学习的研究习惯。
6.4 一个必须澄清的常见误区
最后想澄清一个高频误解:很多人说ResNet解决了梯度消失,所以网络能加深。这个说法不完全准确。梯度消失确实是深层网络难训的原因之一,但ResNet论文重点讨论的退化问题,是在梯度消失已经被BN和合理初始化缓解之后仍然存在的现象。残差结构更多是改善了优化的条件,让深层网络的损失曲面不再那么崎岖,而不是简单地把梯度“放大”了。理解了这一点,你就能明白为什么残差网络即使很宽、很多分支,依然能保持训练稳定。
我个人在实际复现ResNet时体会最深的一点是:读论文不能只看结论,一定要亲手把网络跑起来,然后故意去掉捷径或者调换BM位置看看会发生什么。只有看到训练曲线真的变差,你才真正理解残差连接解决了什么问题。这篇论文值得你花一个完整的下午去精读、复现和改实验,它带给你的不只是几个网络结构,更是一整套诊断深度学习优化问题的思路。