多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

ResNet-18全解析:残差网络原理、PyTorch实现与训练技巧

ResNet-18全解析:残差网络原理、PyTorch实现与训练技巧 ResNet-18是我在实际项目里用得最多的网络没有之一。做图像分类要选个baseline做物体检测要挑backbone做迁移学习要拿个预训练模型最后总会绕回它。这篇文章就按自己的理解从原理到代码到训练细节一次讲透18到底怎么数、残差连接为什么有效、每一层在干什么、PyTorch怎么搭、训练时哪些坑要躲。先说结论给没耐心的人ResNet-18是残差网络家族里最轻量、最容易上手的成员参数量约11.7M百万级能在ImageNet上达到接近72%的top-1准确率而且结构足够简单适合作为理解现代卷积神经网络的起点。接下来我按实际学习顺序展开不堆论文公式尽量用工程视角把每个设计讲明白。1. ResNet-18到底是什么从“18”这个数字说起1.1 网络退化问题为什么要发明残差结构早年间大家有个直觉网络越深表达能力越强效果就越好。但实际上直接把几十层普通卷积网络堆上去训练误差先降后升——注意是“训练误差”变大不是验证集过拟合。这个问题一度让深层网络没法落地。举个生活化例子你让一个实习生去读一本百科全书每天给他加页数结果他连第一遍都读不完不是书变难了是他的学习方法出了问题。网络也是一样梯度在反向传播中一层层相乘层数深了以后梯度要么爆炸要么消失前层参数基本收不到有效更新深层网络实际学到的东西非常有限。有人可能想说BatchNorm不是能缓解吗BN确实能把梯度量级压住但深层网络仍然很难训练。2015年ResNet被提出来后一个非常朴素的改动解决了这个问题不再让网络直接学习目标映射H(x)而是学一个残差F(x)H(x)-x最后用F(x)x作为输出。如果恒等映射已经足够好网络只需要把F(x)学到接近0这比硬生生去拟合H(x)容易得多。这个改动让可以训练的网络深度从十几层推到上百层ImageNet上的分类错误率也跟着掉了一大截。1.2 权重层到底怎么数“18”这个数字很多新手会数错。常见误区是把池化层、ReLU甚至BN也算进去然后发现怎么数都对不上。ResNet系列的层数约定数的是“带权重参数的层”也就是卷积层和全连接层。ResNet-18由这几部分构成一个7×7卷积第1层4个stage每个stage里有2个BasicBlock每个BasicBlock里有2个3×3卷积共4×2×216层最后接一个全连接分类层1层。所以1 16 1 18。BN层里也有可学习的缩放和平移参数但在模型命名时不算层。池化、ReLU这些更不算。顺带说一句ResNet-34就是把每个stage的block数从[2,2,2,2]改成[3,4,6,3]总权重层是1(3463)×2134。ResNet-50之后改用Bottleneck结构计数方式会变但“只数权重层”这条规则是不变的。你只要记住这个数字表示的是“需要被训练的参数层”数量不是网络总深度。2. 残差单元的核心逻辑为什么F(x)x就好使2.1 从拟合目标到残差学习先看数学上的变化。假设我们想学一个理想映射H(x)传统网络直接让卷积堆叠去逼近H(x)。残差网络改为去逼近F(x)H(x)-x最后的输出是F(x)x。为什么这样改会更好至少有两个层面能解释。第一层是优化难度。如果H(x)和x非常接近尤其当恒等映射已经接近最优时直接学H(x)意味着让一堆卷积层去拟合一堆特别接近恒等映射的函数参数更新方向非常微妙。而学残差的话网络只要让权重趋近0F(x)自然趋近0输出就约等于输入x。一个“什么都不做”的模块在传统结构里很难实现因为权重一旦初始化为0或特别小梯度传不回来在残差结构里却很自然。第二层是梯度传播。反向传播公式里多了一条从后面层直达前面的恒等路径。假设损失对某一层的梯度已知下一层的输入等于本层输出加捷径分支那导数里天然带一个“1”项。这个“1”保证深层信号在反向传播时始终原样保留一份即使残差分支梯度很小整体梯度也不会完全消失。层数越深这条“高速公路”越关键。2.2 维度不匹配时恒等短接与投影短接残差相加要求F(x)和x的shape完全一致。在ResNet-18里绝大多数BasicBlock是恒等短接输入输出同尺寸、同通道数直接相加就行。真正麻烦的是每个stage的第一个block——通道数翻倍分辨率减半这时候需要做一次“投影”。投影短接的做法是用一个1×1卷积输出通道等于目标通道数stride设为2让特征图分辨率和主路径对齐然后两个分支相加。1×1卷积本质上就是每个位置做一次线性变换参数量很小不会给网络增加多少负担。注意这个投影分支只在维度变化时才出现ResNet-18一共出现在第2、3、4个stage的第一个block共3处。这里有一个容易被忽略的设计细节作者在做维度变化时把特征图尺寸整倍数减半、通道数整倍数翻倍保持计算量和信息量的平衡。这个“干净”的尺度设计是ResNet另一个高明之处后面做工程复现、做剪枝量化都会方便很多。你在自己设计网络时也可以参考这个思路分辨率变化和通道变化尽量成倍数关系。3. ResNet-18网络结构逐层拆解3.1 从输入到输出的完整数据流以ImageNet标准输入224×224×3为例我把完整流程整理成了一张表。这张表建议保存下来后面无论写代码还是看别人项目对照着看会清楚很多。模块具体操作输出尺寸conv17×7卷积64个卷积核stride2112×112×64bn1 reluBatchNorm ReLU激活112×112×64maxpool3×3最大池化stride256×56×64layer1BasicBlock×264→64stride156×56×64layer2BasicBlock×264→128首个block下采样28×28×128layer3BasicBlock×2128→256首个block下采样14×14×256layer4BasicBlock×2256→512首个block下采样7×7×512global avgpool全局平均池化每个通道取平均值1×1×512fc全连接层512→类别数1000可以看到空间分辨率从224一路降到7通道数从64升到512。最后不直接用Flatten接全连接而是先用全局平均池化把每个通道压成一个数再进分类层。这样做最大的好处是大幅减少全连接层参数量同时让网络对输入尺寸不那么敏感。理论上任意分辨率输入都能经过池化得到固定长度向量这在做检测、分割这类下游任务时特别有用。3.2 BasicBlock内部到底怎么串的BasicBlock是ResNet-18的基本单元内部代码逻辑只有几步但顺序很关键。输入x先过一个3×3卷积stride可能为2然后过BN再过ReLU接着过第二个3×3卷积过BN如果shortcut需要投影先算好shortcut分支把主分支输出和shortcut分支相加最后再做一次ReLU。有一个细节大家实现时特别容易踩坑第二个卷积后的BN输出要先和shortcut相加再一起过ReLU。如果写成“每个卷积后都接ReLU”那就把残差结构加错了位置训练效果会有明显差别。主路径上激活函数的位置直接决定了恒等路径上经过的非线性变换有多少。BN放在卷积之后、激活之前是为了让激活函数的输入保持稳定范围这也是现代CNN里通用做法。3.3 和ResNet家族其他成员怎么选ResNet-18和ResNet-34用的是BasicBlockResNet-50及以上的深层版本用的是Bottleneck结构也就是把两个3×3卷积换成1×1、3×3、1×1三段式先降维再升维控制计算量。从参数量看ResNet-18约11.7MResNet-34约21.8MResNet-50约25.6MResNet-101约44.5M。单看数字18比50少了差不多一半多但性能差距往往没有参数差距那么夸张在简单任务上完全可能持平。怎么选我的经验是数据量小、任务简单、对延迟敏感直接上ResNet-18数据量大、任务本身复杂、硬件允许再考虑50或者101。对刚开始接触这个系列的人来说先手写一个ResNet-18把权重层、下采样、维度变换这些概念吃透再去看Bottleneck就很轻松了。家族设计是一脉相承的18是理解其他成员的钥匙。4. 从零搭建ResNet-18PyTorch代码实操4.1 BasicBlock与ResNet-18完整实现代码我提供一个简洁版本不依赖torchvision适合学习和二次魔改。核心就两个类BasicBlock和ResNet18。import torch import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): expansion 1 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.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 x out self.conv1(x) out self.bn1(out) out F.relu(out) out self.conv2(out) out self.bn2(out) out out self.shortcut(identity) out F.relu(out) return out class ResNet18(nn.Module): def __init__(self, num_classes1000): super().__init__() self.in_channels 64 self.conv1 nn.Conv2d(3, 64, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(64) self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) self.layer1 self._make_layer(64, 2, stride1) self.layer2 self._make_layer(128, 2, stride2) self.layer3 self._make_layer(256, 2, stride2) self.layer4 self._make_layer(512, 2, stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512, num_classes) def _make_layer(self, out_channels, blocks, stride): strides [stride] [1] * (blocks - 1) layers [] for s in strides: layers.append(BasicBlock(self.in_channels, out_channels, strides)) self.in_channels out_channels return nn.Sequential(*layers) def forward(self, x): x self.maxpool(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 torch.flatten(x, 1) x self.fc(x) return x if __name__ __main__: model ResNet18(num_classes1000) x torch.randn(2, 3, 224, 224) y model(x) print(y.shape) # torch.Size([2, 1000]) total sum(p.numel() for p in model.parameters()) print(f参数量: {total / 1e6:.2f}M) # 约11.69M跑通之后你会在终端看到输出shape是[2, 1000]参数总量打印出来就是11.69M左右。代码里两个地方值得特别说明一是所有卷积都设置了biasFalse因为后面紧跟BN层BN里的可学习参数已经能完成平移和缩放卷积再带bias属于冗余二是_make_layer里先处理stride列表第一个block用传入的stride其余用1这是实现下采样的关键理解这段代码就理解了ResNet整个家族的下采样逻辑。4.2 训练配置与超参数建议模型写完能不能训起来很大程度取决于超参数。我整理了两套常用配置第一套偏ImageNet这类大图数据集第二套偏CIFAR-10这类小图数据集两者来源都是学术界和工业界反复验证过的默认值。配置项ImageNet风格CIFAR-10风格输入尺寸224×22432×32batch size256128初始学习率0.10.1学习率调整30/60 epoch衰减0.1100/150 epoch衰减0.1优化器SGD(momentum0.9)SGD(momentum0.9)weight decay1e-45e-4训练轮数90200两个细节说明一下。学习率0.1这个值对应的是batch size 256的情况。如果显存不足把batch降到64lr最好也跟着降简单做法是让lr按batch_size/256线性缩放例如batch64时可以取0.025。weight decay在小数据集上稍微调大一些能明显缓解过拟合。切换任务时不要只盯着epoch数量数据规模变了训练策略基本都得重调。4.3 小尺寸输入的调整技巧直接用224×224训练好的结构和预训练权重搬到CIFAR这种32×32数据上第一层7×7 stride2的卷积作用在32×32图上感受野太大特征信息损失非常严重。我自己的做法是对32×32输入单独建一个变体把conv1改成3×3、stride1、padding1去掉第一个maxpool避免前两层就把分辨率立刻降到8×8甚至更小。conv1改成3×3之后没法直接加载ImageNet预训练权重因为虽然输入通道都是3但卷积核尺寸一个是7×7一个是3×3权重张量对不上。这时候要么重新训练要么只加载后面几层的权重。这个改动在CIFAR-10上ResNet-18精度通常能到93%以上不改的话性能差距非常明显。如果你在别的数据集上发现ResNet-18效果还不如小网络先检查首层设计是不是没适配输入分辨率。5. 使用ResNet-18的常见问题与排查经验5.1 训练loss不下降或直接NaN这是新手最容易撞上的问题。排查顺序我一般是这样先看数据确认输入里没有异常值、标签没有错乱再看学习率ResNet-18用SGD一般从0.1起步但如果改了batch size没同步调lr或者数据量特别小loss很容易横住不动最后看梯度可以在每个step后打印grad_norm数量级在1附近算正常超过100就危险了。如果加了warmup——前几个epoch从0.01线性升到0.1——很多深层模型和大batch训练会稳很多。损失曲线在初始阶段往下走不代表后面就安全BN层统计量在训练早期还没稳定这时候学习率太大同样可能直接炸出NaN。遇到NaN不用先怀疑代码写错按数据、学习率、梯度这个顺序排查大概率能在十分钟内定位。5.2 BN在训练和推理时的割裂BatchNorm在训练时用的是当前batch的均值方差在推理时用的是训练过程中累计的running_mean和running_var。如果训练完忘记调model.eval()或者做推理的代码里一直开着model.train()状态结果就是同样的输入每次跑出来输出都不一样精度忽高忽低。这个坑最常见也最好修只要在推理前加一行model.eval()就能解决。还有一个迁移学习里的经典坑用预训练ResNet-18做微调时如果只冻结backbone但没处理BN层会导致网络行为不稳定。因为卷积权重冻结了BN的统计量还在随当前batch变化而小batch下统计量噪声很大整个推理结果会飘。我在代码里一般会对要冻结的层做逐层检查BN的requires_grad和train/eval状态分开处理这个小细节能省掉很多调试时间。5.3 预训练模型最后一层怎么改从ImageNet预训练模型迁移到自己的分类任务时最后一层fc输出必须换成自己的类别数。替换fc之后新fc层权重是随机初始化的而前面backbone是ImageNet的权重如果直接用同一个学习率训练随机初始化的fc层梯度比backbone大得多很容易把预训练权重冲坏。经验做法是先冻结backbone只训练fc层练几个epoch让分类头先收敛再解锁backbone用较小的学习率整体微调。或者在优化器里给fc层单独设置一个更大的学习率比如设为backbone的10倍。这个技巧在数据量不大时尤其管用能明显提升微调后的最终精度。5.4 部署和加速上的一个判断ResNet-18真正吸引我的地方不只是精度而是它在CPU和低算力设备上也能跑起来。跟ResNet-50相比18的参数量和计算量少了将近一半多但不少任务的精度差距只在个位数以内。在工业场景里如果模型要放边缘设备我通常会先用18跑通完整流程再做INT8量化或者蒸馏。如果精度还不够再考虑更大的模型而不是一上来就上ResNet-50。另外ResNet-18作为骨干网络时后面往往还会接FPN做目标检测、接FCN做语义分割、接各种注意力模块做特征增强。它几乎成了整个视觉领域的“通用底座”。这也是为什么即使新模型层出不穷我依旧建议把ResNet-18作为打底网络的原因——它简单、稳定、普适几乎所有训练技巧都可以在它身上先验证。最后分享一点个人经验不管你现在做分类、检测还是分割我都建议花一个下午不借助现成库手写一遍ResNet-18。把BasicBlock的forward、维度变化的每个细节彻底弄清楚以后再看其他网络结构也会轻松很多。这算是我自己学习路线里收获最大的一次手写复现。跑通那一刻你会对“深度学习模型不过是张量流动”这句话有非常直观的体会。
返回列表