
做计算机视觉避不开ResNet。无论是课程里学图像分类还是大作业里做目标检测甚至实习项目里换一个更强的主干网络ResNet都是默认那个选项。你可能已经用过了但未必清楚它到底怎么解决训练深层网络的问题也不一定知道为什么现在很多预训练模型都管它叫backbone。这篇文章想把自己学习ResNet的过程、踩过的坑、以及在实际项目里怎么用它一次性讲清楚。1. 从“网络越深越差”说起残差要解决什么问题1.1 退化问题不是过拟合很多刚接触深度学习的同学都会默认一件事模型越深表达能力越强效果应该越好。这个直觉在浅层阶段是成立的VGG从11层加到19层ImageNet分类误差确实往下降。但到了深层之后事情开始变得不对劲。2015年的时候有实验数据摆在面前一个56层的卷积网络在CIFAR-10和ImageNet上的训练误差和测试误差都明显高于20层的网络。这里最反直觉的地方在于训练误差也变高了。如果只是测试误差高那还能解释成过拟合但训练误差都掉不下去说明问题根本不出在泛化上而是深层网络在优化阶段就已经吃不住了。有人可能会想那是不是梯度消失了实际上BN和合理的初始化已经把梯度消失和梯度爆炸处理得差不多了VGG那种反复堆叠的方式到了很深的地方依然很难找到让损失下降的方向。打个比方你派一个博士去处理一件本来只需要初中生就能做的工作结果他反而做不好。这不是因为他笨而是他过去解决复杂问题的策略在面对“什么都不做”这种看似简单的任务时反而产生了额外的负担。深层网络需要学习的正是这种能力有一些层最好什么都不做让输入直接透传过去这样才能保证深层版本至少不比浅层版本差。1.2 恒等映射为什么难学如果深层网络真的想达到“至少不差于浅层”的性能最优策略是在多出来的层里学一个恒等映射也就是把输入原封不动地输出H(x) x。但问题在于用一堆卷积、BN、ReLU去拟合恒等映射在实际上非常困难。为什么难因为网络的权重初始化之后很少会刚好落在“让输出等于输入”的位置上。优化器需要不断调整所有卷积核的权重最终找到一个近似单位变换的解。这个过程在参数空间中要走的路太长了尤其是当网络到了一定深度前向传播的微小扰动都会被放大反向传播的梯度也变得不再平滑。你让网络去猜一个恒等映射它往往给出的是一堆杂乱的变换而不是什么都不做。ResNet的聪明之处在于换了一个目标函数。它不直接学H(x) x而是定义残差F(x) H(x) - x于是网络要拟合的就变成了F(x)。如果最优解确实接近恒等映射那残差只需要接近0就行。让输出归零比让输出等于输入要容易得多权重衰减、BN的平移缩放参数都是在往“信号不过度变换”的方向引导。这样一来深层网络就有了学习的下限也就是至少能退化成一个浅层网络。1.3 残差学习的形式化理解用公式表示一个残差块可以写成y F(x, {W_i}) x其中F可以是两层卷积加激活x是输入y是输出。捷径连接shortcut就是那个直接加x的分支它不需要任何额外参数。关键是反向传播时梯度可以从y这一层通过加法节点直接回到x而不经过中间那些卷积层。这意味着即便残差支路的梯度很小恒等路径也一直保持着通畅的梯度流。这种结构从设计上缓解了深层网络的梯度消失也让训练几十层甚至上百层成为可能。后来有人做过实验如果把残差块里的ReLU也按照预激活的顺序调整梯度流动会更加顺畅这也就是Pre-activation ResNet的由来。不过最基本的“加法加恒等路径”这个想法才是ResNet真正厉害的地方。2. ResNet核心结构拆解残差块、瓶颈层与网络配置2.1 残差块捷径连接到底做了什么先看一个最常见的BasicBlock也就是ResNet18和ResNet34里用的残差块。它的结构是卷积3x3、BN、ReLU再卷积3x3、BN然后把输入x和输出相加最后再过一次ReLU。用PyTorch写出来大概是这样的import torch.nn as nn class BasicBlock(nn.Module): expansion 1 def __init__(self, in_channels, out_channels, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.downsample downsample def forward(self, x): identity x out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return out注意几个细节。第一个是biasFalse因为后面接BN偏置会被BN吸收不加偏置能省显存也避免冗余。第二个是downsample当通道数变化或者特征图尺寸减半时x和F(x)不能直接相加需要用1x1卷积调整x的通道和尺寸。第三是ReLU放在相加之后原版ResNet就是这么设计的。捷径连接本身没有可学习参数所以不会增加模型复杂度。它的作用更像是一条高速公路让信息可以从浅层一路传到深层同时让梯度也从深层回传到浅层。2.2 瓶颈设计1x1卷积降维升维的妙处ResNet50及以上的版本不再使用BasicBlock而是改用Bottleneck。 bottleneck的核心思路是用两个1x1卷积夹住中间的3x3卷积形成“降维-卷积-升维”的结构。假设输入是256个通道标准做法是直接用3x3卷积把256通道映射到256通道计算量是256 * 3 * 3 * 256 589,824次乘法。Bottleneck的做法是先用1x1卷积把256压缩到64再用3x3卷积从64到64最后用1x1卷积从64升到256。计算量变成256 * 1 * 1 * 64 64 * 3 * 3 * 64 64 * 1 * 1 * 256 16,384 36,864 16,384 69,632次只有原来的约十二分之一。这个设计在深层网络里至关重要。没有瓶颈结构ResNet101的计算量会大到不现实训练速度和显存消耗都会让人崩溃。1x1卷积本质上是在通道维度上做加权组合相当于对特征做了压缩和扩展虽然通道数临时降低了但最终输出维度保持跟输入一致方便做残差相加。2.3 从ResNet18到ResNet152数字背后的含义ResNet的数字指的是“带权重的卷积层全连接层数量”一般只看卷积层数量最后那个1000类全连接通常不算。不同深度的ResNet其实是在调整四个Stage内部的残差块数量。模型Stage1Stage2Stage3Stage4输出尺寸变化ResNet182个BasicBlock2个BasicBlock2个BasicBlock2个BasicBlock1/32ResNet343个BasicBlock4个BasicBlock6个BasicBlock3个BasicBlock1/32ResNet503个Bottleneck4个Bottleneck6个Bottleneck3个Bottleneck1/32ResNet1013个Bottleneck4个Bottleneck23个Bottleneck3个Bottleneck1/32ResNet1523个Bottleneck8个Bottleneck36个Bottleneck3个Bottleneck1/32每个Stage都会在开头把特征图尺寸降一半同时把通道数翻倍。ResNet系列的标准通道数是64、128、256、512。所以一个输入224x224的图片经过整个网络后会变成7x7的特征图通道数在最深层是512BasicBlock或2048Bottleneck的升维结果。实际使用的时候ResNet18和ResNet34比较轻量适合显存小、对实时性有要求、或者数据量不大的情况。ResNet50是分水岭很多检测分割模型的默认骨干网络都是它。ResNet101和152则用于大规模数据集和精度要求极高的任务但训练成本也高得多。2.4 激活函数与BN的摆放顺序原版残差块的顺序是conv - BN - ReLU - conv - BN - add - ReLU。这个细节经常被忽略但它对训练稳定性影响很大。ReLU放在add之前会让恒等路径直接穿过加法节点如果把ReLU放在加法之后相当于对求和结果也做了一次非线性截断短期看差别不大长期训下来会发现收敛速度和稳定性都不同。后来何恺明团队还提出了Pre-activation结构把ReLU移到卷积之前像这样BN - ReLU - conv。这种结构让恒等路径完全没有激活函数阻挡梯度传播更干净在数百层的极深网络上效果更明显。但对大多数使用者来说原版结构已经够用真正需要关注的是BN在训练和测试时行为不同。训练时BN用当前batch的均值和方差测试时用整个训练集滑动的统计量。所以加载预训练模型做推理时一定要调用model.eval()否则同一个模型在推理时输出会抖动。3. ResNet在计算机视觉各方向的应用版图3.1 从分类到检测分割ResNet无处不在很多初学者以为ResNet只是用来做图像分类的模型其实它更大的价值在于作为骨干网络。目标检测里的Faster R-CNN、YOLO v3之后的Darknet结构也借鉴了残差思想语义分割里的DeepLab、UNet变体姿态估计里的SimpleBaseline人脸识别里的ArcFace都大量采用ResNet作为特征提取器。在检测任务中ResNet的c2、c3、c4、c5这四个阶段的输出天然形成了金字塔结构。FPN特征金字塔网络就把这些不同分辨率的特征图拿去做多尺度融合小目标靠高层语义信息大目标靠低层纹理信息以此来提升检测精度。在分割任务中如果直接用stride2的卷积不断降采样输出分辨率会低得没法看所以DeepLab会修改ResNet从c3开始的stride换成空洞卷积保持特征图分辨率的同时扩大感受野。可以说在Transformer大规模入侵视觉之前ResNet就是视觉骨干网络的默认底座。3.2 作为骨干网络预训练模型与迁移学习使用ResNet最常见的方式不是从零训练而是加载在ImageNet上预训练好的权重然后在自己的数据集上微调。ImageNet预训练模型已经学会了大量纹理、边缘、颜色、形状等通用特征这些特征对很多视觉任务都有帮助尤其是在你自己数据量不够大的时候。加载预训练模型的要点是修改最后一层全连接。ImageNet是1000类而你的任务可能是10类或者2类所以最后一层必须替换import torchvision.models as models model models.resnet50(pretrainedTrue) num_ftrs model.fc.in_features model.fc nn.Linear(num_ftrs, num_classes)这里有个判断如果数据量很少比如几千张最好把前面所有层都冻结只训练新的全连接层防止严重过拟合。如果数据量有几万张甚至更多可以解冻所有层对全部参数做微调效果通常更好。还有一种中间方案是冻结前几个Stage只微调后面几个Stage和全连接层既能加快训练也能保留更多的通用特征。3.3 什么时候用ResNet18什么时候用ResNet50这是我在实际项目里经常被问到的问题答案不是“越深越好”而是要看数据量、显存、实时性、任务复杂度。如果特征是车牌识别、人脸属性分析这种相对结构化、背景变化不大的任务ResNet18甚至能接近ResNet50的效果而且训练速度快很多模型大小也只有不到45MB。如果任务是COCO这种类别多、物体尺度变化大、场景复杂的检测任务ResNet50通常才够用ResNet18的语义表达能力会拖后腿。显存只有4GB又想用ResNet50也不是不行把输入图片调小到128x128再用混合精度照样可以跑但精度会有损失。所以我会建议先跑通ResNet18确认你的数据预处理、训练流程没有问题再切换到ResNet50这样排错成本最低。4. 实战用PyTorch从零实现一个ResNet并跑通训练4.1 数据准备与预处理动手实现ResNet建议先在CIFAR-10上实验因为这个数据集够小、够经典一张24x24的彩色图训练集5万张测试集1万张一张普通显卡就能几分钟看到效果。预处理要注意CIFAR-10的尺寸是32x32而ImageNet是224x224所以通常会把图片先padding到36再随机裁剪成32增加平移不变性。同时做随机水平翻转最后归一化。不需要直接resize到224因为那样会丢失小图细节也没必要。我习惯用torchvision的CIFAR-10配合DataLoader。训练集要开shuffle测试集不开。归一化的均值方差用CIFAR-10的标准值(0.4914, 0.4822, 0.4465)标准差是(0.2470, 0.2435, 0.2616)不是ImageNet那套别抄错。4.2 残差块代码实现上面给了BasicBlock下面给出完整的Bottleneck和ResNet骨架然后分别构建ResNet18和ResNet50。class Bottleneck(nn.Module): expansion 4 def __init__(self, in_channels, out_channels, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.conv3 nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels * self.expansion) self.relu nn.ReLU(inplaceTrue) self.downsample downsample def forward(self, x): identity x out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) out self.relu(out) out self.conv3(out) out self.bn3(out) if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return outBottleneck需要在第一层降维第二层做空间卷积第三层升维到out_channels * 4。ResNet50里每个Stage的out_channels分别为64、128、256、512那么最终通道数就是256、512、1024、2048这就是为什么torchvision的ResNet50全连接层输入是2048。构建ResNet主体时注意第一层是7x7卷积加BN加ReLU然后3x3最大池化。但对于CIFAR-10这种32x32的小图第一层更适合用3x3卷积stride1不急着降分辨率否则一开始就把特征图压到8x8后面就没得玩。下面是一个支持CIFAR-10的简化ResNetclass ResNet(nn.Module): def __init__(self, block, layers, num_classes10): super().__init__() self.in_channels 64 self.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) # 如果是ImageNet尺寸可以使用 maxpoolCIFAR-10 时去掉 self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) self.layer1 self._make_layer(block, 64, layers[0], stride1) self.layer2 self._make_layer(block, 128, layers[1], stride2) self.layer3 self._make_layer(block, 256, layers[2], stride2) self.layer4 self._make_layer(block, 512, layers[3], stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512 * block.expansion, num_classes) def _make_layer(self, block, out_channels, blocks, stride): downsample None if stride ! 1 or self.in_channels ! out_channels * block.expansion: downsample nn.Sequential( nn.Conv2d(self.in_channels, out_channels * block.expansion, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels * block.expansion) ) layers [] layers.append(block(self.in_channels, out_channels, stride, downsample)) self.in_channels out_channels * block.expansion for _ in range(1, blocks): layers.append(block(self.in_channels, out_channels)) 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 def resnet18(num_classes10): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes) def resnet50(num_classes10): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes)对于CIFAR-10用上面的resnet18输入32x32经过layer2、layer3、layer4三次stride2降采样最终特征图是4x4再经过自适应平均池化变成1x1所以不会报错。如果想去掉maxpool也可以很多CIFAR-10实现直接把maxpool删掉效果差别不大。4.3 模型配置与训练技巧训练ResNet时优化器选择SGDMomentum是经典配置不要一上来就换Adam尤其是在微调预训练模型时SGD一般更稳。学习率从0.1或者0.01开始配合余弦退火或者阶梯下降。CIFAR-10这种小数据300轮训练足以看到完整曲线如果时间有限60轮也能到90%以上。损失函数用CrossEntropyLoss。Batch size在128到256之间比较常见。还有一个容易被忽略的点批量归一化对batch size敏感如果显存只允许你设8那BN的均值方差会很不稳定训练效果会明显变差。这时要么加大batch要么用SyncBN要么把输入图片调小一点。训练循环建议每5个epoch记录一次训练损失和验证准确率。你会发现刚开始损失下降很快到后期会出现小幅度震荡这是正常现象。如果验证准确率一直不动先检查数据预处理是不是错了再看学习率是不是太大。4.4 训练曲线与结果分析我自己跑过一遍ResNet18在CIFAR-10上batch size 128SGD lr0.1、momentum0.9、weight_decay1e-4采用step schedule在第80、120轮降学习率160轮左右验证集准确率能达到93%左右300轮能到94%以上。ResNet50也能到94%但训练时间大约是ResNet18的2到3倍数据量不够大的时候优势不明显。从训练曲线里可以看到ResNet18在60轮之前就已经过拟合的迹象不明显说明残差结构本身收敛性好。如果你发现验证准确率远低于训练准确率那才是真正的过拟合可以通过数据增强、加Dropout、调节weight_decay来解决。5. 踩坑记录我训练ResNet时遇到的那些问题5.1 学习率策略不对导致不收敛有次我在一个新数据集上用ResNet50微调直接把学习率设成0.01结果训练了20个batchloss直接变成NaN或者loss卡在初始值附近下不去。后来排查下来问题出在预训练模型和自定义数据分布差异较大一开始就以高学习率更新很容易把BN的统计量冲垮。解决办法是加一个warmup前5个epoch从0线性升到目标学习率或者干脆用一个很小的初始学习率比如0.001。对于预训练模型微调我现在的习惯是先用0.001跑20个epoch看loss有没有明显下降如果pre-train特征很通用甚至可以直接用0.0001稳定为主再配合余弦退火慢慢调。5.2 预训练模型与自定义类别数不匹配这是新手最容易踩的坑。加载torchvision的预训练ResNet直接跑自己的数据集会报一个维度不匹配的错提示fc.weight和fc.bias尺寸对不上。原因是预训练模型在ImageNet上是1000类你自己的数据集可能是10类、2类或者100类。正确做法是先加载state_dict遍历所有参数把fc层的key去掉然后替换新的fc层。更省事的办法是像我上面那样先构建一个ResNet50再修改model.fc nn.Linear(2048, num_classes)。但如果你用的是别人给的预训练权重不一定叫fc比如timm里叫head就要先打印一下state_dict里的key看清楚再操作。5.3 显存不够怎么办服务器只有一块8GB显卡又想用ResNet50这时候有几个技巧可以叠加。第一个是减小batch size但别小于16否则BN就不稳定。第二个是开启混合精度PyTorch里用torch.cuda.amp可以减少接近一半的显存占用速度还能提升。第三个是梯度累积把4个batch的梯度累积起来再更新一次相当于变相扩大了batch sizeaccumulation_steps 4 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): images, labels images.cuda(), labels.cuda() outputs model(images) loss criterion(outputs, labels) loss loss / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()注意损失要除以累积步数否则梯度会放大accumulation_steps倍导致学习率失控。还有一个偏方是减少输入分辨率。检测任务里把640x640降到512x512显存能省差不多三分之一mAP可能会掉0.3到0.5个点但训练时间也相应缩短。实际项目中要学会取舍。5.4 同步BN与多卡训练的细节多卡训练时如果batch_size总共只有32分配到每张卡上就很小BN统计量会非常不准。解决办法是使用同步批归一化SyncBN让BN在多个GPU之间同步计算均值和方差。PyTorch里用torch.nn.SyncBatchNorm.convert_sync_batchnorm(model)把普通BN转换成同步版再配合DistributedDataParallel使用。不过在数据量很小、模型不大的时候多卡同步BN提升有限反而增加了通信开销。我的经验是单卡batch能超过64就没必要用SyncBN单卡batch只有16或者更小多卡训练时同步BN收益非常明显。6. 学习路线与判断标准如何真正掌握ResNet6.1 最低限度的复现标准很多人刷完了理论代码也能跑通但问起来还是一问三不知。我给自己定过一个“最低限度复现标准”不调用torchvision.models.resnet18自己写BasicBlock和ResNet在CIFAR-10上训练到92%以上。做到这一步才算真的懂了ResNet的结构。如果还想再巩固要求自己解释为什么加shortcut就能训练更深网络画一下反向传播时梯度经过shortcut那条路径为什么不衰减再说一说Bottleneck的FLOPs对比。面试或者期末考试问到ResNet其实核心就是这几个问题。6.2 ResNet的变体与后续发展ResNet不是一个孤立点它带出了一整条研究路线。ResNeXt在残差块里引入分组卷积用更大的基数替代单纯的深度SE-ResNet在残差支路输出后加了一个Squeeze-and-Excitation模块显式建模通道之间的依赖DenseNet干脆把每一层的输出都拼接到后面所有层实现特征复用Res2Net则在单个残差块内部做多尺度特征。再往后EfficientNet用NAS搜索缩放宽度、深度、分辨率但这些模型里依然能看到残差连接的设计精神。直到ViT出现卷积骨干才被Transformer大规模替代但很多ViT的设计里也保留了残差连接。我建议你把ResNet和ResNeXt、DenseNet放在一起对比看它们对特征流的不同处理方式这样能更快建立对整个CNN演化谱系的印象。6.3 面对期末与大作业时的解题思路课程的大作业往往不会让你只训一个分类模型常见的有用ResNet做猫狗分类、用Faster R-CNN做目标检测、用Grad-CAM可视化ResNet关注的区域、或者对比不同深度ResNet的性能差异。这种题目的核心套路是先确定任务类型分类就直接用预训练ResNet微调检测就用detectron2或mmdetection里的标准config分割就参考DeepLab不要在模型搭建上花费太多时间把精力放在数据清洗、数据增强和结果分析上。如果题目要求“从零实现ResNet”一定要保留自己写的代码和训练曲线而不是只贴torchvision一行代码。老师想看到的是你对残差结构的理解以及动手验证的能力。最后记得分析失败案例什么情况下ResNet效果变差为什么这类分析往往比完美的准确率更能体现水平。我自己后来做项目时也遇到不少“ResNet失手”的场景。有一个OCR方向的任务发现跳过后两层特征效果反而更好因为目标区域太小深层的下采样把细节丢没了。这时候就要学会去改ResNet的输出层把c4甚至c3作为最终特征而不是抱着整个模型不撒手。ResNet的价值就在这里它给你提供了一个稳定的骨架但你得懂得怎么根据自己的任务去裁剪和调整。把结构吃透比会调用模型重要得多。