☰
从零手写ResNet18:深入理解残差网络与PyTorch实现
2026/10/21 5:25:28 网站建设 项目流程

1. 从零手写ResNet18:为什么我不建议直接调库

很多人第一次接触残差网络,都是从torchvision.models.resnet18(pretrained=True)这一行代码开始的。加载预训练权重,改一下全连接层,跑几个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用的是BasicBlock,ResNet50及以上用的是Bottleneck。BasicBlock的结构很简洁:两个3x3卷积,每个卷积后面跟一个批归一化(BatchNorm)和ReLU激活,最后把输入加到第二个卷积的输出上,再过一次ReLU。

具体来说,输入x经过第一个3x3卷积(stride可配置)、BN、ReLU,得到中间特征;再经过第二个3x3卷积(stride=1)、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卷积调整通道数,同时用stride=2降低分辨率。

在ResNet18中,每个stage的第一个BasicBlock需要做下采样(除了第一个stage)。具体来说,conv2_x的第一个block、conv3_x的第一个block、conv4_x的第一个block、conv5_x的第一个block都需要下采样。下采样通过两种方式实现:主分支的第一个3x3卷积用stride=2,跳跃连接的1x1卷积也用stride=2。这样两边的输出尺寸才能对齐。

这里有个容易搞错的地方:跳跃连接上的1x1卷积只在需要下采样的时候才加,不需要下采样的时候跳跃连接就是纯恒等映射,没有任何参数。我见过有人在每个block都加1x1卷积,虽然也能跑,但参数量会增加,而且违背了ResNet的设计初衷。

import torch import torch.nn as nn class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(BasicBlock, self).__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) 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

这段代码里有个细节:卷积层的bias=False。因为后面紧跟着BatchNorm,BN会减去均值,卷积的偏置项会被抵消掉,所以设成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,加上最后的全连接层,所以叫ResNet18(8个Block × 2个卷积层 + 1个初始卷积 + 1个全连接 = 18层)。

stem层是一个7x7卷积,stride=2,padding=3,输出通道64,后面跟BN、ReLU,再接一个3x3最大池化,stride=2,padding=1。这一层的作用是快速降低分辨率,同时提取底层特征。7x7卷积的计算量比较大,但只在第一层用一次,影响可控。

conv2_x到conv5_x的通道数依次是64、128、256、512,每个stage的第一个Block负责下采样(stride=2),后面的Block保持stride=1。下采样时通道数翻倍,空间尺寸减半。

3.2 下采样位置的精确判断

在组装网络的时候,最容易出错的地方就是判断哪个Block需要下采样。我的做法是用一个循环来构建每个stage,记录当前stage的输入通道数和输出通道数,以及stride。

对于第一个stage(conv2_x),输入通道是64(来自stem层),输出通道是64,stride=1,不需要下采样。对于后续的stage,输入通道是上一个stage的输出通道,输出通道翻倍,stride=2,第一个Block需要下采样。

具体实现时,我用layers列表来收集每个stage的Block,然后传给nn.Sequential。每个stage的第一个Block单独处理,传入downsample参数,后面的Block直接复用。

class ResNet18(nn.Module): def __init__(self, num_classes=1000): super(ResNet18, self).__init__() self.in_channels = 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(64, 2, stride=1) self.layer2 = self._make_layer(128, 2, stride=2) self.layer3 = self._make_layer(256, 2, stride=2) self.layer4 = self._make_layer(512, 2, stride=2) 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_size=1, stride=stride, bias=False), 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

3.3 参数量与计算量的估算

ResNet18的参数量大约是11.7M(1170万),在ImageNet上top-1准确率大约69.8%。这个参数量在当时的背景下算是比较轻量的,比VGG16的138M小了一个数量级,但效果更好。

计算量方面,ResNet18的FLOPs大约是1.8G(18亿次浮点运算),输入尺寸224x224。大部分计算量集中在conv2_x到conv5_x,stem层的7x7卷积虽然计算量大,但只占一小部分。

如果你想验证自己的实现是否正确,可以用torchsummary或者手动计算每一层的输出尺寸。一个简单的检查方法是:输入一个torch.randn(1, 3, 224, 224),看输出是不是(1, num_classes)。如果中间某一步尺寸对不上,大概率是下采样的位置搞错了。

4. 训练ResNet18时那些文档不会告诉你的坑

4.1 批归一化与权重初始化的配合

ResNet的论文里强调了权重初始化的重要性。虽然有了BatchNorm之后,初始化没那么敏感了,但用对初始化方法仍然能加速收敛。PyTorch默认的初始化是Kaiming均匀初始化,对卷积层来说基本够用。但如果你自己写初始化,记得对卷积层用Kaiming正态初始化,对BN层的weight初始化为1,bias初始化为0。

有一个细节:ResNet的最后一个BN层(每个Block的bn2)在初始阶段输出接近零,这样残差块的输出就接近恒等映射,相当于网络一开始就有一个好的起点。这是ResNet设计的一个巧妙之处,BN层在这里起到了正则化和稳定训练的双重作用。

4.2 学习率与优化器的选择

训练ResNet18,我一般用SGD加上动量0.9,weight decay 1e-4。学习率初始值设0.1,配合余弦退火或者阶梯下降。如果用Adam,学习率要调小到1e-3左右,但实测下来SGD的泛化性能更好,尤其是在ImageNet这种大规模数据集上。

Batch size对BN的影响很大。BN在batch size太小的时候统计量不稳定,一般建议batch size至少32。如果显存不够,可以用梯度累积来模拟大batch。我试过batch size=16训练ResNet18,准确率比batch size=256低了大概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统计量导致输出不稳定。

还有一个隐蔽的坑:inplace=True的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 → 112(conv1)→ 56(maxpool)→ 56(layer1)→ 28(layer2)→ 14(layer3)→ 7(layer4)→ 1(avgpool)。如果某一步对不上,就回去检查那个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卷积,stride=1,去掉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以下。这个对比让我真正理解了残差学习的价值,也推荐你在学习的时候做一个这样的对照实验,比看任何论文都直观。

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

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

立即咨询