平时在社区里回答网络结构相关问题时,被问最多的就是 ResNet。2015年它刚出来的时候,大家还都在为“网络越深越难训练”发愁,结果它靠一个残差块的改动,把网络深度直接刷到上百层,横扫各类榜单。直到今天,ResNet依然是最常用的主干网络之一,很多检测、分割模型都在Backbone里用它的变体。这篇就把它从原理到代码,从搭建到调参,完整拆一遍。适合刚学完基础卷积神经网络、准备上手写分类模型,以及想深入理解残差连接到底在做什么的人。
1. 从梯度消失说起:为什么需要残差结构
1.1 深度网络训练的核心痛点
很多初学者第一反应是“网络越深,效果越好”,但真正去堆层数时就会发现完全不是那么回事。单纯叠加卷积层,到了四五十层以后,训练集上的loss往往就降不下来了,更别说验证集。这不是过拟合,而是优化本身出了问题。
关键原因就是梯度消失和梯度爆炸。反向传播时,梯度要从最后一层一路乘回去,每一层卷积的权重矩阵引入一个梯度缩放。随着层数增加,这些缩放因子连乘之后会变得非常小,或者非常大。梯度爆炸还好处理,用梯度裁剪就能压住;梯度消失则很难办,前面几十层的权重收不到有效更新,等于白加了这些层。
Batch Normalization能够缓解一部分问题,它把每层的输入强行拉回均值为0、方差为1的分布,让梯度不会因为激活函数饱和而直接消失。但玄学的是,即使加了BN,纯堆叠式的深层网络在几十层以上仍然不好训练,训练loss会先下降,然后在某个深度后反而回升。这也说明,梯度问题只是表象,还有一个更隐蔽的“退化问题”。
1.2 残差学习的直觉
退化问题听起来反直觉:增加网络深度,哪怕只增加恒等映射层,理论上训练误差也不应该变大。可实际上,让一堆非线性卷积层去逼近恒等映射,难度比想象中大得多。权重初始化稍有不慎,网络输出就会偏离输入,导致优化器很难把“什么都不做”这个状态学出来。
残差网络的思路很直接:不在每个层去学习目标映射 H(x),而是让网络去学残差 F(x) = H(x) - x,最后用 F(x) + x 作为输出。如果目标就是恒等映射,那网络只需要把 F(x) 学成0,这个比学成 x 容易太多。
注意:残差连接还有一个非常朴素的好处——它给梯度提供了一条“高速公路”。反向传播时梯度可以直接通过捷径分支传到前面层,即使深层卷积权重梯度消失,也能确保浅层有稳定的梯度信号。这是ResNet能训练上百层的关键保障之一。
我经常用一个类比说明这件事:不加残差的深层网络像一列堵车的车队,后车的路况完全取决于前面每一辆车;加了残差,相当于每隔一段就开一条辅路,让信息可以绕开拥堵路段直行。
2. ResNet核心构件拆解
2.1 残差块的常规结构
ResNet的基本单元由两条路径组成:主路径执行卷积、BN、激活,捷径路径把输入直接加到主路径输出上。以最经典的BasicBlock为例:
- 主路径:3x3卷积 -> BatchNorm -> ReLU -> 3x3卷积 -> BatchNorm
- 合并操作:主路径输出与捷径输出做逐元素相加
- 相加之后再过一个ReLU作为最终输出
这里的顺序值得掰扯。第一个卷积前要不要先过ReLU?原始ResNet中的BasicBlock第一个卷积是直接接收输入的,所以流程是 Conv-BN-ReLU-Conv-BN,然后 add + ReLU。也有人喜欢改成 Conv-BN-ReLU-Conv-BN-ReLU,但这些细节对最终精度影响不大,关键是必须保证相加的两个张量形状一致且语义上能对应。
两个3x3卷积堆叠的感受野是5x5,但参数数量是 2×9×C×C = 18C²,而直接用一个5x5卷积需要 25C²,所以残差块在参数上更省,非线性表达能力却几乎不输。这就是ResNet中大量使用“两个3x3代替一个5x5”的原因。
2.2 两种残差块设计:BasicBlock 与 Bottleneck
不同深度的ResNet,残差块形状不一样:
- ResNet18/34使用BasicBlock:每层两个3x3卷积,通道数通常不变
- ResNet50/101/152使用Bottleneck:每层三个卷积,形状为 1x1->3x3->1x1
Bottleneck的设计核心是先用1x1卷积压缩通道数,比如从256压缩到64,再用3x3卷积做空间特征提取,最后用1x1卷积把通道数恢复到256。三明治结构让中间3x3卷积的计算量大幅下降,整体参数和FLOPs都更可控。
以输入256通道为例算一下:
- BasicBlock 两个3x3:2 × 3 × 3 × 256 × 256 = 1,179,648 个乘法操作
- Bottleneck 1x1-3x3-1x1:1×1×256×64 + 3×3×64×64 + 1×1×64×256 = 16,384 + 36,864 + 16,384 = 69,632
差了近17倍。所以深层网络用Bottleneck不只是为了提点,更是为了让计算量不要爆炸。如果你在搭建50层以上网络时还沿用BasicBlock,显存和训练时间都会很不舒服。
2.3 下采样与捷径连接的三种处理方式
残差块的输入输出shape必须一致,相加才能成立。一旦需要下采样或者升维,有几种做法:
- 直接在捷径分支使用 stride=2 的1x1卷积,同时改变通道数
- 直接在捷径分支做全局平均池化,再接1x1卷积调整通道
- 使用恒等映射加零填充补齐通道,也就是说输入本身不经过任何操作,只在相加时把多出来的通道补0
第一种是最常用的,ResNet官方实现也采用这种方式。第二种在部分变体里出现过,效果往往略好但实现稍复杂。第三种省参数,但语义上补0通道会让网络难以利用这部分信息,实际用的不多。
需要注意的是,下采样卷积的stride放在哪个卷积层。BasicBlock做下采样时,官方代码把stride=2放在两个3x3卷积的第一个上。Bottleneck做下采样时,stride既可以放在中间的3x3卷积,也可以放在第一个1x1卷积。经验上放在第一个1x1上通常信息损失更少,因为1x1卷积会先对空间位置逐点重映射,如果直接stride=2,就相当于跳过了一半像素点。很多经典实现都放在第一个1x1上,你可以对比实验看看。
3. 常见ResNet变体与整体设计规律
3.1 ResNet家族速览
常被挑做Backbone的ResNet版本主要是这几个:
| 网络 | 残差块类型 | 总层数 | 典型场景 |
|---|---|---|---|
| ResNet18 | BasicBlock | 18 | 轻量任务、嵌入式、快速验证 |
| ResNet34 | BasicBlock | 34 | 中轻度任务 |
| ResNet50 | Bottleneck | 50 | 多数视觉任务的默认选择 |
| ResNet101 | Bottleneck | 101 | 精度要求高的任务 |
| ResNet152 | Bottleneck | 152 | 大规模数据集极限精度 |
层数计算有个公式,以ResNet50为例:
- 起始一个7x7卷积加一个最大池化,不算进基本残差块
- 接下来四个Layer分别包含 3、4、6、3 个Bottleneck
- 每个Bottleneck包含3个卷积,所以残差块总卷积数 = (3+4+6+3) × 3 = 48
- 最后一个分类层前的全局平均池化不算卷积,但在统计总层数时常被加入,最终把初始卷积加上得到50层
如果你自己动手数,可能有人把BN层也算进去,就会得到更复杂的数字。在论文语境里,层数通常只统计带权重的卷积层和全连接层,BN不算。
3.2 通道数翻倍与连续下采样
ResNet的特征提取部分分成Stage2到Stage5,通道数从64开始,依次翻倍为128、256、512,对应特征图分辨率逐级减半。这种“分辨率下降、通道数上升”的结构是后来所有主干网络的基本范式,因为高分辨率层关注细节但通道可以少,低分辨率层感受野大但需要更多通道来表达语义。
每个Stage的开头第一个残差块负责下采样。以ResNet50为例,Stage3的第一个Bottleneck会把输入的 56×56×128 变成 28×28×256,这是通过把第一个1x1卷积stride设为2实现的。之后同Stage内的其他残差块stride都保持1,分辨率不再下降。
有次某同学问为什么特征图在Stage内还要反复做3x3卷积,这不浪费计算吗?实际上同一分辨率下的多个残差块是在逐步增加非线性映射能力,让网络在保持分辨率的同时提取更抽象的特征。如果每个Stage只放一个残差块,网络就很浅,效果会明显下滑。
4. 从零搭建ResNet:以PyTorch为例
4.1 基础模块代码实现
直接上代码,这里用PyTorch的常用写法,拆成BasicBlock和Bottleneck两个类。
import torch 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.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.downsample = downsample def forward(self, x): identity = x if self.downsample is not None: identity = self.downsample(x) out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) out += identity out = self.relu(out) return outBottleneck略复杂一点,需要注意通道数变化逻辑:
class Bottleneck(nn.Module): expansion = 4 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super().__init__() # 第一个1x1:降通道 self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) # 第二个3x3:空间卷积,stride决定是否下采样 self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) # 第三个1x1:恢复4倍通道 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 if self.downsample is not None: identity = self.downsample(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) out += identity out = self.relu(out) return out这里全部用了 bias=False,因为后续有BatchNorm,卷积的偏置会被BN的均值减法抵消,留着只会浪费参数和显存。这是很多人容易踩的小坑。
4.2 构建完整网络骨架
定义好残差块后,再搭整体框架。主要包含四个Stage和最后的分类头。
class ResNet(nn.Module): def __init__(self, block, layers, num_classes=1000): super().__init__() self.in_channels = 64 self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) 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=1): 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_make_layer里有个关键点:每个Stage的第一个残差块负责下采样和通道调整,所以单独构造downsample,后面的残差块都保持空间尺寸和通道数不变。这种写法与官方实现基本一致,后续加注意力模块或FPN时也方便插入。
实例化不同的深度:
def resnet18(num_classes=1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes=num_classes) def resnet34(num_classes=1000): return ResNet(BasicBlock, [3, 4, 6, 3], num_classes=num_classes) def resnet50(num_classes=1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes) def resnet101(num_classes=1000): return ResNet(Bottleneck, [3, 4, 23, 3], num_classes=num_classes)4.3 输入尺寸与参数计算
官方默认输入是 224×224 的RGB图。以ResNet50为例,前向传播各阶段特征图变化如下:
- conv1:224×224×3 -> 112×112×64
- maxpool:112×112×64 -> 56×56×64
- layer1:56×56×64 -> 56×56×256
- layer2:28×28×128 -> 28×28×512
- layer3:14×14×256 -> 14×14×1024
- layer4:7×7×512 -> 7×7×2048
- avgpool:7×7×2048 -> 1×1×2048
- fc:2048 -> 1000
如果你输入尺寸不是224,比如是320或512,也不用改结构。AdaptiveAvgPool会保证最后一层池化输出永远是1×1,所以全连接层能接上。但要注意,小尺寸输入时maxpool之后特征图可能小于7×7,或者直接变成奇数尺寸,部分Stage的卷积加padding=1后能够适配大多数尺寸,不过resize到官方标称尺寸通常最稳。
关于参数数量,ResNet50大约25.6M,ResNet101约44.5M,这个量级在很多任务里算“中等偏大”。使用Bottleneck后参数集中在1×1卷积,其中很多是在调整通道。
4.4 初始化策略:为什么要有专门的init
PyTorch默认初始化在深层网络里效果其实一般,ResNet论文作者专门提出了适合ReLU的初始化方法——何恺明初始化。PyTorch里可以用如下方式手动给网络做初始化:
def init_weights(m): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) elif isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') nn.init.constant_(m.bias, 0) model.apply(init_weights)这里使用 fan_out 模式,是因为前向传播时希望保持响应方差稳定,同时配合ReLU的非线性。BN的weight初始化为1,bias为0则是最常规做法。如果你用迁移学习加载预训练权重,初始化这步就可以跳过,因为预训练模型里的值已经经过了大量数据调整,重新初始化反而会破坏预训练特征。
5. 训练实战与调参经验
5.1 数据增强与超参基线
搭建好模型只是起点,真正让ResNet起效果的是训练配置。以ImageNet规模的数据集为例,一套在实战中广泛验证过的配置大致是:
- 输入尺寸:随机resize到256后随机裁剪224×224
- 水平随机翻转:概率0.5
- 颜色抖动:亮度、对比度、饱和度变化0.2~0.4
- 标称化:均值 [0.485, 0.456, 0.406],标准差 [0.229, 0.224, 0.225]
- 优化器:SGD + Momentum 0.9 + Weight Decay 1e-4
- 批次大小:256(多卡时按卡数等倍扩大)
- 学习率初始:0.1(批次256对应的标称值)
- 学习率调整:每30个epoch降到原来的1/10,共90epoch
如果是小数据集,batch size没那么大,学习率也要等比缩放。一个经验法则是:batch size翻倍,学习率也翻倍;batch size减半,学习率减半。直接拿着0.1跑到batch size 32上,很容易发散。
关于weight decay,ResNet通常用1e-4,太大容易欠拟合,太小容易过拟合。BN层里的scale参数和卷积权重一样会被衰减,所以不要把BN参数从weight decay中排除,除非你做的是超大规模蒸馏等特殊场景。
5.2 学习率策略与迁移学习
ResNet训练中,warmup对超大规模batch很有帮助。线性warmup 5个epoch,把学习率从0.05倍目标值慢慢升到目标值,能有效避免前期loss突然冲高。这是因为大batch全局梯度方向比较稳定,但如果学习率一开始就太大,BN统计量还没来得及稳定,会带来训练震荡。
如果不想从头训练,加载预训练权重做迁移学习是更常见的做法。在分类任务中,通常把最后一层全连接替换成自己的类别数,然后选择微调策略:
- 只训练新全连接层,冻结前面所有层的参数,适合数据量极少的情况
- 以较低学习率(如0.001~0.01)训练全部层,适合中等数据量
- 在骨干网络后面加可训练的线性探测层,先固定骨干跑几个epoch,再解锁全网络微调
我实际实验过,很多时候冻结前几层、微调后面几个Stage,效果比全量微调更稳,尤其是数据分布和预训练数据集差异较大时。如果数据和预训练数据同域,全量微调通常最好。
5.3 Batch Size 与显存优化
ResNet50跑224×224,batch size 32在常见10G显存卡上勉勉强强,batch 16更稳。想省显存有几招:
- 把激活函数换为inplace=True,理论上能省一些内存,虽然实际不容易量化
- 用梯度累积模拟大batch:batch size 32,梯度累积4步,等效batch 128
- 在BatchNorm开启时,梯度累积前后损失需要除以累积步数,并且每步backward后要手动清零梯度
另一个要注意的是混合精度训练。AMP自动混合精度对ResNet加速明显,显存占用也能砍掉不少。开启前建议先确认BN计算是否保持FP32,大多数框架默认会保持,这点不用担心。
6. 常见问题与排查技巧
6.1 训练不收敛:先排除结构错误
ResNet最常见的“不收敛”其实是代码错误,而不是超参问题。症状往往是loss震荡、降不下去,或者提前降到某个值后卡死。
排查顺序:
- 输入输出维度是否匹配,用随机输入单步前向测试
- shortcut相加的位置是否正确,是否在最后ReLU之前
- BN是否在train和eval模式下都能正常运行
- 标签是否从0开始,loss是否计算正确
- 是否做了正确的数据归一化
最快的方法是先用很小的数据集(比如几百张图)拟合看看,如果loss能降到接近0,说明结构能work,那问题出在数据或超参;如果小数据上依然不收敛,那就是结构写错了。
6.2 显存不足,多数是特征图缓存爆炸
ResNet显存占用大头是前向缓存的特征图,尤其前两个Stage虽然大通道不多,但分辨率高,每个tensor都不小。遇到OOM可以这样尝试:
- 减小batch size,从32降到16
- 关闭计算图缓存只做测试时,用torch.no_grad()
- 使用混合精度
- 降低输入分辨率,但注意最终精度会受影响
- 检查是否有不必要的中间变量被保留
还有个容易忽略的点:训练时每个epoch前要调用model.train(),验证时调用model.eval(),忘记切换会让BN统计量一直在更新,导致验证阶段表现时好时坏,而且会让梯度计算量加倍,显存压力也变大。
6.3 精度上不去:从数据与训练细节找原因
如果网络结构没问题,但分类精度总差着几个点,优先检查:
- 是否做了随机裁剪和翻转,若只做中心裁剪,模型很容易过拟合
- 学习率调度是否正确,很多情况是最后一阶段学习率没降够
- 训练epoch是否足够,ResNet在ImageNet上往往要到90~100个epoch才完全收敛
- 数据是否有标签噪声或者预处理不一致
我见过有人把图片归一化做错,减均值前没有将图像从0~255缩放到0~1,导致输入特征集中在很大的绝对值上,BN虽然能兜底一部分,但效果始终差一些。这类低级问题排查起来最费时间,建议先打印一批输入tensor的数值范围确认。
6.4 残差块实现易错点速查表
| 错误点 | 具体表现 | 解决方案 |
|---|---|---|
| 捷径分支忘记下采样 | 相加时shape不匹配报错 | 在downsample中设置stride |
| 下采样位置错误 | 特征图分辨率降得太早 | 确保下采样只发生在每个Stage第一个块 |
| BN放在ReLU之后 | 训练稳定但收敛慢 | 按Conv-BN-ReLU顺序 |
| 最后一个ReLU在相加前 | 残差被截断,效果下降 | 确保add后再ReLU |
| conv层bias=True | 参数冗余且精度无益 | 设置bias=False |
| 多个Stage通道数没翻倍 | 模型容量不足 | 检查_make_layer输出通道 |
7. 最后分享一点个人心得
ResNet这个结构看起来简单,但它的设计思路影响了好几年的Backbone演进。理解了残差连接为什么有效,后续再看DenseNet、ResNeXt、SENet都会轻松很多。如果你是自己动手搭网络,我建议先搭出一个能跑通的ResNet18,在CIFAR或者自己的小数据集上从零训练到80%以上准确率,再逐步加深。别一上来就上152层,那样出了问题你根本不知道是结构问题还是优化问题。把基础知识夯实,后面才能真正玩转各种复杂结构。