做计算机视觉避不开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, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=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, kernel_size=3, stride=1, padding=1, bias=False) 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注意几个细节。第一个是bias=False,因为后面接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内部的残差块数量。
| 模型 | Stage1 | Stage2 | Stage3 | Stage4 | 输出尺寸变化 |
|---|---|---|---|---|---|
| ResNet18 | 2个BasicBlock | 2个BasicBlock | 2个BasicBlock | 2个BasicBlock | 1/32 |
| ResNet34 | 3个BasicBlock | 4个BasicBlock | 6个BasicBlock | 3个BasicBlock | 1/32 |
| ResNet50 | 3个Bottleneck | 4个Bottleneck | 6个Bottleneck | 3个Bottleneck | 1/32 |
| ResNet101 | 3个Bottleneck | 4个Bottleneck | 23个Bottleneck | 3个Bottleneck | 1/32 |
| ResNet152 | 3个Bottleneck | 8个Bottleneck | 36个Bottleneck | 3个Bottleneck | 1/32 |
每个Stage都会在开头把特征图尺寸降一半,同时把通道数翻倍。ResNet系列的标准通道数是64、128、256、512。所以一个输入224x224的图片,经过整个网络后,会变成7x7的特征图,通道数在最深层是512(BasicBlock)或2048(Bottleneck的升维结果)。
实际使用的时候,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(特征金字塔网络)就把这些不同分辨率的特征图拿去做多尺度融合,小目标靠高层语义信息,大目标靠低层纹理信息,以此来提升检测精度。在分割任务中,如果直接用stride=2的卷积不断降采样,输出分辨率会低得没法看,所以DeepLab会修改ResNet从c3开始的stride,换成空洞卷积,保持特征图分辨率的同时扩大感受野。
可以说,在Transformer大规模入侵视觉之前,ResNet就是视觉骨干网络的默认底座。
3.2 作为骨干网络:预训练模型与迁移学习
使用ResNet最常见的方式不是从零训练,而是加载在ImageNet上预训练好的权重,然后在自己的数据集上微调。ImageNet预训练模型已经学会了大量纹理、边缘、颜色、形状等通用特征,这些特征对很多视觉任务都有帮助,尤其是在你自己数据量不够大的时候。
加载预训练模型的要点是修改最后一层全连接。ImageNet是1000类,而你的任务可能是10类或者2类,所以最后一层必须替换:
import torchvision.models as models model = models.resnet50(pretrained=True) 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, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels * self.expansion) self.relu = nn.ReLU(inplace=True) 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卷积,stride=1,不急着降分辨率,否则一开始就把特征图压到8x8,后面就没得玩。下面是一个支持CIFAR-10的简化ResNet:
class ResNet(nn.Module): def __init__(self, block, layers, num_classes=10): super().__init__() self.in_channels = 64 self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) # 如果是ImageNet尺寸,可以使用 maxpool;CIFAR-10 时去掉 self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) self.layer1 = self._make_layer(block, 64, layers[0], stride=1) self.layer2 = self._make_layer(block, 128, layers[1], stride=2) self.layer3 = self._make_layer(block, 256, layers[2], stride=2) self.layer4 = self._make_layer(block, 512, layers[3], stride=2) 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_size=1, stride=stride, bias=False), 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_classes=10): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes) def resnet50(num_classes=10): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes)对于CIFAR-10,用上面的resnet18,输入32x32,经过layer2、layer3、layer4三次stride=2降采样,最终特征图是4x4,再经过自适应平均池化变成1x1,所以不会报错。如果想去掉maxpool,也可以,很多CIFAR-10实现直接把maxpool删掉,效果差别不大。
4.3 模型配置与训练技巧
训练ResNet时,优化器选择SGD+Momentum是经典配置,不要一上来就换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 128,SGD lr=0.1、momentum=0.9、weight_decay=1e-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个batch,loss直接变成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 size:
accumulation_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. 学习路线与判断标准:如何真正掌握ResNet
6.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的价值就在这里,它给你提供了一个稳定的骨架,但你得懂得怎么根据自己的任务去裁剪和调整。把结构吃透,比会调用模型重要得多。