ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

手动复现ResNet18:从残差连接到完整网络逐行拆解

2026/10/8 15:20:45 拓冰建站 浏览量
手动复现ResNet18:从残差连接到完整网络逐行拆解 如果你跟我一样最早接触resnet18时用的就是那句model torchvision.models.resnet18(pretrainedTrue)那大概率有过一种“既会用它又不真正懂它”的状态。网络能跑、准确率还不错可真被别人问到“残差连接到底解决什么问题”“F(x)x里的加法在代码里到底哪一行实现”“shortcut什么时候要改变尺寸、什么时候直接加”时脑子里的印象往往是模糊的。这个状态很危险因为你只能调现成的模型一旦要改结构、加模块、做可视化立刻卡住。我大概在两年前彻底想明白了这件事。原因很简单我把 ResNet18 从零手动复现了好几遍。第一遍完全不看参考代码只盯着论文里的残差块公式结果踩了一堆坑——维度对不上、downsample设计错误、BN 顺序搞反、残差相加后少一个 ReLU。这些东西你在torchvision里根本看不到因为它们已经被封装得干干净净了。所以这篇文章我打算用“亲手写一遍”的方式把 ResNet18 和它最核心的残差连接从头拆开从为什么需要残差到逐行手写BasicBlock再到组装出完整网络、验证它跟官方版是否一致。最后再把我踩过的坑和从这个结构延伸出去的方法一次性讲清楚。如果你已经学过 CNN、了解过卷积和池化的基础只是还没真正手写过经典分类网络这篇正好合适。如果你自认为对 ResNet 很熟了也可以拿着我的代码和验证步骤回头对照自己写的那版大概率能发现一两个以前忽略的细节。1. 一个现象为什么“越深效果越好”在 ResNet 之前不成立想理解残差连接不能只看代码里那个加法得先从它解决的问题出发。1.1 退化问题网络加深反而变差的根源在 ResNet 出现之前深度学习社区已经达成了一个共识更大的模型通常意味着更强的表达能力。于是堆层数成了最直接的手段VGG 把这个思路推到了 19 层GoogleNet 用 Inception 模块把层数做到了 22 层。人们很自然地会想继续加层是不是更好结果是反直觉的。论文里提到过一个明确的实验现象一个 56 层的网络在训练集上的误差居然比 20 层网络还高。这不是过拟合因为训练集本身的误差都没降下去。也就是说当网络加深到一定程度时优化本身变得非常困难普通的反向传播很难在几十层这么深的参数空间里稳定地找到好的解。这个问题被称作“退化问题”degradation。注意它不是梯度消失导致的完全失效而是深层网络很难去拟合一个合理的映射。我自己理解这件事的时候用过一个类比假如你要在一个富人区里建一栋楼地基不稳你拼命往上面加楼层结果不是楼塌了而是电梯根本没法把每一层的人都顺畅送到目的地。网络每多一层就相当于多了一道优化的“手续”层与层之间耦合越来越深完全靠梯度去逐层调整那层数一高就会失控。1.2 残差的视角H(x)F(x)x 为什么能打破僵局ResNet 的核心改动只有一句话把网络要学习的映射从H(x)改成H(x) - x也就是残差。结构上对应的是输出 F(x) x其中F(x)是若干卷积、BatchNorm、ReLU 堆叠出来的非线性映射x是输入通过一条“短接路径”绕到后面直接相加。这条短接路径有个专业的名字——shortcut connection中文常叫捷径连接或恒等映射。为什么这样做有效一个很关键的推论是如果某个深层网络已经是最优的那么多加一层恒等映射不应该让结果变差。但普通网络连“学成一个恒等映射”都很难。要知道让一个卷积层输出等于输入需要把很多权重系数调到特定状态这对优化器来说是很高的要求。而有了残差连接后网络只需要把F(x)学成接近 0输出就能自动接近输入。学“不变”比学“变化”要容易得多特别是在深层结构中。这也是为什么后来很多人简单笼统地说“ResNet 让梯度更容易流动”——反向传播时x这一支可以几乎无损地把梯度传回前层缓解深层网络训练困难的问题。1.3 为什么非得“手动复现”而不是调包如果你只是想要一个能跑的分类网络torchvision显然更省事。但绝大多数时候我们手动复现不是为了“造轮子”而是为了建立精确的结构感。只有自己手写一遍你才会注意残差相加是在bn2之后还是relu2之前downsample在哪个 Block 会出现为什么BasicBlock和Bottleneck的expansion不一样为什么卷积层要设置biasFalse。这些细节在调包时全部隐藏掉了但当你准备改进这个结构、或者在它基础上加注意力模块、做知识蒸馏剪枝时每一个细节都可能成为调试一整天的根源。所以手动复现本身就是最好的“知其所以然”过程。下面我们进入正题。2. 一个残差块 BasicBlock 的逐层拆解ResNet18 用到的残差块叫BasicBlock由两个 3x3 卷积组成这是它区别于 ResNet50/101使用Bottleneck的最大特征。2.1 主路径两个 3x3 卷积加 BN 再加 ReLU一个标准的BasicBlock主路径长这样conv13x3 卷积padding1保持空间尺寸不变bn1BatchNormreluReLU 激活conv23x3 卷积padding1bn2BatchNorm注意顺序第一个 ReLU 在bn1之后、conv2之前第二个 BatchNorm 后面不接 ReLU要等残差相加完成后再统一做 ReLU。这是初学者最容易写错的点。这里我把标准实现直接贴出来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.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d( out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse, ) self.bn2 nn.BatchNorm2d(out_channels) 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 out2.2 短接路径恒等映射与 Downsample 的分工上面代码里identity x以及后面的out identity就是真正的残差连接。浅层阶段、通道数一致的时候identity直接从输入复制过来不需要任何额外操作。这等价于数学上的恒等映射。但网络越深通道数会逐渐从 64 涨到 128、256、512空间尺寸也会从 56x56 降半到 28x28、14x14、7x7。此时如果还想把输入x加到输出上两边形状就对不上了。解决办法是给短接路径也加一个“适配层”也就是代码里的downsample。它的标准做法是用一个1x1卷积downsample nn.Sequential( nn.Conv2d(self.in_channels, out_channels, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels), )1x1卷积的作用有两个一是把通道数从in_channels变成out_channels二是通过设置stride让空间尺寸同步减半。至于为什么不直接对x做池化或裁剪是因为卷积可以通过可学习参数对输入做一次线性变换比硬性裁剪保留更多信息也比池化更灵活。这个细节值得记住任何时候你看到残差块里有downsample脑子里应该马上反应过来它存在的唯一目的就是让“加法”的两个操作数形状一致。2.3 关键细节bias 关闭与残差相加后的 ReLU 顺序为什么每个Conv2d都要设biasFalse因为后面紧跟 BatchNorm。BN 层本身就带有一个可学习的偏置参数beta它会对卷积输出做归一化后再做平移。如果卷积层还保留 bias这两个偏置就重复了而且容易导致训练初期数值不稳定。这是 ResNet 所有标准实现里的固定设定。残差相加后为什么还要过一个 ReLU你可能会想两个分支都已经过激活函数了再加一次激活是否多余不关键区别在于主路径的最后一个操作是bn2它只做了线性变换并未经过非线性激活。此刻把identity加进来如果不加最后一个 ReLU整个后续网络就等于在没有激活的情况下直接跨过了这个残差块非线性能力会被削弱。更深一层说这个 ReLU 也保证了残差块的输出始终是非负的避免后续特征分布剧烈偏移。3. 从残差块到完整 ResNet18Stage 组装与维度流动有了BasicBlock下一步就是把它们排成四个 Stage拼出完整的 ResNet18。3.1 Stage 配置表和“18”的由来ResNet18 在 224x224 输入下四个 Stage 的配置如下Stage输出尺寸通道数Block 数第一层 stride是否需要 downsampleconv1 maxpool56x5664-stride2-layer156x56642stride1否layer228x281282stride2是layer314x142562stride2是layer47x75122stride2是“18”这个数字拆开算一下初始conv1算 1 个卷积层四个 Stage 每个含 2 个 BasicBlock每个 BasicBlock 有 2 个卷积层于是一共1 4×2×2 17个卷积层再加上最后的全连接fc层正好 18 个可学习权重层。注意 BatchNorm 不算在这个数字里Pooling、ReLU 也不算这就是 ResNet18 命名的由来。3.2 make_layer 的封装逻辑封装的时候有个常用的辅助函数它的职责是把一个 Stage 里若干个残差块堆起来同时只在第一个残差块位置处理 stride 和 downsample 的逻辑。完整代码我贴一下这段代码基本等价于torchvision里 ResNet 的实现思路class ResNet18(nn.Module): def __init__(self, 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(64, blocks2, stride1) self.layer2 self._make_layer(128, blocks2, stride2) self.layer3 self._make_layer(256, blocks2, stride2) self.layer4 self._make_layer(512, blocks2, stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512, num_classes) def _make_layer(self, out_channels, blocks, stride): downsample None if stride ! 1 or self.in_channels ! out_channels: downsample nn.Sequential( nn.Conv2d(self.in_channels, out_channels, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels), ) layers [] layers.append( BasicBlock(self.in_channels, out_channels, stride, downsample) ) self.in_channels out_channels for _ in range(1, blocks): layers.append(BasicBlock(out_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里那个if条件值得多看几遍它判断的是该 Stage 第一个残差块是否需要短接适配。第一个 Stage 呢self.in_channels 64目标out_channels 64stride 1所以条件不成立downsampleNone输入直接恒等相加。第二个 Stage 开始in_channels64目标通道变成 128且stride2条件成立于是第一个 BasicBlock 的短接路径会用1x1卷积把 64 通道的[56,56]特征图变成 128 通道的[28,28]。每个 Stage 之后self.in_channels都会被更新为out_channels保证范围内后续 Block 不需要再做维度适配。3.3 从 224x224 到 512 维特征的前向维度推演拿一张224x224x3的输入走一遍完整前向过程conv17x7stride2padding3 —— 输出112x112x64bn1relu尺寸不变maxpool3x3stride2padding1 —— 输出56x56x64layer1两个 BasicBlock每个输出56x56x64layer2第一个 Block 的conv1使用 stride2输出28x28x128短接路径用 stride2 的1x1卷积对齐第二个 Block 保持28x28x128layer3同理变成14x14x256layer4同理变成7x7x512avgpool用AdaptiveAvgPool2d((1,1))输出1x1x512fc将让512维向量投射到num_classes默认 1000手动推一遍这个流程非常值得。你会发现每个 Stage 都遵循统一规律空间尺寸减半、通道数翻倍而没有 downsample 的 Block 完全不改变形状。这种规律一旦在脑子里固定下来之后看任何 ResNet 变种都会快很多。4. 验证手写模型与 torchvision 一致性手写完了心里肯定会犯嘀咕这样写对吗我建议不要凭感觉直接跑验证。4.1 先加载官方权重再对比输出结构正确性最直接的验证方式是把手写模型加载官方预训练权重再送入同一个随机输入比较输出是否一致。如果密钥不匹配load_state_dict会直接报错这本身就是一次结构体检。import torch from torchvision.models import resnet18 official resnet18(pretrainedTrue) manual ResNet18(num_classes1000) manual.load_state_dict(official.state_dict()) manual.eval() official.eval() x torch.randn(2, 3, 224, 224) with torch.no_grad(): out_official official(x) out_manual manual(x) print(torch.allclose(out_official, out_manual, atol1e-5)) # 正常输出True这里有一个非常重要的前提必须先把官方权重灌进手写模型再对比。很多人第一次验证时直接比较两个随机初始化模型的前向输出发现结果不一样就以为自己网络写错了。其实随机初始化的两个相同结构网络输出本来就不一样。结构相同不等于参数相同输出自然不同。4.2 逐层比对 state_dict 与参数量load_state_dict成功本身就说明所有层的名字和形状都对齐了。不过名字对齐不等于每个 Block 的内部顺序没问题所以我建议再打印一份详细对比把每个模块的 key 和 tensor 形状拉出来过一遍重点是layer4.1.bn2.weight这种倒数第二层是否和官方完全一致。参数量也是很好的检查项官方resnet18的参数量是11,689,512手写后统计一下如果是同一个数基本可以放心total_params sum(p.numel() for p in manual.parameters()) print(total_params) # 116895124.3 用预训练权重直接灌入自写模型如果你手写的模型在load_state_dict后能成功跑出一致的输出下一步就可以把它当作一个完全可用的模型直接拿来做迁移学习或者下游任务。这样带来的自由度非常大因为你完全掌控了内部结构想在哪个 Stage 之后插入模块都行不需要再依赖torchvision的黑盒。我平时做实验还会顺手把模型结构打印出来用torchsummary或者手写循环打印每层输出尺寸确认每个BasicBlock的 shape 转换都符合预期。from torchsummary import summary summary(manual, (3, 224, 224), devicecpu)看到层列表里逐层尺寸按刚才推演的那样变化心里才算真正踏实。5. 手动复现中最容易踩的五个坑复现本身不难难在半路遇到各种“看似正常但实际错误”的状态。下面五个坑我是逐个踩过来的方向完全来自真实调试过程。5.1 Downsample 的 1x1 卷积漏掉 BatchNorm很多简化版代码里downsample只写了一个nn.Conv2d。短期看不会报错因为维度确实对齐了。但训练时你会发现问题短接路径的尺度分布和主路径完全不在一个量级。主路径过了多次 BN数值已经归一化而identity分支的1x1卷积没有 BN很可能输出较大或较小的值加到一起后主路径学习到的分布被带偏表现为 loss 震荡甚至不收敛。标准做法必须给这个1x1卷积后面接nn.BatchNorm2d(out_channels)。5.2 残差相加之后少一个 ReLU我第一版代码把 ReLU 放在了bn2之后、加法之前等于把残差块变成了“两个卷积层输出经过激活后再和 shortcut 相加”。这会导致一个问题identity直接绕过非线性但主路径已经激活相加后的结果再次进入下一层。看起来只是 ReLU 位置挪了一步实际网络非线性能力会被削弱短时间不明显多训练几十个 epoch 后精度会有明显差距。标准写法是out identity然后out self.relu(out)顺序不要动。5.3 stride2 时 padding 与尺寸计算的混淆手动设置 stride 时最容易出现维度对不上的报错。心里记住一个公式输出尺寸 floor((输入尺寸 2×padding - kernel_size) / stride) 1对3x3卷积、stride2、padding1比如输入 56输出尺寸 floor((56 2 - 3) / 2) 1 floor(55/2) 1 27 1 28所以[56,56]变成[28,28]符合减半预期。如果你把 padding 设成 0输出尺寸 floor((56 - 3) / 2) 1 26 1 27那就不是整数减半残差相加必然报 shape 不匹配。建议每层卷积都先按公式算一下不要等报错再改。5.4 用随机初始化模型对比输出导致的“伪 bug”前面提到的典型错误两个相同结构、随机权重的网络输出不一致这不是 bug是正常的。随机权重下任何两个独立初始化的网络输出都不相同。要验证结构一致性必须先把官方权重load_state_dict加载进来再跑前向对比。另一个可选思路是保存自己模型的 state_dict灌回torchvision模型再对比效果一样。这条看起来是常识但真的能拦住很多人。5.5 训练时忘记切换 eval 模式导致 BN 行为不一致如果你加载了预训练权重去做迁移学习注意 PyTorch 的 BN 层有两种行为model.train()模式下running_mean和running_var会被当前 batch 统计量更新model.eval()模式下BN 使用累积统计量。验证或推理阶段如果忘记加manual.eval()同一个输入在不同 batch 下输出会抖动你甚至可能误以为模型没训练好。反向推导一下这也是为什么官方预训练模型在eval()状态下才能复现出论文中的准确率。6. 从 ResNet18 向外延伸ResNet34/50 与残差思想的复用手写完 ResNet18 后往其他 ResNet 变体扩展是顺理成章的事核心逻辑完全一致只是换配置和残差块类型。6.1 层数怎么改ResNet34 的 [3,4,6,3] 配置ResNet34 和 ResNet18 结构一样唯一的区别是每个 Stage 的 Block 数量从[2,2,2,2]变成了[3,4,6,3]。在_make_layer里把blocks参数换一下即可self.layer1 self._make_layer(64, blocks3, stride1) self.layer2 self._make_layer(128, blocks4, stride2) self.layer3 self._make_layer(256, blocks6, stride2) self.layer4 self._make_layer(512, blocks3, stride2)因为每个 Stage 第一个 Block 的 downsample 逻辑完全复用所以整个BasicBlock结构不用动。6.2 扩展 BlockBottleneck 与 BasicBlock 的差异ResNet50/101/152 使用Bottleneck先用1x1卷积把输入通道数压下去再用3x3卷积做空间特征提取最后用1x1卷积把通道数扩回来。比BasicBlock多一个卷积层但每个3x3卷积的计算量更小可以在更深的情况下控制总体参数量。class Bottleneck(nn.Module): expansion 4 def __init__(self, in_channels, out_channels, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) 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 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注意expansion 4即最后输出维度是中间维度 4 倍。初始化in_channels时也要随之调整第一个 Stage 的输出不是 64而是64×4 256。如果你顺手把self.in_channels 64改成256ResNet50 的配置才说得通。6.3 残差连接在其他结构里的影子把这段代码写完以后你会发现残差连接的思想已经成为现代深度模型的基础组件。Transformer 里的Add Norm、GAN 生成器里的跳跃连接、UNet 的 skip connection本质都是在不同尺度上让信息绕开中间处理环节直接向前传播。我自己的体会是手动复现过一遍BasicBlock后再接触这些会更顺因为核心思想其实特别朴素——给梯度一条捷径给信息一条近道。如果你也正在手写这个结构建议先别急着跑训练花一小时把上面验证的部分做扎实加载官方权重、对比输出、对比参数量。这几个步骤做完你对 ResNet18 残差连接的理解会从“知道”变成“掌握”以后再改结构手底下会有把握得多。