☰
手写ResNet:从残差块到完整网络的构建与训练实践
2026/10/15 7:20:55 网站建设 项目流程

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 与 Bottleneck

ResNet 的基础组件是残差块,不同的层数配置对应不同的块类型。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 卷积把输入通道数映射到与输出一致。这会引入少量参数(输出通道数 × 输入通道数),但能保证信息不丢失。常规实现中,下采样都用这种方式,配合 stride=2 同时降低空间分辨率。

这里有一个关键细节:ResNet 的空间下采样不是通过池化完成,而是通过 stride=2 的卷积直接完成。在原始论文中,下采样残差块的第一个卷积使用 stride=2,这样既能降低分辨率,又不会引入额外的池化层。但在不同版本的实现里有所差异,有的把 stride=2 放在第一个 1×1 卷积,有的放在中间 3×3 卷积。我后面会展开说这个问题,因为它影响最终特征图的空间对应关系。

2.3 激活函数与 BN 层的排布细节

残差块的内部顺序直接决定了训练稳定性和收敛速度。最经典的排法是:Conv → BN → ReLU。我做了几组对照实验,发现如果把 BN 放在激活函数之后,或者把 ReLU 放在残差相加之后,收敛速度都会明显变慢。

在残差块内部,我倾向于把顺序固定为:

def basic_block(x, in_planes, planes, stride=1): 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 为例,完整结构是:

  1. Stem:7×7 卷积,stride=2,64 通道,接 BN + ReLU,随后 3×3 max pooling,stride=2。最大池化之后,空间分辨率变为原来的 1/4。
  2. Stage 1:2 个 BasicBlock,64 通道,分辨率保持不变。
  3. Stage 2:2 个 BasicBlock(第一个 stride=2),128 通道,分辨率降为原来的一半。
  4. Stage 3:2 个 BasicBlock(第一个 stride=2),256 通道。
  5. Stage 4:2 个 BasicBlock(第一个 stride=2),512 通道。
  6. 分类头:全局平均池化 + 全连接层,输出为类别数。

ResNet-34 的区别在于每个 Stage 的块数更多:分别是 3、4、6、3。ResNet-50 则换成 Bottleneck 结构,块数为 3、4、6、3。

3.2 完整网络构建的代码骨架

如果让我按工程化的方式写,边写边验证是很好的习惯。下面这份代码是基础版本:

def resnet18(num_classes=1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes) def resnet34(num_classes=1000): return ResNet(BasicBlock, [3, 4, 6, 3], num_classes) def resnet50(num_classes=1000): 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_classes=1000): super().__init__() self.in_planes = 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) 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)的意思是:每个阶段内部的第一个残差块负责下采样(stride=2),后面的残差块保持分辨率(stride=1)。这一点很重要——下采样动作只在每个 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 的全部卷积层中,我都会设置bias=False。所有构建模块里的卷积,统一用这个参数。BN 层自身带有可学习的 γ 和 β,不需要卷积额外提供偏置。这也是为什么我在代码里反复强调bias=False的原因。

4. 关键设计细节:stride、padding 与下采样路径的选择

4.1 空间尺寸变化计算

卷积层的输出尺寸公式是⌊(W - kernel_size + 2×padding) / stride⌋ + 1。ResNet 中:

  • 输入 224×224,经过 stem 7×7 stride=2 后:⌊(224-7+6)/2⌋ + 1 = 112
  • max pooling 3×3 stride=2 后:⌊(112-3+2)/2⌋ + 1 = 56
  • Stage 2 入口的 stride=2 卷积后:从 56 降到 28
  • Stage 3 入口:28 → 14
  • Stage 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 实现通常把 stride=2 放在 Bottleneck 第一个 1×1 卷积上
  • 部分实现把 stride=2 放在中间的 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, mode='fan_out', nonlinearity='relu') 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, mode='fan_out', nonlinearity='relu') nn.init.constant_(m.bias, 0)

这里mode='fan_out'是专门配合 ReLU 的设定。它根据输出通道数来缩放初始化方差,保证前向传播时,每层的输出方差大致保持不变。如果不做这一步,深层网络前几层的输出可能在第一次前向传播时就发生数值爆炸或衰减,跟有没有残差连接是两回事。

我自己做过对比实验:相同网络结构、相同数据,不用初始化直接训练,loss 在初期的下降速度明显慢于经过 Kaiming 初始化的版本。

5.2 优化器与学习率曲线

ResNet 训练中的默认选择是 SGD + Momentum,权重衰减设置为 1e-4。现在的很多项目上来就是 Adam,但在图像分类任务上,SGD + Momentum 的最终精度往往更高。原因在于,Adam 的自适应学习率会导致参数在后期震荡,难以收敛到平坦的极小值区域。

我在 CIFAR-10 和自定义数据集上都对比过:

优化器收敛速度最终准确率(同 epoch)稳定性
SGD (momentum=0.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-50,Batch Size 约 32
  • ResNet-18,Batch 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 stride=1 保持 8×8,第二个 Stage stride=2 变成 4×4,第三个 Stage 变成 2×2,第四个 Stage 入口 stride=2,此时输出尺寸为⌊(2-3+2)/2⌋ + 1 = 1,勉强能算,但特征图已经太小,语义信息严重受损。

实际训练时,网络在 CIFAR-10 上面收敛很慢,准确率远低于其他实现。后来比对公开代码才发现,需要修改 stem 的下采样策略:

self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) 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 的情况。排查顺序一般是:

  1. 检查输入数据是否包含 NaN 或异常值
  2. 检查学习率是否过大
  3. 检查卷积层的数值范围
  4. 最后考虑是否在 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中等中等数据集 / 通用 backbone
ResNet-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 哪一步出了问题。别嫌土,这个办法在实战中比对着代码空想要高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询