资讯动态

PyTorch官方版ResNet源码拆解:从残差块到网络构建全解析

发布时间:2026/10/17 7:36:40 来源:尧图企业网站定制
读源码这件事我一直觉得是学习深度学习最划算的投资之一。尤其是ResNet的PyTorch官方实现代码不长、结构清晰但把现代CNN的很多工程细节都体现出来了残差连接的写法、BatchNorm与卷积的配合、stride和padding在降采样上的取舍、权重初始化……最近我又完整读了一遍干脆把关键部分拆开揉碎了写出来给同样在啃源码的同学参考。这篇文章适合两类人一是已经用过ResNet、但只在调用层转悠还没真正看过内部实现的读者二是面试前需要把经典模型讲清楚、得对着源码说原理的候选人。我已经把官方文件里的每个关键类、每个方法怎么运作的都梳理了一遍顺便补充了大量实际训练中会踩到的坑。整篇读下来你应该能完全自己手写一个ResNet而且知道每行代码为什么要这么写。1. 整体设计与源码结构拆解1.1 官方代码为什么值得读很多人觉得读源码难其实难点不在代码本身而在于不知道从哪儿看起。PyTorch的ResNet实现位于torchvision/models/resnet.py整个文件设计得非常模块化——先定义两个小函数再定义两种残差块最后拼成一个完整网络类。这种“自底向上搭建”的思路本身就是PyTorch建模型的经典范式学会了它你再去看其他模型的源码就会觉得顺眼很多。整个文件可以按“辅助函数 → 残差块 → 主干网络 → 构建入口”四个层次来划分。辅助函数处理最基础的卷积创建残差块负责搭建单个building block主干网络把残差块串联成完整的ResNet最后的构建入口通过参数控制生成不同层数的变体。四个层次各管一段逻辑非常清楚。1.2 模块层次与数据流概览我把官方代码的模块关系梳理了一下它们的关系大致是conv3x3和conv1x1两个极简的卷积封装函数所有残差块都基于它们搭建。BasicBlockResNet-18/34使用的基础残差块包含两个3x3卷积。BottleneckResNet-50/101/152使用的瓶颈残差块包含三个卷积通道数变化呈“降维-卷积-升维”的沙漏形。ResNet主干网络类内部调用make_layer把残差块按配置串联成四个阶段stage。_resnet和不同变体的构造函数控制不同深度对应的层数配置、是否使用预训练权重。从数据流的角度看一张图片进入网络后依次经过7x7卷积加BatchNorm加ReLU → 3x3最大池化 → 四个由残差块构成的阶段 → 全局平均池化 → 全连接分类层。其中四个阶段的通道数递增特征图尺寸递减这就构成了经典的金字塔结构。这里有个容易忽略的细节与许多简单示例不同官方实现没有用自适应池化把特征图强行压成1x1而是用了固定输入尺寸通常是224x224所以四个阶段的输出尺寸都是确定的。输入尺寸一旦变化最后的全连接层输入维度不会变因为全局平均池化会把它压成固定的通道数但中间某些层的感受野与特征尺寸关系会跟着变这是继续往下读之前心里要清楚的背景。2. 核心模块与残差块源码详解2.1 两个基础卷积函数的设计意图看文件开头就是两个短小精悍的函数def conv3x3(in_planes, out_planes, stride1): 3x3 convolution with padding return nn.Conv2d(in_planes, out_planes, kernel_size3, stridestride, padding1, biasFalse) def conv1x1(in_planes, out_planes, stride1): 1x1 convolution return nn.Conv2d(in_planes, out_planes, kernel_size1, stridestride, biasFalse)这两个函数把卷积参数固定住省得每个残差块写一大堆重复参数。关键在于biasFalse这是我读第一遍时没注意、后来才琢磨明白的地方。卷积层如果带偏置输出会额外加一个可学习常数但这层卷积后面几乎总跟着BatchNormBatchNorm自身就有可学习的缩放和偏置会在归一化后再做一次平移。卷积层的偏置在归一化时会被减掉等于白白多存一组参数、多算一次加法完全没用。所以官方实现把所有卷积的偏置都关掉了这是工程上的常见优化习惯。我在自己复现时曾经把bias设成True想看看效果结果发现参数量没有明显增加、训练速度也没区别但BN的平移项会重新学出来所以不影响精度。不过既然官方这么写我们照做就好保持代码简洁、行为可预期。2.2 BasicBlock两个3x3卷积如何实现恒等映射ResNet-18/34用的BasicBlock是理解残差连接的最小案例。我先把简化后的核心结构写出来class BasicBlock(nn.Module): expansion 1 def __init__(self, inplanes, planes, stride1, downsampleNone): super().__init__() self.conv1 conv3x3(inplanes, planes, stride) self.bn1 nn.BatchNorm2d(planes) self.relu nn.ReLU(inplaceTrue) self.conv2 conv3x3(planes, planes) self.bn2 nn.BatchNorm2d(planes) self.downsample downsample 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理解这段代码的钥匙是“恒等映射”这四个字。正常情况下卷积块想学习的是一个从输入到输出的完整映射函数F(x)现在残差块改为学习“输出与输入之间的差异”即F(x) H(x) - x最后的输出是H(x) F(x) x。这就像你在一个会议室里要传达一个消息如果让每个人都完整复述一遍信息损耗会很大但让每个人只记录“和上一句相比少了什么、改了什么”传到后面的信息就更可靠训练也更容易。具体到这个类里当输入输出的通道数、尺寸完全一致时identity就直接等于输入x相加操作不做任何额外处理。这是最简单的情况也是最符合“恒等映射”本来含义的写法。我个人在给初学者讲的时候会先把这种情况说透再看带downsample的情况。2.3 Bottleneck为什么是沙漏形的三个卷积ResNet-50往上就不再使用两个3x3卷积了而是换成Bottleneck这个模块是面试时被问最多的。它的结构是先用1x1卷积把通道压缩到原来的四分之一再用3x3卷积处理空间信息最后用1x1卷积把通道还原成4倍。我把官方实现的核心代码贴出来class Bottleneck(nn.Module): expansion 4 def __init__(self, inplanes, planes, stride1, downsampleNone): super().__init__() self.conv1 conv1x1(inplanes, planes) self.bn1 nn.BatchNorm2d(planes) self.conv2 conv3x3(planes, planes, stride) self.bn2 nn.BatchNorm2d(planes) self.conv3 conv1x1(planes, planes * self.expansion) self.bn3 nn.BatchNorm2d(planes * self.expansion) self.relu nn.ReLU(inplaceTrue) self.downsample downsample 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这里的planes是“中间层通道数”不是输出通道数。简单算一笔账你就懂为什么要设计成沙漏形假设输入是256通道输出也要是256通道。如果用两个3x3卷积直接做一个分支的参数量约是3*3*256*256*2 1179648。而Bottleneck先降维到64做完3x3卷积再升维回256参数量约是1*1*256*64 3*3*64*64 1*1*64*256 90112。只有前者的约7.6%。深层网络里堆几百个这样的块省下的量非常可观。而且中间的3x3卷积因为通道数少实际计算量也小很多训练和推理都会更快。但有一点必须提醒Bottleneck因为降维再升维信息在中间层被压缩过理论上表达能力会比直接做两个3x3稍弱一点。ResNet的作者在50层以上的网络里选用Bottleneck是因为省下来的计算量可以换取更深的层数深度带来的收益远远超过了那点表达能力损失。这也是为什么ResNet-50以上的模型普遍比ResNet-34效果好但参数量并没有爆炸的原因之一。2.4 Downsample的三种实现选择残差连接要成立必须保证输入和输出能直接相加。当通道数或特征图尺寸不一致时就需要对输入做一次投影也就是downsample。官方代码在make_layer里这样构造它if stride ! 1 or self.inplanes ! planes * block.expansion: downsample nn.Sequential( conv1x1(self.inplanes, planes * block.expansion, stride), nn.BatchNorm2d(planes * block.expansion), )这里用的是1x1卷积加BatchNorm。很多初学者会问为什么不用池化来做降采样毕竟池化没有可学习参数看起来更“省”。答案是池化只能改变空间尺寸不能改变通道数而每次进入新阶段通道数都要翻倍比如64→128这个操作必须用带可学习参数的卷积来做。至于为什么不用普通的3x3卷积做投影一是因为1x1卷积参数更少二是因为1x1卷积不会引入新的空间混合它只是对每个像素位置做通道间的线性组合不会对恒等路径的信息做过度加工。我在实际调试中观察过当downsample没加BatchNorm时深层模型在前几百个迭代里loss会偏高收敛更慢。原因在于残差分支和恒等分支的数值尺度如果不一致相加之后BN的归一化要花更多步来调整。官方实现给downsample也接一个BN是非常务实的做法动手改代码时一定不要把这层去掉。3. 主干网络生成逻辑与层数计算3.1 make_layer串联残差块的核心循环make_layer是ResNet类里最重要的部分它按配置把单个残差块循环堆叠起来。官方代码如下def make_layer(self, block, planes, blocks, stride1): downsample None if stride ! 1 or self.inplanes ! planes * block.expansion: downsample nn.Sequential( conv1x1(self.inplanes, planes * block.expansion, stride), nn.BatchNorm2d(planes * block.expansion), ) layers [] layers.append(block(self.inplanes, planes, stride, downsample)) self.inplanes planes * block.expansion for _ in range(1, blocks): layers.append(block(self.inplanes, planes)) return nn.Sequential(*layers)这段代码有两个非常关键的设计。第一layers.append对第一个block传入stride和downsample随后的block全部用默认stride1、downsampleNone。这意味着一个阶段内只有第一个残差块负责降采样和通道变换剩下的块都在保持尺寸不变的前提下做特征提取。换句话说整个网络的空间尺寸只在“阶段边界”上缩小这个策略既节约计算也符合特征金字塔逐层抽象的逻辑。第二每次调用make_layer后self.inplanes会被更新成当前阶段的输出通道数。这个状态变量的更新顺序很容易出错我在自己写网络时曾经把更新语句放到了循环里结果第二个block的输入通道数变成了planes * expansion * 2网络直接维度不匹配报错。官方代码里这个更新只执行一次是在整个阶段拼接完之后才更新这是对“状态机”手法的一个经典运用初学时值得反复咀嚼。3.2 不同变体的层数配置对比ResNet之所以叫“18”“34”“50”等等指的是带权重层的总数卷积层和全连接层但不包括BatchNorm层和激活层。官方通过给_resnet传入不同的block和layers列表来生成不同变体。我把常用配置整理成一张表变体block类型layers配置各阶段输出通道/尺寸参数量(约)ResNet-18BasicBlock[2, 2, 2, 2]64/56, 128/28, 256/14, 512/711.7MResNet-34BasicBlock[3, 4, 6, 3]64/56, 128/28, 256/14, 512/721.8MResNet-50Bottleneck[3, 4, 6, 3]256/56, 512/28, 1024/14, 2048/725.6MResNet-101Bottleneck[3, 4, 23, 3]256/56, 512/28, 1024/14, 2048/744.5MResNet-152Bottleneck[3, 8, 36, 3]256/56, 512/28, 1024/14, 2048/760.2M表格里写的“56”“28”等尺寸是按照输入224x224推算的。你会发现两个有趣的现象一是ResNet-18和34的参数量差距比50和101的差距小很多原因是Bottleneck到BasicBlock的倍数不同二是即便到ResNet-152参数量也只有约60M相比近年动辄上亿的大模型ResNet还算是轻量级选手。3.3 主干网络的四个阶段ResNet类的__init__是理解整体组装逻辑的入口我把关键部分标注出来self.conv1 nn.Conv2d(3, self.inplanes, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(self.inplanes) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) self.layer1 self._make_layer(block, 64, layers[0]) 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)第一个卷积是7x7、步长2、padding 3把224x224的输入降成112x112然后接一个3x3步长2的最大池化再降到56x56。这之后才进入layer1。从layer2开始每个阶段的第一个block的stride都设为2所以特征图从56→28→14→7通道数从64→128→256→512。最后的全局平均池化把7x7压成1x1再拉平送到全连接层。这里值得注意一个细节四个阶段里第一个阶段layer1没有设置stride2输入56x56输出还是56x56。而很多现代网络比如后来的一些分类模型会在第一个阶段就做降采样。ResNet选择在第一阶段保持空间分辨率是为了让浅层多保留一些细节信息。这个设计在目标检测任务中尤其重要因为检测模型往往会把layer2或layer3的输出当作多尺度特征来用分辨率越高对小目标越友好。我第一次看到时以为是个随机选择后来对照了论文结构图才发现这是作者有意为之的常规策略。4. 前向传播与权重初始化细节4.1 Forward流程与inplace操作完整网络的前向传播代码非常简洁def _forward_impl(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新版本的torchvision为了支持torch.jit.script把forward拆成了forward和_forward_impl两个方法主forward里直接调用_forward_impl。这个拆法本身没什么高深但体现了一个工程习惯把实际逻辑与接口分离后续如果要用装饰器或做代码检查改动点可以集中在一处。代码里有个self.relu nn.ReLU(inplaceTrue)这个inplace参数很多人不理解。它表示ReLU直接修改输入张量、而不是新开一块内存存结果。在ResNet这种深层网络里激活函数被调用上百次每次少一次内存分配能明显减少显存占用和碎片化。代价是原输入张量被覆盖如果后面还要用原始值就不能inplace。在残差块里out经过ReLU之后没有其他地方引用旧值所以官方敢放心用。我在自定义网络里如果复用了某个张量就只把这个参数设成False否则容易出现“不知不觉改了前一层输出”的难查问题。4.2 全连接层、Flatten与global pooling看到x torch.flatten(x, 1)这行有的同学会问avgpool已经输出1x1了直接x.view(x.size(0), -1)不行吗其实两者效果一样但torch.flatten写法更简洁也不容易把维度写错。AdaptiveAvgPool2d((1, 1))的作用是无论输入尺寸多大都输出1x1的特征图也就是说全连接层的输入维度永远是512 * block.expansion输入大小变化不会导致fc层维度错误。这就是为什么把ResNet用在不同图像尺寸上是可行的只要尺寸不太极端前面的卷积都能处理最后全局池化会把空间信息压成常数维度。但用的时候要注意如果输入从224改成112前两个stage的后续层大多没问题但到后面输出尺寸会变成3x3甚至更小信息容量下降精度通常会跌一点。这不是Bug是深层网络固有的空间分辨率瓶颈。4.3 权重初始化的选择与理由官方代码在__init__最后调用了_initialize_weights这也是一个值得拆开看的细节for m in self.modules(): 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)理论上PyTorch的nn.Conv2d在创建时已经做过默认初始化但官方仍然选择手动设置目的是统一控制方差尺度。modefan_out的意思是按“输出通道数”计算初始化方差配合ReLU激活函数可以保证在前向传播时每层的激活值方差维持在近似不变的量级。这个思路的逻辑源头是Kaiming初始化推导出的方差保持条件——如果初始化方差过大深层网络的输出会越来越大导致梯度消失或爆炸如果过小激活值会逐渐趋于零网络学不到东西。BN层的权重初始化为1、偏置为0也很关键。BN的数学形式是y γ * (x - μ) / sqrt(σ² ε) β如果初始化时γ0那么整个残差块都会输出常数梯度也没法传回去网络直接瘫痪。所以γ1是保证训练起步阶段的行为接近“标准归一化后不做缩放”的合理选择。我在写自己的网络时一开始用默认初始化发现前100步loss下降得非常慢换成这套初始化后明显更顺。这就是官方的经验价值所在。5. 训练和改造时的常见问题与排查技巧5.1 冻结BN层微调的正确姿势在迁移学习里我们经常加载预训练权重后只更新后面的分类层或者对整个网络做finetune。如果batch size很小比如目标检测里每张图只有1到2个样本BN层继续更新会非常不稳定因为归一化用的均值和方差是由当前batch估算的batch太小估算噪声就大。这时候应该把BN层冻结只让卷积层微调。官方代码里的BN模块在训练模式下会根据当前batch的统计量做归一化并更新running_mean和running_var。冻结BN最稳妥的做法是for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval()但你如果只是把整个模型model.eval()会把Dropout和所有卷积的评估行为也一并改掉这在训练模式下是不对的。正确做法是让BN层保持评估模式同时让其他层保持训练模式。我踩过这个坑当时微调一个检测模型loss逐渐走高找了很久才发现是BN还在更新把小batch的噪声放大到了灾难级别。解决办法就是上面那段循环。5.2 修改分类头时容易忽略的维度问题最常见的需求是把ResNet的1000类分类改成自己的类别数比如10类。大部分人只改最后一行self.fc nn.Linear(512 * block.expansion, num_classes)。这确实是最主要的一步但有两个隐藏问题需要注意。第一如果你修改完分类头然后load_state_dict加载预训练权重strictTrue会报错因为fc层的weight和bias形状不匹配。解法是加载前先删掉不匹配的键或者用load_state_dict(state_dict, strictFalse)。但strictFalse会把所有形状匹配不上的参数都忽略很隐蔽地丢掉一些你原本想保留的层所以稳妥的姿势是只过滤掉名字以fc.开头的键其余全部加载。第二如果你的输入不是三通道RGB而是单通道灰度图或多波段遥感图第一层conv1的输入维度也要改。很多人改着改着就忘了把conv1也纳入修改范围结果报维度错误。我曾在一个医学图像项目里因为只改了fc、没改conv1卡了整整一个下午最后发现就是第一行卷积的输入通道没有对准。5.3 训练不收敛时的排查顺序如果你用ResNet训练自己的数据集不收敛不要第一时间怀疑代码写错了。按我自己的排查习惯顺序是这样先看loss曲线如果loss一直不降多半是学习率设置问题尤其是用ImageNet预训练权重时微调的学习率一般比从头训练小一个数量级常用1e-3以下。再看BN是否冻结batch size小、又开了BN更新loss曲线会剧烈震荡。再看数据预处理ResNet的预训练权重是在特定归一化参数下训练的均值和方差不能随便换否则输入分布偏移太多模型一开始的表现就会很差。最后才怀疑网络结构改动比如你给Bottleneck增加了Dropout位置加得不对可能会破坏残差连接的恒等通路。这四条是我排除了无数个“看起来像是代码Bug”的问题之后的经验。事实上官方实现本身非常稳定大多数不收敛都来自数据或超参数环节。5.4 内存占用过高时的优化手段ResNet虽然不算是超大模型但真的训练起来显存也很紧张。一个很容易被忽略的优化点是inplace操作。我在官方源码基础上把所有ReLU的inplaceTrue保持住再多加一些层时也尽量保持这个习惯整体显存能省不少。另一个优化点是梯度检查点checkpointing。torchvision的实现里没有内置但你可以把每个stage包进torch.utils.checkpoint.checkpoint用计算换显存。这个技巧在16G以下显卡上训练大模型尤其有用我试过把ResNet-50的layer3和layer4包进去显存能省差不多三分之一训练时间会增加一些但比起换显卡的成本还是划算得多。6. 拓展改造思路与个人心得6.1 用官方代码改出自定义的变体读懂官方实现后你可以很轻松地在它基础上做各种改造。比如我需要的场景是处理小尺寸医学图像原始stem用7x7卷积和3x3池化会把32x32的输入直接压缩到8x8太狠了。于是我把stem改成两个3x3卷积取消第一层的大步长这样小图也能保持足够的分辨率。具体做法就是仿照官方写法自己写一个make_layer把stride参数调整一下。还有一个常见需求是把分类头从全连接改成更复杂的结构比如先做全局平均池化再接一个小型MLP。只要明确了self.avgpool输出的形状是[N, C, 1, 1]你在torch.flatten之后想接什么层都很自由。官方代码的模块化设计就是为这种二次开发准备的这也是它值得反复精读的原因。6.2 读码时的状态变量追踪法最后分享一个我自己读这类源码的方法。对于ResNet这种带状态变量的类不要只看某个方法而是准备一张纸手动跟踪inplanes的变化。我会把四个阶段调用make_layer时传入的参数、更新后的inplanes、每个block的输入输出通道数都写出来。这张表画完整个网络结构基本就刻在脑子里了。我试过推荐给身边的同事效果都很好。因为PyTorch代码和论文结构图之间毕竟有一层抽象状态变量的隐式更新很容易被忽略。手动走一遍数据维度的变化比直接看十遍代码都有用。6.3 一份可以照做的验证清单如果你也刚读完官方源码想确认自己真的理解了可以试试下面这几件事第一不看源码自己手写一个ResNet-18跑一次前向传播验证维度正确第二把Bottleneck的expansion改成1观察参数量和输出尺寸变化第三给ResNet-50加一个只冻结前三层的微调流程第四把输入从224改成128记录每个stage输出尺寸的变化。任何一个步骤卡住都说明对应的细节还需要再回看一遍。这几件事我都亲自做过尤其是“手写一遍”这条路收益最大。等你能够不看官方代码、照样写出一个跑得通的ResNet这套源码才算真正变成了你自己的东西。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑