首页 / 资讯中心 / 文章详情

ResNet残差网络深度解析:从原理到PyTorch实战

ResNet残差网络深度解析:从原理到PyTorch实战 ★ FEATURED ARTICLE
先说个有意思的事前几天有人让我帮忙整理一份某深度学习框架的“ResNet中文翻译”我刚开始以为就是老老实实把论文里的句子翻成人话结果越翻越发现真正难的不是英文而是“残差”这两个字到底该怎么让一个没接触过神经网络的人听懂。ResNet这个名字现在基本是计算机视觉领域的标配关键词了做图像分类、目标检测、语义分割十个模型里八个都有它的影子。但如果你只看那些译文和解读很容易陷入一种“每个字都认识、连起来不知道在说啥”的状态。这篇东西我想换个方式聊不照搬论文结构而是站在一个工程师的角度把ResNet从名字到原理到代码到实战里那些坑一层一层拆开讲明白。不管是刚入门的新手还是被各种博客绕晕的进阶选手应该都能从中找到自己能用的东西。1. 先把名字翻明白残差网络到底在说什么1.1 名字拆开看“残差”和“网络”分别指什么先说“网络”这个好理解就是指神经网络一堆带参数的层叠在一起输入图片进去输出预测结果。ResNet的全称残差网络重点在那个“残差”。残差这个词在数学里很直白真实值和估计值之间的差距。比如我说这瓶水有500毫升实际量出来是480毫升那20毫升就是残差。在深度学习的语境里残差网络的意思就是让网络去学这个“差距”而不是直接学那个“结果”。这看起来只是一个小小的思路转变但背后的逻辑非常关键。假设你想让网络学会一个理想映射 H(x)传统做法是让网络层直接拟合 H(x)也就是把输入 x 变成 H(x) 这个结果。残差网络的做法是让网络层去拟合 F(x) H(x) - x然后最终的输出变成 F(x) x。换句话说网络不需要从零开始硬记整个映射只需要在输入的基础上做一点修正就够了。1.2 一个生活化的类比改稿子而不是从头写稿子我特别喜欢用一个改稿子的比喻来解释这个事。想象你要让实习生把一篇技术文档改得更通俗两种做法差别很大。第一种做法是让他完全不看原文凭自己对主题的理解凭空写一篇新文档出来。这个难度极高因为他要把握整体结构、术语准确性、表达连贯性任何一个环节出问题写出来的东西可能还不如原文。第二种做法是给他原文让他只把晦涩的地方标出来改写成更易懂的说法其余部分原样保留。这个任务难度就低多了因为大部分内容不用动他只需要针对“差异部分”做调整。传统深层网络是第一种做法层数越深它要凭空学的东西越多最后反而学不动。残差网络是第二种做法它保留了原始输入的信息只学那个“改动量”任务简单了网络层数也就可以堆得很深。这也是为什么ResNet能做出152层甚至更深的版本而更早的网络几十层就开始出各种问题。2. 为什么要做残差从退化问题说起2.1 网络越深就一定越好吗——退化现象给我的第一记闷棍在我刚开始接触深度学习的时候流行一个直觉网络越深能力越强效果越好。毕竟层多了参数多了能表达的函数范围更广。这个直觉在浅层到中层阶段基本是对的但到了某一个深度之后事情就开始不对劲了。有个现象叫退化问题。简单来说把网络从20层加到56层按理说效果应该更好但实际训练出来的准确率反而下降了。而且最诡异的是56层网络的训练误差本身就比20层更高。这排除了过拟合的可能因为过拟合的表现是训练误差低、测试误差高现在是训练阶段就已经输了。我当时第一反应是是不是梯度消失导致前面那些层根本没学到东西这个猜测对了一半。网络加深之后梯度在反向传播过程中需要连乘很多层如果权重初始值比较小梯度会指数级衰减传到前面几层的时候几乎等于没有前面那些层就成了摆设。但残差网络给了另一个视角即使梯度问题通过归一化等手段缓解了深层网络在优化上仍然面临巨大的困难。让几十层网络直接拟合一个复杂映射这个最优化问题的难度本身很高网络很容易陷入一个糟糕的局部最优。而残差结构相当于把一个困难问题改造成一个相对容易的问题网络只需要学变化量优化起来自然就顺畅得多。2.2 为什么“跳过连接”能保住信息不丢失残差块的核心结构可以用一句话概括在两层或三层的输出上加一条从输入直接连过来的捷径专业说法叫跳过连接或者捷径连接。设输入是 x经过若干卷积层后得到一个输出 F(x)然后这个输出和输入 x 相加得到最终输出 y F(x) x。这条捷径不携带任何参数就是一个恒等映射。这个设计解决了两个问题。第一个是信息传递问题梯度在反向传播时有了这条捷径可以直接从后面的层流回前面的层不会因为中间堆了太多卷积层而衰减殆尽。第二个是优化问题如果某个层的功能已经足够好网络可以通过把 F(x) 的权重压到接近零让输出退化成恒等映射,这样深层网络的效果至少不会比浅层差。实践层面这条捷径还有一个隐藏好处实现起来几乎不增加任何计算量和参数量。它只是一个张量的加法操作在推理速度上和没有残差的网络相差无几但训练稳定性和最终精度提升明显。这也是ResNet能快速取代当时的各种深层网络方案成为社区默认选型的重要原因之一。3. 结构拆解从基础块到整体骨架3.1 BasicBlock 和 Bottleneck 到底该怎么选ResNet里最常用的残差块有两种它们的名字第一次接触的人很容易混淆BasicBlock和Bottleneck。BasicBlock是基础版本结构是两层3x3卷积中间夹一个批归一化和激活函数。它适合层数不太深的网络比如ResNet-18和ResNet-34。这种结构简单直接参数量不大在小尺寸输入上训练速度快适合快速验证想法。Bottleneck则用于ResNet-50及以上的深层网络。它的结构是三层的先一个1x1卷积把通道数降下来再一个3x3卷积做空间特征提取最后再一个1x1卷积把通道数升回去。整个结构的形状像瓶颈两头宽中间窄所以叫这个名字。为什么要这样做深层网络如果直接用BasicBlock参数量和计算量会爆炸。Bottleneck先用1x1卷积降维把3x3卷积的输入通道数大幅减少计算量能控制住最后再用1x1卷积恢复维度。比如一个输入256通道的特征图如果用BasicBlock两层3x3卷积都是在256通道上做运算而用Bottleneck先把256降到643x3卷积在64通道上做最后再升回256总计算量小很多还能把网络做得更深。在实际选型上我的经验是如果是做基础实验、数据量不大或者追求推理速度ResNet-18和ResNet-34足够如果数据量大、任务复杂比如大规模图像分类或检测任务的主干网络直接上ResNet-50或更深版本。两者的差异不只是参数数量更重要的是深层网络的特征表达能力。3.2 五种常见ResNet骨架的参数与场景对比很多框架里都把ResNet封装成了现成的接口可以直接通过参数指定深度。我用得比较多的几个版本是18、34、50、101、152。它们之间的差异本质上就是残差块数量、通道数和总层数的排列组合。先看一张我整理过很多次的对照表模型残差块类型主要构成按阶段大概参数量典型适用场景ResNet-18BasicBlock[2, 2, 2, 2]约11M小数据集、移动端、快速验证ResNet-34BasicBlock[3, 4, 6, 3]约21M中等数据集、特征提取主干ResNet-50Bottleneck[3, 4, 6, 3]约25M大规模分类、检测、分割主干ResNet-101Bottleneck[3, 4, 23, 3]约44M高精度要求、复杂任务ResNet-152Bottleneck[3, 8, 36, 3]约60M顶级精度竞赛、计算资源充足每一列里的数字代表每个阶段堆叠的残差块数量比如ResNet-50的[3,4,6,3]意思就是四个阶段分别有3、4、6、3个Bottleneck块。阶段之间通过一个步长为2的卷积或池化来降低特征图尺寸、增加通道数。这里有一个很容易踩的坑不同框架里ResNet的微调参数名经常对不上。比如PyTorch官方实现里每个Bottleneck通常有卷积1、批归一化1、卷积2、批归一化2这套命名而某些第三方实现的命名习惯完全不同。你要是直接拿官方预训练权重去加载第三方代码构建的模型十有八九会遇到尺寸不匹配。解决方案是先打印模型的state_dict核对每一层的名称和形状再决定是修改权重还是修改代码。4. 从零实现一个ResNet并跑通训练4.1 用PyTorch手写残差块核心就这几步如果只是调用现成接口很多东西会被封装在底层一旦出了奇怪的问题很难排查。我建议每个想理解ResNet的人都亲手写一遍代码哪怕只是在草稿纸上画逻辑也行。下面是一个最小可运行的PyTorch实现思路。先定义BasicBlock的forward过程。输入x进来经过第一个卷积、批归一化、ReLU激活再经过第二个卷积和批归一化得到F(x)。然后判断跳过连接是否需要处理通道数或尺寸如果需要就加一个1x1卷积调整x最后把F(x)和x相加再过一次ReLU。import torch import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels: self.shortcut nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels) ) 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 out identity out self.relu(out) return out这个实现的几个细节值得说明。卷积的bias设置为False是因为后面紧跟了批归一化批归一化本身带有可学习的平移参数卷积层的偏置是冗余的。shortcut分支在输入输出形状一致时是个空模块forward里identity直接等于x只有在尺寸或通道变化时才真正进行卷积。所有卷积使用padding1保证3x3卷积不会改变特征图的空间尺寸。Bottleneck的写法类似只是中间多了1x1卷积和3x3卷积通道数先缩小再放大。代码里我习惯把三个卷积命名为conv1、conv2、conv3对应的批归一化命名为bn1、bn2、bn3这样和PyTorch官方实现里预训练权重的命名保持一致加载权重时不用做额外的映射。4.2 训练参数设置与实测心得模型搭好之后训练参数的选择同样关键。我用小规模图片数据集做过很多次实验下面这组参数是反复调出来的起手式。输入图片统一缩放到224x224如果原图尺寸不统一这一步容易成为性能瓶颈。优化器我常用带动量的SGD初始学习率0.1动量0.9权重衰减1e-4。学习率调整策略用余弦退火或者每隔30个epoch手动降为原来的0.1这两种方式在小数据集上效果都还不错。批大小需要根据显存来定我实验时常用64到128。如果显存不够优先减小批大小而不是降低图片分辨率因为分辨率改变会影响模型对物体尺度的感知。有一个经常被忽略的细节PyTorch模型默认是训练模式如果你在推理时忘了调用model.eval()批归一化层的统计量仍然在更新输出结果会有明显偏差。我在刚开始跑实验的时候踩过这个坑结果一个训练好的模型在测试集上表现极其不稳定排查了半天才发现是推理模式问题。经过大约50个epoch的训练小规模数据集上的准确率能稳定在一个不错的水平。刚开始的10个epoch里loss下降很快后面逐渐变慢这是正常现象。如果loss在前几个epoch不降反升优先怀疑学习率设置过大或者数据预处理有误。5. 常见问题与排查技巧实录5.1 复现ResNet时最常碰到的五类问题ResNet虽然结构看起来简单但实际复现和训练中遇到的问题一点都不少。我把自己踩过的和一些朋友问过我的问题整理成了一张速查表方便大家按图索骥。问题现象可能原因排查方向训练loss下降到一定程度后震荡学习率偏高降低学习率或用余弦退火测试集效果远差于训练集未切推理模式或数据增强过强调用model.eval()检查增强管线加载预训练权重报尺寸错误模型结构不匹配打印state_dict对比层名显存溢出批大小太大或输入尺寸太大减小批大小检查是否有忘释放的中间变量深层网络loss不下降初始化不当或数据未归一化检查数据分布尝试更换初始化方式还有一个在ResNet系列里比较经典的问题训练时BN层设置不合理导致的batch size敏感。BN层的统计量依赖当前批数据如果批大小太小统计量波动大会严重影响训练稳定性。实际操作中我尽量把批大小保持在32以上实在不行就用累积梯度的方式来模拟更大批大小。5.2 避坑经验分享从模型命名到权重迁移关于权重迁移再说一个非常实用的技巧。加载预训练模型时不要直接用torch.load然后把权重硬塞进去建议先加载到一个临时模型上再逐层复制参数。这样即使某些层名称不一致也能在复制过程中加一层判断哪些层匹配、哪些层不匹配一目了然。另外ResNet可以去掉最后一层全连接当作特征提取器用这个操作在迁移学习里很常见。具体做法是把模型实例化后将model.fc替换为nn.Identity或者直接在前向传播时不用最后一层。输出特征图的维度就是最后一个卷积阶段的输出通道数比如ResNet-50是2048维向量可以直接接到下游分类器或用于距离度量。这些经验看起来琐碎但每一个都能省下大把调试时间。我后来把类似的问题沉淀成了一小份自己的核对清单每次复现新模型之前先对着清单过一遍基本可以避免大半的无效debug。6. 关于“中文翻译”这件事的额外想法既然标题里带了“中文翻译”四个字最后我想多说几句翻译层面的事。ResNet论文里最容易被翻译带偏的概念其实不是“残差”而是“Identity Mapping”和“Shortcut Connection”这两个术语。前者我在前文译成恒等映射强调的是输入输出在理想情况下保持一致后者译成捷径连接强调的是信息不走主干、直接跳过某些层。理解这两个词的侧重点读很多后续论文会顺畅很多。有价值的做法是先读原始英文论文和代码遇到不理解的概念再去看中文解读和翻译。因为翻译过程本身会损失一些信息尤其是“恒等映射”和“退化问题”这类高度概括的术语直译会让人觉得生硬意译又容易偏离原意。只要英文原意清晰中文翻译只是辅助理解的手段而不是唯一依据。我在实际使用中最深的一点体会是不要只把ResNet当成一个网络架构要把它当成一种设计思路。它教会我们的核心思想是当深层网络难以直接优化的时候可以通过添加恒等映射旁路把问题分拆成一个相对容易的子问题。这种思路后来影响了很多模型设计比如各种跨层连接、稠密连接都能看到残差思想的影子。理解了这个层面再看那些衍生模型就有一种“万变不离其宗”的感觉。最后再送一个小技巧如果你想快速验证自己是否真的理解了残差块可以试着把ResNet-18结构里的残差连接全部去掉保持其他部分不变然后训练同样的数据。对比一下两个模型的收敛速度和最终精度这个实验会告诉你残差结构到底带来了什么。这种“做减法”的实验比读十篇博客都更能加深理解。
阅读完成 · 觉得有帮助?
咨询建站