1. 为什么要手写 ResNet让网络变深这件事没那么简单我最早接触 ResNet 是在做图像分类的迁移学习实验时。当时直接调用了现成的接口模型下载下来就能跑出结果确实很方便。但一旦进入实际项目——数据是自己拍的标注是自己做的类别分布和公开数据集差异很大——我就发现不对了调接口训练时网络层数加到一定深度后loss 怎么调都降不下去甚至越训越差。那时候才开始老老实实研究 ResNet 的构建过程从残差块写起一层一层把网络搭出来。很多人觉得 ResNet 不就是加了条“跳线”嘛论文里说得清清楚楚代码库里有现成实现为什么要从零构建我的答案是只有手写过一次你才会真正理解为什么参数要这么设、为什么残差连接放在这里而不是那里、为什么 BN 要跟卷积配对而不是跟激活函数配对。这些细节直接用现成接口是永远学不到的。先说清楚 ResNet 解决的核心问题网络退化degradation。理论上网络越深表达能力越强至少不应该比浅层网络差——因为你可以把多余的层训练成恒等映射。但实践发现50 层往上的网络在训练集上的误差反而比 20 层的还高这不是过拟合而是优化困难。深层网络的反向传播路径太长梯度在逐层传递中不断衰减前几层几乎收不到有效更新信号。这就是梯度消失问题。残差连接的核心思想是把“让 H(x) 逼近目标映射”转变成“让 F(x) 逼近目标映射与输入的差值 H(x) - x”。当 F(x) 为 0 时输出就是输入本身网络就自动退化成恒等映射。这意味着深层网络的每一层至少不会比浅层更差。这种设计从根本上缓解了优化困难让上百层的网络真正训练得动。我构建 ResNet 不是单纯复现而是想弄清楚每一行代码背后的道理。下面整理的这些内容基本覆盖了我从零手写 ResNet 到实际训练的全过程包括模块拆解、代码实现、参数推导、踩坑记录和训练心得。2. 核心模块拆解残差块里的学问比想象中多2.1 两种残差块BasicBlock 与 BottleneckResNet 的基础组件是残差块不同的层数配置对应不同的块类型。18 层和 34 层的 ResNet 使用 BasicBlock结构比较简单两个 3×3 卷积中间夹一个 BN 和 ReLU最后把输入和第二个卷积的输出相加再过一个 ReLU。而 50 层以上的 ResNet 使用 Bottleneck因为网络加深后计算量激增必须压缩中间层的通道数来控制计算成本。Bottleneck 是三层结构先用 1×1 卷积降维通常降到输入通道的 1/4再用 3×3 卷积做特征提取最后用 1×1 卷积恢复维度。这样设计的好处很明显3×3 卷积在更小的通道数上计算整体参数量和计算量大幅下降。以 ResNet-50 为例如果不使用 Bottleneck 而是堆叠 BasicBlock参数量会膨胀好几倍训练速度和显存占用都很难接受。Bottleneck 实际上是“先压缩、再处理、后恢复”的思想跟编解码结构的思路一脉相承。这里是两种残差块的结构对比项目BasicBlockBottleneck适用网络ResNet-18 / ResNet-34ResNet-50 / ResNet-101 / ResNet-152卷积层数2 层 3×33 层1×1 3×3 1×1通道变化输入输出通常一致中间通道 输入 1/4输出通常放大或不变参数量小相对更节省适合深层网络计算瓶颈无重点1×1 降维后 3×3 卷积是计算主体2.2 恒等映射的两种连接方式残差块的第二个关键设计是跨层连接。当输入和输出维度一致时直接相加identity shortcut当维度不一致时需要额外的操作让输入匹配输出维度。我在最开始写代码时简单地以为把 padding 改一下就行结果发现这种做法虽然能达到维度对齐的目的但会丢失信息。正确的做法有两种零填充zero-padding在输入张量的空间维度补零。优点是没有额外参数缺点是填充部分不包含有效特征等价于“浪费”了一半通道。实践中通常只用在对空间分辨率变化敏感的轻量场景。1×1 卷积投影projection shortcut用 1×1 卷积把输入通道数映射到与输出一致。这会引入少量参数输出通道数 × 输入通道数但能保证信息不丢失。常规实现中下采样都用这种方式配合 stride2 同时降低空间分辨率。这里有一个关键细节ResNet 的空间下采样不是通过池化完成而是通过 stride2 的卷积直接完成。在原始论文中下采样残差块的第一个卷积使用 stride2这样既能降低分辨率又不会引入额外的池化层。但在不同版本的实现里有所差异有的把 stride2 放在第一个 1×1 卷积有的放在中间 3×3 卷积。我后面会展开说这个问题因为它影响最终特征图的空间对应关系。2.3 激活函数与 BN 层的排布细节残差块的内部顺序直接决定了训练稳定性和收敛速度。最经典的排法是Conv → BN → ReLU。我做了几组对照实验发现如果把 BN 放在激活函数之后或者把 ReLU 放在残差相加之后收敛速度都会明显变慢。在残差块内部我倾向于把顺序固定为def basic_block(x, in_planes, planes, stride1): identity x out conv3x3(x, in_planes, planes, stride) out bn(out) out relu(out) out conv3x3(out, planes, planes, 1) out bn(out) # 维度不匹配时的投影或填充 if stride ! 1 or in_planes ! planes: identity conv1x1(x, in_planes, planes, stride) identity bn(identity) out identity out relu(out) return out注意最后一个卷积的输出通过 BN 后直接与恒等分支相加在这之前不经过 ReLU。这样做的原因是残差相加的输入范围不再是 [0, ∞)而是可以包含负值的完整实数域。如果先经过 ReLU负值全部被裁剪相当于丢掉了残差学习中的反向更新信息会严重拖慢收敛。2.4 为什么是“先 BN 后 ReLU”而不是“先 ReLU 后 BN”这个问题有段时间困扰过我。从直觉上先激活再归一化似乎也可以但在实际调试中发现了明显的差别。BN 的统计量计算是在卷积输出的分布上进行的并且会引入可学习的缩放系数 γ 和偏移 β。如果把 ReLU 放在前面输入到 BN 的数据已经被强制截断为非负这会改变 BN 试图归一化的分布形态尤其是在深层网络中误差会逐层累积最终表现为训练不稳定。一个更形象的类比BN 的作用相当于“把卷积输出拉回到正常区间”而 ReLU 的作用是“把不需要的负信号裁掉”。先拉回再裁掉网络能专注于有效信号的加工先裁掉再拉回等于裁掉之后又来一次重新分布整体的信息利用率更差。3. 从残差块到完整网络每一层的计算我都给你算清楚3.1 主体结构卷积层、Stage 与通道数变化ResNet 的宏观骨架是一个 stem最初的若干层 四个 stage特征提取阶段 分类头。每个 stage 由多个残差块堆叠而成stage 之间通道数翻倍、空间分辨率减半。以 ResNet-18 为例完整结构是Stem7×7 卷积stride264 通道接 BN ReLU随后 3×3 max poolingstride2。最大池化之后空间分辨率变为原来的 1/4。Stage 12 个 BasicBlock64 通道分辨率保持不变。Stage 22 个 BasicBlock第一个 stride2128 通道分辨率降为原来的一半。Stage 32 个 BasicBlock第一个 stride2256 通道。Stage 42 个 BasicBlock第一个 stride2512 通道。分类头全局平均池化 全连接层输出为类别数。ResNet-34 的区别在于每个 Stage 的块数更多分别是 3、4、6、3。ResNet-50 则换成 Bottleneck 结构块数为 3、4、6、3。3.2 完整网络构建的代码骨架如果让我按工程化的方式写边写边验证是很好的习惯。下面这份代码是基础版本def resnet18(num_classes1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes) def resnet34(num_classes1000): return ResNet(BasicBlock, [3, 4, 6, 3], num_classes) def resnet50(num_classes1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes)这里的[2, 2, 2, 2]就是四个 Stage 各自包含的残差块数量。ResNet-101 对应[3, 4, 23, 3]ResNet-152 对应[3, 8, 36, 3]。ResNet 构建函数如下class ResNet(nn.Module): def __init__(self, block, layers, num_classes1000): super().__init__() self.in_planes 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) self._initialize_weights() def _make_layer(self, block, planes, num_blocks, stride): strides [stride] [1] * (num_blocks - 1) layers [] for s in strides: layers.append(block(self.in_planes, planes, s)) self.in_planes planes * block.expansion 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中strides [stride] [1] * (num_blocks - 1)的意思是每个阶段内部的第一个残差块负责下采样stride2后面的残差块保持分辨率stride1。这一点很重要——下采样动作只在每个 Stage 的入口发生一次后续块都在同一分辨率上做特征提纯。3.3 参数量与计算量推算网络构建过程中我养成一个习惯每搭完一个模块用torchsummary或者其他方式统计一次参数量核对与论文是否一致。以 ResNet-18 为例手动推算参数量Stem 的 7×7 卷积3 × 64 × 7 × 7 ≈ 0.94 万Stage 1 两个 BasicBlock每个块两个 3×3 卷积64 通道参数量为 2 × (64×64×3×3) ≈ 7.37 万两个块约 14.74 万Stage 2 两个 BasicBlock第一个块包含 1×1 投影合计约 29.5 万Stage 3 两个 BasicBlock约 59 万Stage 4 两个 BasicBlock约 118 万全连接层512 × 1000 ≈ 51.2 万合计约 1168 万左右。PyTorch 实际统计值约 11689512手动推算与它非常接近。这种量级的手算在 ResNet-50 上更值得做一次因为 Bottleneck 的 1×1 卷积参数要简单得多计算式是in×out×1×1。3.4 Conv2d 参数里容易忽略的细节在 Conv2d 中有一个容易被忽略的陷阱bias参数的默认值是True。然而当卷积后紧跟 BN 时卷积输出被 BN 减去均值再除以标准差bias 的作用会被完全抵消相当于白白增加参数量。所以在 ResNet 的全部卷积层中我都会设置biasFalse。所有构建模块里的卷积统一用这个参数。BN 层自身带有可学习的 γ 和 β不需要卷积额外提供偏置。这也是为什么我在代码里反复强调biasFalse的原因。4. 关键设计细节stride、padding 与下采样路径的选择4.1 空间尺寸变化计算卷积层的输出尺寸公式是⌊(W - kernel_size 2×padding) / stride⌋ 1。ResNet 中输入 224×224经过 stem 7×7 stride2 后⌊(224-76)/2⌋ 1 112max pooling 3×3 stride2 后⌊(112-32)/2⌋ 1 56Stage 2 入口的 stride2 卷积后从 56 降到 28Stage 3 入口28 → 14Stage 4 入口14 → 7全局平均池化后1×1如果你的输入尺寸不是 224×224比如 CIFAR-10 的 32×32这些数字会全部变化。我实际测试过如果把 32×32 的输入直接塞进标准 ResNet-18经过 stem 后已经变成 8×8经过最大池化后再变成 4×4此时 Stage 4 的卷积根本无法正常计算。所以训练小分辨率数据集时要么去掉 stem 的最大池化要么直接调整 stride。这是初学者最容易踩的第一个坑。4.2 下采样路径的两种实现差异PyTorch 官方实现和 TensorFlow 的 Keras 实现在下采样残差块的位置上存在差异。以 50 层网络为例PyTorch 实现通常把 stride2 放在 Bottleneck 第一个 1×1 卷积上部分实现把 stride2 放在中间的 3×3 卷积上两者的区别在于前者先降分辨率再降通道数后者先降通道数再做空间降采样。从梯度流动的角度看前者的梯度传播路径更直接整体更稳定。我在两套思想上分别做过实验相同数据集、相同超参数下前者的收敛略微慢一点但泛化更稳后者收敛快一些但训练初期容易出现震荡。实际工程中跟随你所使用框架的主流实现是更省心的选择。如果你从零手写建议沿用 PyTorch 官方风格stride 在 1×1 卷积上。4.3 恒等分支是“必须”还是“可选”从 2020 年之后的一些研究来看残差分支被替换或删除后网络依然能训练但性能会有变化。这说明恒等分支的核心价值不只是“保底性能”它还在反向传播时提供了一条高速通道让梯度能够“直通”到输入层附近。有研究者对恒等映射做了简化实验把捷径中的 1×1 卷积换成缩放因子或者加入 dropout结果都出现不同程度的性能下降。恒等映射之所以最优是因为它保证了信息的无损传递。如果捷径需要变换性能必然打折。实际经验是能用恒等直接相加就不要加任何非线性变换。5. 训练准备初始化、优化器与超参数5.1 Kaiming 初始化防止信号衰减的“最后一公里”ResNet 使用 ReLU 激活权重初始化需要匹配这种非线性特性。PyTorch 官方实现中包含了_initialize_weights方法使用 Kaiming 均匀分布初始化卷积层和全连接层BN 层的 γ 初始化为 1β 初始化为 0。def _initialize_weights(self): for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) if m.bias is not None: nn.init.constant_(m.bias, 0) 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)这里modefan_out是专门配合 ReLU 的设定。它根据输出通道数来缩放初始化方差保证前向传播时每层的输出方差大致保持不变。如果不做这一步深层网络前几层的输出可能在第一次前向传播时就发生数值爆炸或衰减跟有没有残差连接是两回事。我自己做过对比实验相同网络结构、相同数据不用初始化直接训练loss 在初期的下降速度明显慢于经过 Kaiming 初始化的版本。5.2 优化器与学习率曲线ResNet 训练中的默认选择是 SGD Momentum权重衰减设置为 1e-4。现在的很多项目上来就是 Adam但在图像分类任务上SGD Momentum 的最终精度往往更高。原因在于Adam 的自适应学习率会导致参数在后期震荡难以收敛到平坦的极小值区域。我在 CIFAR-10 和自定义数据集上都对比过优化器收敛速度最终准确率同 epoch稳定性SGD (momentum0.9)略慢更高稳定Adam快略低有时震荡学习率策略方面我常用的两个方案阶梯下降初始 0.1分别在 30、60、80 epoch 时除以 10余弦退火从 0.1 到 0.00001全程平滑下降在有限预算下阶梯下降更容易判断收敛趋势。训练初期大学习率帮助网络快速离开初始状态后续小学习率做精细调整。用小学习率从头训练深层网络前期 loss 下降会很慢还可能陷入平稳区域。5.3 Batch Size 对 BN 的影响BN 的统计量依赖当前 batch 的均值和方差。Batch Size 太小统计噪声大训练不稳定Batch Size 太大虽然统计稳定但每步迭代意味着更少的更新次数。一个可参考的经验值单卡 8GB 显存224×224 输入ResNet-50Batch Size 约 32ResNet-18Batch Size 可以到 128如果 Batch Size 必须很小比如 8建议考虑使用 Group Normalization 替代 BN另外测试时 BN 用的是训练阶段的滑动平均统计量。如果训练过程中保存和加载的模型不一致例如训练时改变了 Batch Size测试结果可能出现波动。如果遇到这类问题把model.eval()的处理逻辑确认一遍很有必要。6. 训练中踩过的坑写代码时容易忽略的隐性规则6.1 32×32 输入下Stage 4 无法前向传播这是我早期吃过大亏的一处。在 CIFAR-10 上使用标准 ResNet-18输入 32×32经过 stem 和 maxpool 后得到 8×8。第一个 Stage stride1 保持 8×8第二个 Stage stride2 变成 4×4第三个 Stage 变成 2×2第四个 Stage 入口 stride2此时输出尺寸为⌊(2-32)/2⌋ 1 1勉强能算但特征图已经太小语义信息严重受损。实际训练时网络在 CIFAR-10 上面收敛很慢准确率远低于其他实现。后来比对公开代码才发现需要修改 stem 的下采样策略self.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) self.bn1 nn.BatchNorm2d(64) self.maxpool nn.Identity() # 去掉最大池化这样输入 32×32经过第一个卷积后仍为 32×32四个 Stage 依次降采样到 4×4整体结构依然完整。6.2 BN 的 Running Mean 偏差导致测试精度暴跌训练完成后在测试集上评估模型时发现准确率比训练集低很多我一度怀疑是数据泄露问题。排查到最后原因是 BN 在训练阶段维护的 running_mean 和 running_statistics 在切换评估模式时尚未收敛到合适值。更常见的触发条件是模型先训练好了但训练时做过 warmup、中途调整过学习率或是在测试脚本中对输入做了与训练不同的预处理比如忘记归一化。这里最直接的验证方式是把模型在测试前重新前向传播一次观察bn1.running_mean的数值是否接近 0如果不是检查预处理管道。6.3 显存不足时的三个排查方向显存不够是家常便饭。但问题不一定出在模型本身。输入张量是否已经转移到 GPU是否同时在前向传播时保留了中间张量例如调试时打印多个 feature map优化器内部是否保存了额外的梯度状态还有个冷门原因如果 DataLoader 的num_workers开得大也可能导致内存占用高。显存不足和内存不足不一样很多人在这一块搞混。6.4 深层网络的梯度爆炸问题虽然残差结构缓解了梯度消失但 ResNet-152 级别的深层网络在训练初期仍然可能遇到损失突然变成 NaN 的情况。排查顺序一般是检查输入数据是否包含 NaN 或异常值检查学习率是否过大检查卷积层的数值范围最后考虑是否在 forward 过程中不小心对同一张量进行了原地修改导致计算图破坏我实际遇到的一次是自定义数据处理时忘记对图像做归一化像素值直接是 0–255 的原始范围。输入到网络后第一个卷积输出方差巨大BN 统计量在初期不稳定训练到第 20 个 epoch 时直接 NaN。归一化后问题彻底消失。7. 不同层数的 ResNet 在你自己的任务中该怎么选7.1 图像分类作为基础任务时的选择逻辑如果你是新手第一次接触 ResNet建议从 ResNet-18 开始。参数量小、训练快、容易调试遇到问题也好定位。等思路理清楚了再上 ResNet-50。ResNet-50 是性价比最高的版本。它的参数量适中模型容量足够大在各类视觉任务上都能作为 backbone 使用。很多目标检测、语义分割项目默认采用 ResNet-50。ResNet-101 适合大模型预算充裕、数据量充足、对精度有更高要求的场景。ResNet-152 纯粹为了让实验数据更“好看”时才会用到实际工程应用不多。选择参考标准模型参数量224×224 单图推理耗时V100 参考适用的数据规模ResNet-18约 11M快小数据集 / 快速验证ResNet-50约 25M中等中等数据集 / 通用 backboneResNet-101约 44M较慢大数据集 / 高精度要求ResNet-152约 60M慢极为充足的数据与算力7.2 小数据为什么不适合直接上深层网络如果你的分类任务只有几千张图我不建议直接上 ResNet-101。模型容量过大小数据集很容易出现过拟合训练集准确率接近 100%测试集却一塌糊涂。此时先用 ResNet-18 训练出基线再用预训练模型做微调是更稳妥的路径。预训练模型在这种场景中非常实用因为它的底层特征对通用图像是有效的。在大数据集上预训练的权重已经学会了边缘、纹理等低层特征微调时可以保留这些通用特征只更新高层语义相关的部分。7.3 从分类迁移到目标检测 / 语义分割ResNet 作为骨干网络时通常直接复用其 Stage 1-4 的特征输出再接入目标检测头或分割头。因此构建 ResNet 时有一个额外要求Stage 的各层输出要能被外部模块访问。基于这个原因我习惯于把 ResNet 的 forward 返回值设计为多个 Feature Map 的组合def forward(self, x): x self.conv1(x) x self.bn1(x) x self.relu(x) x self.maxpool(x) c2 self.layer1(x) c3 self.layer2(c2) c4 self.layer3(c3) c5 self.layer4(c4) return c2, c3, c4, c5这样设计后接入 FPN 或检测头时不需要改动内部结构。如果你从一开始就规划好这个接口后面迁移到检测、分割任务时会非常顺利。8. 从手写 ResNet 到实际项目的一点体会构建一次完整的 ResNet跟调包跑通的体验完全不一样。你会理解为什么每个卷积后面要跟 BN为什么通道数要这样设计为什么下采样要放在每个 Stage 的第一个残差块。这些知识在调试深层模型时都是一点一点积累起来的方向感。我在实际项目中最大的体会是模型代码只是整个训练流程的一环数据管线、预处理、优化策略、评估逻辑任何一个环节出问题都可能让模型性能表现糟糕。盲目改网络结构之前先确认数据流是否正常。如果你现在正准备手写 ResNet我给你的建议是先把 BasicBlock 写对、跑通、可视化检查特征图再扩展到 Bottleneck。不要一开始就追求把 ResNet-152 复现出来。基础模块扎实了后面的内容才谈得上真正掌握。最后分享一个小技巧在调试时会经常需要核对中间特征图的尺寸我喜欢在关键层后面加一个断点打印x.shape。通过形状变化能很快判断出 stride、padding 哪一步出了问题。别嫌土这个办法在实战中比对着代码空想要高效得多。