平时在社区里回答网络结构相关问题时被问最多的就是 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-1x1Bottleneck的设计核心是先用1x1卷积压缩通道数比如从256压缩到64再用3x3卷积做空间特征提取最后用1x1卷积把通道数恢复到256。三明治结构让中间3x3卷积的计算量大幅下降整体参数和FLOPs都更可控。以输入256通道为例算一下BasicBlock 两个3x32 × 3 × 3 × 256 × 256 1,179,648 个乘法操作Bottleneck 1x1-3x3-1x11×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必须一致相加才能成立。一旦需要下采样或者升维有几种做法直接在捷径分支使用 stride2 的1x1卷积同时改变通道数直接在捷径分支做全局平均池化再接1x1卷积调整通道使用恒等映射加零填充补齐通道也就是说输入本身不经过任何操作只在相加时把多出来的通道补0第一种是最常用的ResNet官方实现也采用这种方式。第二种在部分变体里出现过效果往往略好但实现稍复杂。第三种省参数但语义上补0通道会让网络难以利用这部分信息实际用的不多。需要注意的是下采样卷积的stride放在哪个卷积层。BasicBlock做下采样时官方代码把stride2放在两个3x3卷积的第一个上。Bottleneck做下采样时stride既可以放在中间的3x3卷积也可以放在第一个1x1卷积。经验上放在第一个1x1上通常信息损失更少因为1x1卷积会先对空间位置逐点重映射如果直接stride2就相当于跳过了一半像素点。很多经典实现都放在第一个1x1上你可以对比实验看看。3. 常见ResNet变体与整体设计规律3.1 ResNet家族速览常被挑做Backbone的ResNet版本主要是这几个网络残差块类型总层数典型场景ResNet18BasicBlock18轻量任务、嵌入式、快速验证ResNet34BasicBlock34中轻度任务ResNet50Bottleneck50多数视觉任务的默认选择ResNet101Bottleneck101精度要求高的任务ResNet152Bottleneck152大规模数据集极限精度层数计算有个公式以ResNet50为例起始一个7x7卷积加一个最大池化不算进基本残差块接下来四个Layer分别包含 3、4、6、3 个Bottleneck每个Bottleneck包含3个卷积所以残差块总卷积数 (3463) × 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, stride1, downsampleNone): 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.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, stride1, downsampleNone): super().__init__() # 第一个1x1降通道 self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) # 第二个3x3空间卷积stride决定是否下采样 self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) # 第三个1x1恢复4倍通道 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 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这里全部用了 biasFalse因为后续有BatchNorm卷积的偏置会被BN的均值减法抵消留着只会浪费参数和显存。这是很多人容易踩的小坑。4.2 构建完整网络骨架定义好残差块后再搭整体框架。主要包含四个Stage和最后的分类头。class ResNet(nn.Module): def __init__(self, block, layers, 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.relu nn.ReLU(inplaceTrue) 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, stride1): 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_make_layer里有个关键点每个Stage的第一个残差块负责下采样和通道调整所以单独构造downsample后面的残差块都保持空间尺寸和通道数不变。这种写法与官方实现基本一致后续加注意力模块或FPN时也方便插入。实例化不同的深度def resnet18(num_classes1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classesnum_classes) def resnet34(num_classes1000): return ResNet(BasicBlock, [3, 4, 6, 3], num_classesnum_classes) def resnet50(num_classes1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classesnum_classes) def resnet101(num_classes1000): return ResNet(Bottleneck, [3, 4, 23, 3], num_classesnum_classes)4.3 输入尺寸与参数计算官方默认输入是 224×224 的RGB图。以ResNet50为例前向传播各阶段特征图变化如下conv1224×224×3 - 112×112×64maxpool112×112×64 - 56×56×64layer156×56×64 - 56×56×256layer228×28×128 - 28×28×512layer314×14×256 - 14×14×1024layer47×7×512 - 7×7×2048avgpool7×7×2048 - 1×1×2048fc2048 - 1000如果你输入尺寸不是224比如是320或512也不用改结构。AdaptiveAvgPool会保证最后一层池化输出永远是1×1所以全连接层能接上。但要注意小尺寸输入时maxpool之后特征图可能小于7×7或者直接变成奇数尺寸部分Stage的卷积加padding1后能够适配大多数尺寸不过resize到官方标称尺寸通常最稳。关于参数数量ResNet50大约25.6MResNet101约44.5M这个量级在很多任务里算“中等偏大”。使用Bottleneck后参数集中在1×1卷积其中很多是在调整通道。4.4 初始化策略为什么要有专门的initPyTorch默认初始化在深层网络里效果其实一般ResNet论文作者专门提出了适合ReLU的初始化方法——何恺明初始化。PyTorch里可以用如下方式手动给网络做初始化def init_weights(m): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) 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, modefan_out, nonlinearityrelu) nn.init.constant_(m.bias, 0) model.apply(init_weights)这里使用 fan_out 模式是因为前向传播时希望保持响应方差稳定同时配合ReLU的非线性。BN的weight初始化为1bias为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 decayResNet通常用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×224batch size 32在常见10G显存卡上勉勉强强batch 16更稳。想省显存有几招把激活函数换为inplaceTrue理论上能省一些内存虽然实际不容易量化用梯度累积模拟大batchbatch 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后再ReLUconv层biasTrue参数冗余且精度无益设置biasFalse多个Stage通道数没翻倍模型容量不足检查_make_layer输出通道7. 最后分享一点个人心得ResNet这个结构看起来简单但它的设计思路影响了好几年的Backbone演进。理解了残差连接为什么有效后续再看DenseNet、ResNeXt、SENet都会轻松很多。如果你是自己动手搭网络我建议先搭出一个能跑通的ResNet18在CIFAR或者自己的小数据集上从零训练到80%以上准确率再逐步加深。别一上来就上152层那样出了问题你根本不知道是结构问题还是优化问题。把基础知识夯实后面才能真正玩转各种复杂结构。