news 2026/10/17 3:28:29

PyTorch官方ResNet源码拆解:从残差块设计到训练细节

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch官方ResNet源码拆解:从残差块设计到训练细节

把 ResNet 论文里的残差结构图变成能跑起来的训练代码,中间其实隔着一大堆设计决策。我第一次照着论文手写复现时,写出来的模型能 forward,但训练曲线和公开基线始终对不上,后来把 torchvision 官方 resnet.py 逐行读了一遍,才发现从 block 的 expansion、shortcut 的 downsample 条件,到 BN 初始化和 ReLU 到底该放在哪,每一个细节都有讲究。这篇文章就以 PyTorch 官方实现为核心,从整体骨架到每个方法逐步拆解,适合已经会用 PyTorch 搭简单 CNN、想真正弄懂经典分类网络代码的同学。读完你不仅能看懂官方代码,还能知道哪些地方可以改、哪些地方最好不要动。

1. 从论文里的残差图到resnet.py:官方代码的整体骨架

1.1 论文图画的是原理,代码写的是工程

任何一篇讲 ResNet 的文章,都会给你看那张经典的残差块示意图:两个 3x3 卷积叠加,旁边一条恒等映射短接线,最后相加再过个 ReLU。图确实简单,但你要是照着图直接写代码,马上会遇到三个图里完全没交代的问题:特征图尺寸减半的时候,恒等映射的 x 怎么和主分支输出相加?通道数翻倍的时候,两边形状都不一样,加法根本做不了?堆到 50 层以上的时候,两个 3x3 卷积的算力开销还扛得住吗?

官方 resnet.py 里那些看起来"多出来"的东西——downsample、expansion、_make_layer 工厂方法,本质上都是在解决这些工程问题。它不只是一份代码,更是 ResNet 从论文走向可训练模型的那座桥。

1.2 先看清全局:resnet.py 由四类代码组成

打开 torchvision 里的 resnet.py,整份文件可以归成四个层次:

  • 辅助函数:conv3x3、conv1x1,负责统一声明卷积层;
  • 残差块类:BasicBlock(浅层用)和Bottleneck(深层用);
  • 网络类:ResNet,负责把残差块组织成 layer1 到 layer4;
  • 入口函数:resnet18、resnet34、resnet50、resnet101、resnet152,返回组装好的模型。

先看两个辅助函数,虽然只有三行,但信息量不小:

def conv3x3(in_planes, out_planes, stride=1, groups=1, dilation=1): return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride, padding=dilation, groups=groups, bias=False, dilation=dilation) def conv1x1(in_planes, out_planes, stride=1): return nn.Conv2d(in_planes, out_planes, kernel_size=1, stride=stride, bias=False)

注意这里bias=False是统一的,而且在后面几乎每个卷积层都是bias=False。原因很简单:这些卷积后面全都跟着一个 BN 层,BN 会对每个通道做归一化,再做一次仿射变换(可学习的 scale 和 shift),卷积自带的 bias 会被 BN 的 shift 完全吸收掉。留着它不但多余,还会让两个 bias 在优化时互相拉扯,不如一开始就关掉。

另外,这两个函数把groups、dilation这些参数提前暴露出来,是为了给后续的 ResNeXt 风格分组卷积和空洞卷积留接口,后面会细说。

2. BasicBlock与Bottleneck:两种残差块的代码走读与设计逻辑

2.1 BasicBlock:浅层残差块的标准写法

resnet18 和 resnet34 用的都是 BasicBlock,完整代码是这样的:

class BasicBlock(nn.Module): expansion = 1 def __init__(self, inplanes, planes, stride=1, downsample=None, groups=1, base_width=64, dilation=1, norm_layer=None): super().__init__() if norm_layer is None: norm_layer = nn.BatchNorm2d if groups != 1 or base_width != 64: raise ValueError("BasicBlock only supports groups=1 and base_width=64") if dilation > 1: raise NotImplementedError("Dilation > 1 not supported in BasicBlock") self.conv1 = conv3x3(inplanes, planes, stride) self.bn1 = norm_layer(planes) self.relu = nn.ReLU(inplace=True) self.conv2 = conv3x3(planes, planes) self.bn2 = norm_layer(planes) self.downsample = downsample self.stride = stride 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

expansion = 1表示这个块的输出通道数就是planes本身。inplanes是输入通道数,planes是当前阶段的基础宽度,两者是两套概念,容易混。

forward 的顺序值得反复看几遍:先在conv1上执行卷积,stride允许不为 1,这正是一个 stage 第一个 block 做空间下采样的入口;然后 BN、ReLU,再conv2、BN。这里注意,conv2之后没有立刻接 ReLU,而是把identity(也就是原始输入 x)加过来,最后才统一过一个 ReLU。

为什么 ReLU 要放在加法之后?因为残差结构的目的就是让梯度能通过短接线直接回传。如果先对主分支输出做 ReLU 再做加法,残差路径上就多了一个非线性门控,恒等映射的梯度会被破坏。虽然大多数文章都默认你懂,但实际复现时真的有人把顺序写反,训起来各种不稳定。

nn.ReLU(inplace=True)也是个细节。inplace 操作会直接修改输入 tensor,省一份激活值的内存,在深网络里能明显降低显存占用。官方代码敢这么用,是因为 identity 在进入卷积之前就被保存了,而 ReLU 修改的是out,不会污染后面还要用的x。你自己改造时也要注意:如果某处 ReLU 之后输入还要继续用,别轻易开 inplace。

2.2 Bottleneck:1x1压缩、3x3卷积、1x1扩展的完整解读

resnet50 及以上的深度网络用 Bottleneck:

class Bottleneck(nn.Module): expansion = 4 def __init__(self, inplanes, planes, stride=1, downsample=None, groups=1, base_width=64, dilation=1, norm_layer=None): super().__init__() if norm_layer is None: norm_layer = nn.BatchNorm2d width = int(planes * (base_width / 64.)) * groups self.conv1 = conv1x1(inplanes, width) self.bn1 = norm_layer(width) self.conv2 = conv3x3(width, width, stride, groups, dilation) self.bn2 = norm_layer(width) self.conv3 = conv1x1(width, planes * self.expansion) self.bn3 = norm_layer(planes * self.expansion) self.relu = nn.ReLU(inplace=True) self.downsample = downsample self.stride = stride 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 out

Bottleneck 的思路是"降维—卷积—升维"。第一个 1x1 把inplanes压到width,中间的 3x3 只在降维后的通道上做空间卷积,最后一个 1x1 再扩到planes * 4。expansion = 4意味着这个块的最终输出通道是输入计划的 4 倍,比如 stage2 里 planes=64,输出就是 256。

值得注意的细节是:stride被放在第二个卷积conv2(3x3)上,而第一个 1x1 和最后一个 1x1 的 stride 都是 1。这和 BasicBlock 把 stride 放在第一个 3x3 上不一样。原因是:先把通道压缩了,再下采样,信息损失更小;同时 3x3 卷积是空间操作,由它来做尺寸减半最自然。如果你改造时把这个 stride 挪到第一个 1x1 上,功能上不算错,但会改变信息流动的路径,和官方设计意图就偏了。

另外注意 forward 里conv3之后没有再接 ReLU,直接出 BN 就等着和 identity 相加了。Bottleneck 内部只在conv1和conv2后面接 ReLU,整体还是「主分支 + 短接 → 加法 → ReLU」的结构。

2.3 为什么50层以下用BasicBlock、50层以上用Bottleneck

根本原因是计算效率。BasicBlock 里是两个 3x3 卷积,假设输入输出都是 C 通道,一个块的参数量是2 × 9 × C²。Bottleneck 是 1x1、3x3、1x1 三个卷积的组合,中间通道被大幅压缩,整体参数量和计算量都显著低于两个 3x3 直接堆叠。

可以对比两个常用网络的体量:ResNet-34 全部用 BasicBlock,参数量大约 21.8M,计算量 3.6 GFLOPs;ResNet-50 深度多了 16 层,改用 Bottleneck 后参数量 25.6M,计算量 4.1 GFLOPs。深度几乎翻了一倍,开销只涨了一点点,这就是 Bottleneck 的价值。

下表把两种残差块的核心差异列在一起:

对比项BasicBlockBottleneck
适用网络resnet18 / resnet34resnet50 / resnet101 / resnet152
卷积组合3x3 + 3x31x1 + 3x3 + 1x1
expansion14
stride 位置第一个 3x3中间的 3x3
中间通道保持 planes压缩到 width 再扩展

3. _make_layer工厂方法:downsample的触发条件和堆叠机制

3.1 downsample的判定:stride不为1,或通道数对不上

_make_layer是 ResNet 类里最核心的搭建方法,值得单独拆开看:

def _make_layer(self, block, planes, blocks, stride=1, dilate=False): norm_layer = self._norm_layer downsample = None previous_dilation = self.dilation if dilate: self.dilation *= stride stride = 1 if stride != 1 or self.inplanes != planes * block.expansion: downsample = nn.Sequential( conv1x1(self.inplanes, planes * block.expansion, stride), norm_layer(planes * block.expansion), ) layers = [] layers.append(block(self.inplanes, planes, stride, downsample, self.groups, self.base_width, previous_dilation, norm_layer)) self.inplanes = planes * block.expansion for _ in range(1, blocks): layers.append(block(self.inplanes, planes, groups=self.groups, base_width=self.base_width, dilation=self.dilation, norm_layer=norm_layer)) return nn.Sequential(*layers)

downsample 的创建条件只有一句话:stride != 1或者self.inplanes != planes * block.expansion。拆开理解:

  • stride != 1:说明这个 stage 要开始空间下采样了,特征图的宽高要减半。此时主分支输出是 H/2、W/2,而短接线直接拿原尺寸的 x 过来没法相加,必须做一个带 stride 的投影把尺寸对齐;
  • self.inplanes != planes * block.expansion:输入通道数和 block 输出通道数对不上,最常见的原因是 expansion。即使这一层不做下采样,只要通道数不一致,也必须用 1x1 卷积把短接线升到目标通道。

两个条件里任何一个成立,都会生成一个Sequential(conv1x1 + norm_layer)作为 downsample。注意这个 1x1 卷积的 stride 和 stage 的 stride 保持一致,正好同时完成空间和通道两个维度的对齐。

3.2 第一个block带条件参数,后面的block走默认

_make_layer的另一个设计巧妙点在于循环。第一个 block 被构造时,同时传入stride和downsample,专门处理尺寸和通道的变化。构造完之后,马上执行self.inplanes = planes * block.expansion,把网络当前通道数更新到这个 stage 的目标通道数。

此后循环里再构造的blocks - 1个 block,就不再传 stride 和 downsample,全部走默认值(stride=1、downsample=None)。因为此时输入通道已经和 block 输出通道一致,尺寸也没变化,短接线直接做恒等相加就行。

这种写法把"变化的第一个块"和"静止的后续块"分开处理,逻辑非常紧凑。但要注意self.inplanes是实例变量,_make_layer通过修改它来影响后续调用,属于典型的有状态工厂方法。初次读代码时容易被这种隐式状态绕晕,理解之后就会觉得确实方便。

3.3 组装resnet50:把配置列表翻译成真实网络

以 resnet50 为例,配置是Bottleneck + [3, 4, 6, 3],也就是 layer1 到 layer4 分别有 3、4、6、3 个 Bottleneck。整个组装过程如下:

  • 初始self.inplanes = 64,stem 输出就是 64 通道;
  • layer1:planes=64,blocks=3,stride=1。第一个 block 时inplanes=64不等于64×4=256,虽然 stride 不变,但第二个条件成立,所以照样生成 downsample。这个块把 64 通道升到 256 通道。之后inplanes变为 256,剩下 2 个 block 全走恒等短接;
  • layer2:planes=128,blocks=4,stride=2。此时inplanes=256,目标输出128×4=512,同时 stride 是 2,两个条件都满足,生成带 stride 的 downsample。之后inplanes=512,剩下 3 个 block 不动;
  • layer3:planes=256,blocks=6,stride=2,同理从 512 升到 1024;
  • layer4:planes=512,blocks=3,stride=2,从 1024 升到 2048。

很多初学者以为 downsample 只出现在 stage 之间,其实 layer1 的第一个 Bottleneck 就有,因为扩通道这件事从第一层就开始了。

4. forward全过程推演:从224x224输入到分类输出

4.1 主干前的"stem":7x7卷积加最大池化的组合

ResNet 类里负责前处理的部分只有几行代码,但信息密度很高:

self.conv1 = nn.Conv2d(3, self.inplanes, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = norm_layer(self.inplanes) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)

对于 224x224 的输入,7x7 卷积 stride=2 把分辨率从 224 变成 112,紧接着 3x3 maxpool stride=2 又变成 56。两步加起来完成 4 倍下采样,但只有四个操作,计算量很小。7x7 大卷积的优势是初始感受野大,对分类任务很友好;如果你要把代码改到 CIFAR 这种 32x32 小图上,这一段通常要换掉,后面会细说。

4.2 四个stage的尺寸变化推演:以resnet50为例

从输入到输出,resnet50 的特征图变化可以这样列出来:

阶段输入尺寸第一个block的stride输出尺寸输出通道
stem224x224x3-56x5664
layer156x56x64156x56256
layer256x56x256228x28512
layer328x28x512214x141024
layer414x14x102427x72048
分类头7x7x2048-1x11000

最后的分类头是AdaptiveAvgPool2d((1, 1))加Linear(512 * block.expansion, num_classes)。对 resnet50,block.expansion=4,所以全连接输入是 2048;对 BasicBlock 类网络,这个值是 512。

AdaptiveAvgPool2d((1,1))的好处是不管输入分辨率是多少,都能池化成 1x1,这让网络天然能接受不同尺寸的输入,只要你自己注意下采样倍率就行。

4.3 残差相加时两条路径如何对齐

残差相加是 ResNet 的数学本质:输出 = 主分支(x) + 短接线(x)。两条路径的分工不一样:

  • 主分支:一连串 conv、BN、ReLU,负责学习残差;
  • 短接线:默认啥也不干,直接返回原始 x;只有遇到下采样或扩通道时,才换成conv1x1 + BN。

当短接线需要动的时候,官方用的是 1x1 卷积,不是 pooling 加零填充。1x1 卷积的好处是:同样带 stride 时它天然能下采样,同时又能通过改变输出通道数来完成对齐,一步到位。加上的那个 BN 会把短接线的特征归一化,让两条路径的量纲尽可能一致。

一个常被忽略的点是:短接线上是不接激活函数的。以后你做自定义网络时,记住短接线应该尽量保持"近乎恒等"的属性,任何非线性都可能干扰梯度回传。

5. 初始化与预留参数:官方代码里容易被略过的工程细节

5.1 模型构建后的默认初始化

ResNet 类在__init__末尾有一段初始化逻辑:

for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') elif isinstance(m, (nn.BatchNorm2d, nn.GroupNorm)): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0)

卷积权重用kaiming_normal_,并且mode='fan_out'、nonlinearity='relu'。这里有两个关键选择:nonlinearity='relu'是因为整个网络大量使用 ReLU,初始化要考虑这个非线性对激活值方差的影响;mode='fan_out'则是让反向传播时的梯度方差更稳定,配合 BN 用起来训练更顺。

BN 的权重初始化成 1、偏置初始化为 0,相当于每个 BN 层在训练刚开始时近似一个恒等归一化。这样网络起点比较温和,不会一开始就输出夸张的数值。

5.2 zero_init_residual:最后一个BN置零的小技巧

官方还给 ResNet 留了一个zero_init_residual参数,开启后会多一段逻辑:

if zero_init_residual: for m in self.modules(): if isinstance(m, Bottleneck): nn.init.constant_(m.bn3.weight, 0) elif isinstance(m, BasicBlock): nn.init.constant_(m.bn2.weight, 0)

含义是把每个残差块最后一个 BN 的 scale 初始化为 0。这样一来,每个 block 在训练第一步的输出就是"恒等映射 + 0 贡献的主分支",整个深度网络起步时表现得像一个浅网络,梯度能毫无阻碍地流过所有短接线。

我在实际训练里的体会是,这个技巧对 101 层以上的深网络效果更明显,能明显改善训练初期的稳定性。官方默认是 False,如果训练起来掉点或者前几步 loss 震荡,可以把它打开试试,代码不用改。

5.3 groups、width_per_group、replace_stride_with_dilation:三个"预留接口"参数

这三个参数平时用默认值,但它们才是官方代码可扩展性的关键。

groups和width_per_group是给 ResNeXt 风格的分组卷积准备的。标准 ResNet 里groups=1、base_width=64,Bottleneck 的中间宽度width = int(planes * (64/64)) * 1 = planes。如果你传入groups=32、width_per_group=8,中间宽度就变成planes/8 × 32,对应 ResNeXt 的分组设计。BasicBlock 里直接抛异常拒绝这两个参数,因为浅层块不支持分组结构。

replace_stride_with_dilation是给语义分割、目标检测这类需要高分辨率特征图的任务留的后门。传[False, True, True]时,layer2 和 layer3 的dilate=True,原来的 stride=2 被改成 1,同时空洞率逐层翻倍。这样特征图分辨率不会一路降到 1/32,而是停在 1/16,是很多分割框架直接拿这个参数做骨干的原因。

6. 读代码踩过的坑与动手改造的实操建议

6.1 三处最容易误解的细节

我见过不少同学在复现 ResNet 时栽在同样的地方,这里集中说一下。

第一,layer1 的第一个 block 也有 downsample。很多人以为只有跨 stage 才有短接投影,实际上只要通道数对不上,逐层网络的第一层就会出现 downsample,哪怕不下采样。

第二,Bottleneck 的 stride 放在 3x3 卷积上,不是第一个 1x1。如果你迁移到别的任务时想调整下采样位置,先想清楚你要改变的是空间分辨率发生的位置,还是通道数变化的位置。

第三,ReLU 在加法之后,不在加法之前。这是残差结构成立的基础,却是在代码里最容易悄悄写错的一行。你可以用一个小网络做个实验:把顺序对调,训练曲线通常会出现明显的收敛变慢。

6.2 我建议保留的官方写法与最小改动方案

改代码时,以下几条官方约定我建议尽量保留:

  • 卷积层保持bias=False,后面紧跟 BN;
  • 每个残差块内部保持 conv、BN、ReLU 的固定顺序;
  • 加法之后统一过 ReLU,短接线上不接激活;
  • 用_make_layer而不是手动拼 module list,减少维度错配的可能。

如果要把网络改到小图任务,最小改动是把 stem 的 7x7 stride=2 换成 3x3 stride=1,并去掉 maxpool。比如 CIFAR-10 的 32x32 输入,保留原来的 stem 会被一次性压到 8x8,信息损失太严重;换掉之后第一层输出保持 32x32,后续 stage 还能正常下采样。

迁移学习场景下,最常见操作就是换分类头:把model.fc替换成自己任务的输出维度;如果只想拿特征,直接在model.avgpool之前截断,取layer4的输出。不要在 forward 里乱改,官方代码的模块边界设计得很清晰,按边界切就行。

6.3 训练复现时的几个细节

用官方预训练权重时,一个非常容易踩的坑是忘记model.eval()。BN 在 train 和 eval 模式下行为完全不同,eval 下用的是训练时统计的 running_mean 和 running_var。如果你带着 BN 在 train 模式下直接推理,输出会和预训练模型的行为不一致,特征质量明显下降。

训练超参上,经典 ImageNet 设定是 batch size 256 配初始学习率 0.1。自己用小 batch 训练时,建议按 batch 比例线性缩放学习率,否则小 batch 配大学习率很容易训练崩。我个人的做法是先跑一个 resnet18 在小数据集上验证 forward 形状和 loss 是否正常下降,确认无误后再升级到 resnet50 或更深的网络,省去很多排查维度错的麻烦。

最后再分享一个读代码的小习惯:官方实现的每个参数都不是随便加的,读的时候多问一句"这个分支什么时候会触发"。把downsample的触发条件、expansion的用途、zero_init_residual的作用都过一遍,这份代码就不再是几百行"能跑的模块",而是一套完整的设计决策记录。之后你再去看 DenseNet、EfficientNet 这类结构的源码,会发现很多工程细节都是同一个套路,顺手就能读懂。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 3:28:00

敏捷与瀑布双轨融合:数字化项目混合式管理实战指南

我先说一个自己踩过的坑:好几年前做某跨平台系统的时候,业务方要求所有需求必须“白纸黑字签字确认”,研发团队却坚持“需求不可能一次想清楚,必须快速迭代”,两边在项目启动会上就杠起来了。我当时还天真地劝大家“选…

作者头像 李华
网站建设 2026/10/17 3:26:55

Linux网络性能排查实战:从TCP参数到抓包分析

没想到吧,最后的瓶颈居然在网络层——TCP握手超时、SYN队列打满、网卡软中断爆掉,这些你平时不怎么看的数据,真正出问题时能让你排查一整天。这篇笔记就从一个线上告警说起,整理一套我从"参数调优"到"请求分析&quo…

作者头像 李华
网站建设 2026/10/17 3:26:43

从补码到整数溢出:理解计算机中的二进制位模式与类型解释

1. 先从一次“诡异”的减一操作聊起大概很多人在写倒计时逻辑时都遇到过这种见鬼的事:一个普通的整数变量,减到 0 之后继续减,下一瞬间变成了 4294967295。第一次看到这个数字时我还以为程序算错了,甚至怀疑是内存被改。后来静下心…

作者头像 李华
网站建设 2026/10/17 3:26:08

Flask + NATAPP 内网穿透:零成本搭建临时文件共享服务

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 3:24:47

基于TCP Socket的猜拳游戏:从协议设计到异常处理实战

1. 从"石头剪刀布"说起:为什么用TCP写猜拳比你想的有意思很多人看到"猜拳游戏"四个字,第一反应是:这不就是几行if-else的事吗?确实,单机版的猜拳逻辑简单到不能再简单——随机数生成、比较大小、输…

作者头像 李华
网站建设 2026/10/17 3:24:33

Python二叉树深度优先遍历:递归与迭代写法及实战应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华