
1. 从零手写ResNet18为什么我不建议直接调库很多人第一次接触残差网络都是从torchvision.models.resnet18(pretrainedTrue)这一行代码开始的。加载预训练权重改一下全连接层跑几个epoch准确率就上去了确实省事。但如果你真的想搞懂ResNet为什么能训练到上百层还不退化光会调库是远远不够的。我见过太多人面试时被问到“残差块里那个跳跃连接到底加在哪里”“BasicBlock和Bottleneck有什么区别”“下采样到底是靠stride还是靠池化”的时候答不上来原因就是从来没自己动手搭过一遍。这篇文章的目标很明确用PyTorch从零把ResNet18的网络结构完整实现出来不依赖torchvision的模型定义每一层都自己写。适合已经了解卷积、池化、批归一化基本概念但还没亲手拆解过ResNet的读者。读完之后你应该能做到三件事第一看着ResNet的论文结构图就能把代码写出来第二清楚每一个设计选择背后的原因比如为什么用3x3卷积堆叠而不是5x5或7x7第三知道在实际训练中哪些地方容易出问题以及怎么排查。我自己的经验是手写一遍ResNet18大概需要两到三个小时但这两三个小时带来的理解深度比看十篇论文解读都管用。下面我按照实际搭建时的思考顺序来展开从残差块的设计开始一步步拼出完整的网络最后再聊聊训练时踩过的坑。2. 残差块的设计ResNet18的基本积木2.1 残差学习的核心动机在ResNet出现之前深层网络面临一个很尴尬的问题层数越多训练误差反而越大。这不是过拟合因为训练误差本身就在上升。理论上说一个20层的网络如果后面多加几层恒等映射也就是什么都不做效果至少不应该比20层差。但实际训练中优化器找不到这个恒等映射的解导致深层网络反而更难训练。残差块的核心思想就是把这个“恒等映射”变成默认选项。假设我们希望网络学到的映射是H(x)残差块让网络去学F(x) H(x) - x然后输出变成F(x) x。如果最优解就是恒等映射那网络只需要把F(x)的权重压到接近零就行了这比直接学一个恒等映射容易得多。那个“x”就是跳跃连接skip connection也叫捷径shortcut。这里有个细节值得注意跳跃连接本身不引入任何额外参数它只是把输入直接加到输出上。这意味着ResNet的参数量并没有因为跳跃连接而增加但梯度的流动路径变短了反向传播时梯度可以通过跳跃连接直接传到前面的层缓解了梯度消失。2.2 BasicBlock的结构拆解ResNet18和ResNet34用的是BasicBlockResNet50及以上用的是Bottleneck。BasicBlock的结构很简洁两个3x3卷积每个卷积后面跟一个批归一化BatchNorm和ReLU激活最后把输入加到第二个卷积的输出上再过一次ReLU。具体来说输入x经过第一个3x3卷积stride可配置、BN、ReLU得到中间特征再经过第二个3x3卷积stride1、BN得到F(x)然后F(x) x最后ReLU。注意第二个卷积后面不要急着加ReLU要等加法做完之后再激活。为什么是两个3x3而不是一个5x5两个3x3卷积的感受野等价于一个5x5但参数量更少两个3x3的参数量是2 × 9 18一个5x5是25。而且两个3x3之间多了一次非线性激活表达能力更强。这个设计在VGG里就被验证过ResNet沿用了这个思路。2.3 维度匹配与下采样处理跳跃连接有一个前提F(x)和x的维度必须一致否则没法直接相加。当输入输出通道数相同、空间尺寸相同时直接相加没问题。但当我们需要改变通道数或者降低分辨率时就需要对x做一个变换通常是用一个1x1卷积调整通道数同时用stride2降低分辨率。在ResNet18中每个stage的第一个BasicBlock需要做下采样除了第一个stage。具体来说conv2_x的第一个block、conv3_x的第一个block、conv4_x的第一个block、conv5_x的第一个block都需要下采样。下采样通过两种方式实现主分支的第一个3x3卷积用stride2跳跃连接的1x1卷积也用stride2。这样两边的输出尺寸才能对齐。这里有个容易搞错的地方跳跃连接上的1x1卷积只在需要下采样的时候才加不需要下采样的时候跳跃连接就是纯恒等映射没有任何参数。我见过有人在每个block都加1x1卷积虽然也能跑但参数量会增加而且违背了ResNet的设计初衷。import torch import torch.nn as nn class BasicBlock(nn.Module): expansion 1 def __init__(self, in_channels, out_channels, stride1, downsampleNone): super(BasicBlock, self).__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 out这段代码里有个细节卷积层的biasFalse。因为后面紧跟着BatchNormBN会减去均值卷积的偏置项会被抵消掉所以设成False可以省一点参数也不会影响效果。这是PyTorch社区的一个常见实践新手容易忽略。3. 从BasicBlock到完整ResNet18的组装过程3.1 网络整体架构的层次划分ResNet18的整体结构可以分成五个阶段stem层初始的7x7卷积最大池化、conv2_x、conv3_x、conv4_x、conv5_x最后是全局平均池化和全连接层。每个stage由若干个BasicBlock堆叠而成ResNet18的配置是[2, 2, 2, 2]也就是每个stage有2个BasicBlock总共8个Block加上最后的全连接层所以叫ResNet188个Block × 2个卷积层 1个初始卷积 1个全连接 18层。stem层是一个7x7卷积stride2padding3输出通道64后面跟BN、ReLU再接一个3x3最大池化stride2padding1。这一层的作用是快速降低分辨率同时提取底层特征。7x7卷积的计算量比较大但只在第一层用一次影响可控。conv2_x到conv5_x的通道数依次是64、128、256、512每个stage的第一个Block负责下采样stride2后面的Block保持stride1。下采样时通道数翻倍空间尺寸减半。3.2 下采样位置的精确判断在组装网络的时候最容易出错的地方就是判断哪个Block需要下采样。我的做法是用一个循环来构建每个stage记录当前stage的输入通道数和输出通道数以及stride。对于第一个stageconv2_x输入通道是64来自stem层输出通道是64stride1不需要下采样。对于后续的stage输入通道是上一个stage的输出通道输出通道翻倍stride2第一个Block需要下采样。具体实现时我用layers列表来收集每个stage的Block然后传给nn.Sequential。每个stage的第一个Block单独处理传入downsample参数后面的Block直接复用。class ResNet18(nn.Module): def __init__(self, num_classes1000): super(ResNet18, self).__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, 2, stride1) self.layer2 self._make_layer(128, 2, stride2) self.layer3 self._make_layer(256, 2, stride2) self.layer4 self._make_layer(512, 2, 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 x3.3 参数量与计算量的估算ResNet18的参数量大约是11.7M1170万在ImageNet上top-1准确率大约69.8%。这个参数量在当时的背景下算是比较轻量的比VGG16的138M小了一个数量级但效果更好。计算量方面ResNet18的FLOPs大约是1.8G18亿次浮点运算输入尺寸224x224。大部分计算量集中在conv2_x到conv5_xstem层的7x7卷积虽然计算量大但只占一小部分。如果你想验证自己的实现是否正确可以用torchsummary或者手动计算每一层的输出尺寸。一个简单的检查方法是输入一个torch.randn(1, 3, 224, 224)看输出是不是(1, num_classes)。如果中间某一步尺寸对不上大概率是下采样的位置搞错了。4. 训练ResNet18时那些文档不会告诉你的坑4.1 批归一化与权重初始化的配合ResNet的论文里强调了权重初始化的重要性。虽然有了BatchNorm之后初始化没那么敏感了但用对初始化方法仍然能加速收敛。PyTorch默认的初始化是Kaiming均匀初始化对卷积层来说基本够用。但如果你自己写初始化记得对卷积层用Kaiming正态初始化对BN层的weight初始化为1bias初始化为0。有一个细节ResNet的最后一个BN层每个Block的bn2在初始阶段输出接近零这样残差块的输出就接近恒等映射相当于网络一开始就有一个好的起点。这是ResNet设计的一个巧妙之处BN层在这里起到了正则化和稳定训练的双重作用。4.2 学习率与优化器的选择训练ResNet18我一般用SGD加上动量0.9weight decay 1e-4。学习率初始值设0.1配合余弦退火或者阶梯下降。如果用Adam学习率要调小到1e-3左右但实测下来SGD的泛化性能更好尤其是在ImageNet这种大规模数据集上。Batch size对BN的影响很大。BN在batch size太小的时候统计量不稳定一般建议batch size至少32。如果显存不够可以用梯度累积来模拟大batch。我试过batch size16训练ResNet18准确率比batch size256低了大概1个百分点而且训练曲线抖动更明显。还有一个坑如果你用了预训练权重学习率要调小一般设成从头训练的十分之一。因为预训练权重已经在一个很大的数据集上收敛了太大的学习率会破坏已有的特征。4.3 数据增强与正则化的实际效果ResNet18在ImageNet上训练时标准的数据增强是随机裁剪RandomResizedCrop加随机水平翻转。这两个增强对准确率的提升非常明显尤其是随机裁剪能让模型对不同尺度和位置的物体更鲁棒。Label Smoothing也是一个有用的技巧把硬标签变成软标签比如正确类别的概率从1.0变成0.9其余类别平分0.1。这个技巧在ResNet的后续改进中被广泛使用能提升大约0.2到0.3个百分点的准确率同时让模型更校准。Dropout在ResNet里用得不多因为BN本身就有正则化效果。如果非要加一般加在全局平均池化之后、全连接层之前dropout rate设0.5。但我实测下来加了Dropout之后收敛变慢最终准确率也没有明显提升所以后来就不加了。4.4 常见报错与排查思路训练ResNet18时最常见的报错是尺寸不匹配比如RuntimeError: The size of tensor a (64) must match the size of tensor b (128)。这通常是因为跳跃连接的downsample没有正确配置导致F(x)和x的通道数不一致。排查方法是打印每个Block的输入输出尺寸看看哪一步对不上。另一个常见问题是损失不下降。如果训练损失一直卡在2.3左右ln(10)说明模型没有学到任何东西。可能的原因包括学习率太大导致梯度爆炸、数据标签有问题、BN层没有正确设置train/eval模式。我遇到过一次是因为在验证时忘了调model.eval()BN层用batch统计量导致输出不稳定。还有一个隐蔽的坑inplaceTrue的ReLU在某些情况下会导致梯度计算错误尤其是在跳跃连接和加法操作附近。如果你发现梯度出现NaN可以先把ReLU的inplace改成False试试。5. 验证实现正确性的几个实用方法5.1 用torchvision的预训练权重做对比最直接的验证方法是加载torchvision的ResNet18预训练权重然后对比你的实现和官方实现的输出。如果两者的输出差异在1e-5以内说明结构完全一致。具体做法是先实例化你的模型和官方模型把官方模型的state_dict加载到你的模型里然后输入同一个张量比较输出。如果加载state_dict时报错说key不匹配说明你的层命名和官方不一致。官方的命名是layer1.0.conv1.weight这种格式你可以对照着调整自己的命名。不过命名不影响功能只要结构对就行。5.2 梯度流动的检查残差网络的核心优势是梯度能通过跳跃连接直接传到底层。你可以写一个简单的检查对输入求梯度看第一层卷积的梯度是否接近零。如果梯度消失很严重说明跳跃连接没有起作用。具体做法是输入一个随机张量前向传播后计算损失反向传播然后打印model.conv1.weight.grad的范数。如果范数在1e-6以下说明梯度几乎没传回来。正常情况下ResNet18的第一层梯度范数应该在1e-3到1e-2之间。5.3 特征图尺寸的逐层验证我习惯在搭建完网络后用一个钩子hook记录每一层的输出尺寸然后和论文里的结构图对照。ResNet18在输入224x224时的尺寸变化是224 → 112conv1→ 56maxpool→ 56layer1→ 28layer2→ 14layer3→ 7layer4→ 1avgpool。如果某一步对不上就回去检查那个stage的stride设置。这个检查看起来简单但能帮你快速定位大部分结构错误。我见过有人把maxpool的stride设成1结果后面所有尺寸都翻倍训练时显存直接爆掉。6. 从ResNet18延伸出去的几个实用方向6.1 迁移学习中的层冻结策略在实际项目中很少有人从头训练ResNet18更常见的是用预训练权重做迁移学习。这时候需要考虑冻结哪些层。我的经验是如果目标数据集和ImageNet比较接近比如都是自然图像只训练最后的全连接层就够了如果差异较大比如医学图像、卫星图像至少要训练layer3和layer4如果数据量很小几千张建议只训练全连接层否则容易过拟合。冻结层的实现很简单把不需要训练的参数的requires_grad设成False。但要注意BN层的running_mean和running_var即使冻结了参数也会更新如果不想让它们更新需要把BN层设成eval模式。6.2 把ResNet18作为骨干网络ResNet18经常被用作目标检测或语义分割的骨干网络。这时候不需要最后的全连接层而是取layer4的输出作为特征图。比如在YOLO或SSD里ResNet18的layer4输出7x7x512会接上额外的卷积层来预测边界框。如果你要做这种事记得把avgpool和fc去掉只保留到layer4。另外有些实现会在layer4后面加一个1x1卷积来调整通道数这个根据具体任务来定。6.3 轻量化改进的思路ResNet18本身已经比较轻量了但如果要在移动端部署还可以进一步压缩。常见的做法包括把3x3卷积换成深度可分离卷积Depthwise Separable Convolution或者用通道剪枝去掉不重要的通道。我试过把ResNet18的通道数整体缩小到0.5倍参数量降到3M左右准确率在CIFAR-10上只掉了1个百分点性价比很高。另一个思路是修改BasicBlock的结构比如把两个3x3卷积换成一个3x3和一个1x1或者引入分组卷积。这些改动需要实验验证不是所有任务都能受益。6.4 在小型数据集上的调参经验如果你在CIFAR-10这种32x32的小图上训练ResNet18需要修改stem层。标准的7x7卷积加maxpool会把32x32直接降到8x8信息损失太大。常见的改法是把7x7卷积换成3x3卷积stride1去掉maxpool这样32x32经过layer1到layer4之后变成4x4比较合理。CIFAR-10上的ResNet18准确率能到95%以上但需要训练200个epoch左右学习率用0.1起步在100和150epoch时各降一次。Weight decay可以设成5e-4比ImageNet上的1e-4大一些因为小数据更容易过拟合。我在实际使用中发现ResNet18的跳跃连接在训练初期的作用特别明显。如果没有跳跃连接同样深度的普通网络在前10个epoch几乎不收敛而ResNet18在5个epoch内就能把损失降到1.0以下。这个对比让我真正理解了残差学习的价值也推荐你在学习的时候做一个这样的对照实验比看任何论文都直观。