ResNet-18是我在实际项目里用得最多的网络,没有之一。做图像分类要选个baseline,做物体检测要挑backbone,做迁移学习要拿个预训练模型,最后总会绕回它。这篇文章就按自己的理解,从原理到代码到训练细节一次讲透:18到底怎么数、残差连接为什么有效、每一层在干什么、PyTorch怎么搭、训练时哪些坑要躲。
先说结论给没耐心的人:ResNet-18是残差网络家族里最轻量、最容易上手的成员,参数量约11.7M(百万级),能在ImageNet上达到接近72%的top-1准确率,而且结构足够简单,适合作为理解现代卷积神经网络的起点。接下来我按实际学习顺序展开,不堆论文公式,尽量用工程视角把每个设计讲明白。
1. ResNet-18到底是什么:从“18”这个数字说起
1.1 网络退化问题:为什么要发明残差结构
早年间大家有个直觉:网络越深,表达能力越强,效果就越好。但实际上直接把几十层普通卷积网络堆上去,训练误差先降后升——注意是“训练误差”变大,不是验证集过拟合。这个问题一度让深层网络没法落地。
举个生活化例子:你让一个实习生去读一本百科全书,每天给他加页数,结果他连第一遍都读不完,不是书变难了,是他的学习方法出了问题。网络也是一样,梯度在反向传播中一层层相乘,层数深了以后梯度要么爆炸要么消失,前层参数基本收不到有效更新,深层网络实际学到的东西非常有限。
有人可能想说BatchNorm不是能缓解吗?BN确实能把梯度量级压住,但深层网络仍然很难训练。2015年ResNet被提出来后,一个非常朴素的改动解决了这个问题:不再让网络直接学习目标映射H(x),而是学一个残差F(x)=H(x)-x,最后用F(x)+x作为输出。如果恒等映射已经足够好,网络只需要把F(x)学到接近0,这比硬生生去拟合H(x)容易得多。这个改动让可以训练的网络深度从十几层推到上百层,ImageNet上的分类错误率也跟着掉了一大截。
1.2 权重层到底怎么数
“18”这个数字,很多新手会数错。常见误区是把池化层、ReLU甚至BN也算进去,然后发现怎么数都对不上。ResNet系列的层数约定,数的是“带权重参数的层”,也就是卷积层和全连接层。
ResNet-18由这几部分构成:
- 一个7×7卷积,第1层;
- 4个stage,每个stage里有2个BasicBlock,每个BasicBlock里有2个3×3卷积,共4×2×2=16层;
- 最后接一个全连接分类层,1层。
所以1 + 16 + 1 = 18。BN层里也有可学习的缩放和平移参数,但在模型命名时不算层。池化、ReLU这些更不算。
顺带说一句,ResNet-34就是把每个stage的block数从[2,2,2,2]改成[3,4,6,3],总权重层是1+(3+4+6+3)×2+1=34。ResNet-50之后改用Bottleneck结构,计数方式会变,但“只数权重层”这条规则是不变的。你只要记住:这个数字表示的是“需要被训练的参数层”数量,不是网络总深度。
2. 残差单元的核心逻辑:为什么F(x)+x就好使
2.1 从拟合目标到残差学习
先看数学上的变化。假设我们想学一个理想映射H(x),传统网络直接让卷积堆叠去逼近H(x)。残差网络改为去逼近F(x)=H(x)-x,最后的输出是F(x)+x。为什么这样改会更好?至少有两个层面能解释。
第一层是优化难度。如果H(x)和x非常接近,尤其当恒等映射已经接近最优时,直接学H(x)意味着让一堆卷积层去拟合一堆特别接近恒等映射的函数,参数更新方向非常微妙。而学残差的话,网络只要让权重趋近0,F(x)自然趋近0,输出就约等于输入x。一个“什么都不做”的模块,在传统结构里很难实现,因为权重一旦初始化为0或特别小,梯度传不回来;在残差结构里却很自然。
第二层是梯度传播。反向传播公式里多了一条从后面层直达前面的恒等路径。假设损失对某一层的梯度已知,下一层的输入等于本层输出加捷径分支,那导数里天然带一个“+1”项。这个“+1”保证深层信号在反向传播时始终原样保留一份,即使残差分支梯度很小,整体梯度也不会完全消失。层数越深,这条“高速公路”越关键。
2.2 维度不匹配时:恒等短接与投影短接
残差相加要求F(x)和x的shape完全一致。在ResNet-18里,绝大多数BasicBlock是恒等短接:输入输出同尺寸、同通道数,直接相加就行。真正麻烦的是每个stage的第一个block——通道数翻倍,分辨率减半,这时候需要做一次“投影”。
投影短接的做法是用一个1×1卷积,输出通道等于目标通道数,stride设为2,让特征图分辨率和主路径对齐,然后两个分支相加。1×1卷积本质上就是每个位置做一次线性变换,参数量很小,不会给网络增加多少负担。注意这个投影分支只在维度变化时才出现,ResNet-18一共出现在第2、3、4个stage的第一个block,共3处。
这里有一个容易被忽略的设计细节:作者在做维度变化时,把特征图尺寸整倍数减半、通道数整倍数翻倍,保持计算量和信息量的平衡。这个“干净”的尺度设计是ResNet另一个高明之处,后面做工程复现、做剪枝量化都会方便很多。你在自己设计网络时也可以参考这个思路:分辨率变化和通道变化尽量成倍数关系。
3. ResNet-18网络结构逐层拆解
3.1 从输入到输出的完整数据流
以ImageNet标准输入224×224×3为例,我把完整流程整理成了一张表。这张表建议保存下来,后面无论写代码还是看别人项目,对照着看会清楚很多。
| 模块 | 具体操作 | 输出尺寸 |
|---|---|---|
| conv1 | 7×7卷积,64个卷积核,stride=2 | 112×112×64 |
| bn1 + relu | BatchNorm + ReLU激活 | 112×112×64 |
| maxpool | 3×3最大池化,stride=2 | 56×56×64 |
| layer1 | BasicBlock×2,64→64,stride=1 | 56×56×64 |
| layer2 | BasicBlock×2,64→128,首个block下采样 | 28×28×128 |
| layer3 | BasicBlock×2,128→256,首个block下采样 | 14×14×256 |
| layer4 | BasicBlock×2,256→512,首个block下采样 | 7×7×512 |
| global avgpool | 全局平均池化,每个通道取平均值 | 1×1×512 |
| fc | 全连接层,512→类别数 | 1000 |
可以看到空间分辨率从224一路降到7,通道数从64升到512。最后不直接用Flatten接全连接,而是先用全局平均池化把每个通道压成一个数,再进分类层。这样做最大的好处是大幅减少全连接层参数量,同时让网络对输入尺寸不那么敏感。理论上任意分辨率输入都能经过池化得到固定长度向量,这在做检测、分割这类下游任务时特别有用。
3.2 BasicBlock内部到底怎么串的
BasicBlock是ResNet-18的基本单元,内部代码逻辑只有几步,但顺序很关键。
输入x先过一个3×3卷积(stride可能为2),然后过BN,再过ReLU;接着过第二个3×3卷积,过BN;如果shortcut需要投影,先算好shortcut分支;把主分支输出和shortcut分支相加,最后再做一次ReLU。
有一个细节大家实现时特别容易踩坑:第二个卷积后的BN,输出要先和shortcut相加,再一起过ReLU。如果写成“每个卷积后都接ReLU”,那就把残差结构加错了位置,训练效果会有明显差别。主路径上激活函数的位置直接决定了恒等路径上经过的非线性变换有多少。BN放在卷积之后、激活之前,是为了让激活函数的输入保持稳定范围,这也是现代CNN里通用做法。
3.3 和ResNet家族其他成员怎么选
ResNet-18和ResNet-34用的是BasicBlock,ResNet-50及以上的深层版本用的是Bottleneck结构,也就是把两个3×3卷积换成1×1、3×3、1×1三段式,先降维再升维,控制计算量。从参数量看,ResNet-18约11.7M,ResNet-34约21.8M,ResNet-50约25.6M,ResNet-101约44.5M。单看数字,18比50少了差不多一半多,但性能差距往往没有参数差距那么夸张,在简单任务上完全可能持平。
怎么选?我的经验是:数据量小、任务简单、对延迟敏感,直接上ResNet-18;数据量大、任务本身复杂、硬件允许,再考虑50或者101。对刚开始接触这个系列的人来说,先手写一个ResNet-18,把权重层、下采样、维度变换这些概念吃透,再去看Bottleneck就很轻松了。家族设计是一脉相承的,18是理解其他成员的钥匙。
4. 从零搭建ResNet-18:PyTorch代码实操
4.1 BasicBlock与ResNet-18完整实现
代码我提供一个简洁版本,不依赖torchvision,适合学习和二次魔改。核心就两个类:BasicBlock和ResNet18。
import torch import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_channels, out_channels, stride=1): super().__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.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = F.relu(out) out = self.conv2(out) out = self.bn2(out) out = out + self.shortcut(identity) out = F.relu(out) return out class ResNet18(nn.Module): def __init__(self, num_classes=1000): super().__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.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): strides = [stride] + [1] * (blocks - 1) layers = [] for s in strides: layers.append(BasicBlock(self.in_channels, out_channels, stride=s)) self.in_channels = out_channels return nn.Sequential(*layers) def forward(self, x): x = self.maxpool(F.relu(self.bn1(self.conv1(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 if __name__ == "__main__": model = ResNet18(num_classes=1000) x = torch.randn(2, 3, 224, 224) y = model(x) print(y.shape) # torch.Size([2, 1000]) total = sum(p.numel() for p in model.parameters()) print(f"参数量: {total / 1e6:.2f}M") # 约11.69M跑通之后,你会在终端看到输出shape是[2, 1000],参数总量打印出来就是11.69M左右。代码里两个地方值得特别说明:一是所有卷积都设置了bias=False,因为后面紧跟BN层,BN里的可学习参数已经能完成平移和缩放,卷积再带bias属于冗余;二是_make_layer里先处理stride列表,第一个block用传入的stride,其余用1,这是实现下采样的关键,理解这段代码就理解了ResNet整个家族的下采样逻辑。
4.2 训练配置与超参数建议
模型写完能不能训起来,很大程度取决于超参数。我整理了两套常用配置,第一套偏ImageNet这类大图数据集,第二套偏CIFAR-10这类小图数据集,两者来源都是学术界和工业界反复验证过的默认值。
| 配置项 | ImageNet风格 | CIFAR-10风格 |
|---|---|---|
| 输入尺寸 | 224×224 | 32×32 |
| batch size | 256 | 128 |
| 初始学习率 | 0.1 | 0.1 |
| 学习率调整 | 30/60 epoch衰减0.1 | 100/150 epoch衰减0.1 |
| 优化器 | SGD(momentum=0.9) | SGD(momentum=0.9) |
| weight decay | 1e-4 | 5e-4 |
| 训练轮数 | 90 | 200 |
两个细节说明一下。学习率0.1这个值对应的是batch size 256的情况。如果显存不足把batch降到64,lr最好也跟着降,简单做法是让lr按batch_size/256线性缩放,例如batch=64时可以取0.025。weight decay在小数据集上稍微调大一些,能明显缓解过拟合。切换任务时不要只盯着epoch数量,数据规模变了,训练策略基本都得重调。
4.3 小尺寸输入的调整技巧
直接用224×224训练好的结构和预训练权重搬到CIFAR这种32×32数据上,第一层7×7 stride=2的卷积作用在32×32图上,感受野太大,特征信息损失非常严重。我自己的做法是,对32×32输入单独建一个变体:把conv1改成3×3、stride=1、padding=1,去掉第一个maxpool,避免前两层就把分辨率立刻降到8×8甚至更小。
conv1改成3×3之后,没法直接加载ImageNet预训练权重,因为虽然输入通道都是3,但卷积核尺寸一个是7×7一个是3×3,权重张量对不上。这时候要么重新训练,要么只加载后面几层的权重。这个改动在CIFAR-10上,ResNet-18精度通常能到93%以上,不改的话性能差距非常明显。如果你在别的数据集上发现ResNet-18效果还不如小网络,先检查首层设计是不是没适配输入分辨率。
5. 使用ResNet-18的常见问题与排查经验
5.1 训练loss不下降或直接NaN
这是新手最容易撞上的问题。排查顺序我一般是这样:先看数据,确认输入里没有异常值、标签没有错乱;再看学习率,ResNet-18用SGD一般从0.1起步,但如果改了batch size没同步调lr,或者数据量特别小,loss很容易横住不动;最后看梯度,可以在每个step后打印grad_norm,数量级在1附近算正常,超过100就危险了。
如果加了warmup——前几个epoch从0.01线性升到0.1——很多深层模型和大batch训练会稳很多。损失曲线在初始阶段往下走,不代表后面就安全,BN层统计量在训练早期还没稳定,这时候学习率太大同样可能直接炸出NaN。遇到NaN不用先怀疑代码写错,按数据、学习率、梯度这个顺序排查,大概率能在十分钟内定位。
5.2 BN在训练和推理时的割裂
BatchNorm在训练时用的是当前batch的均值方差,在推理时用的是训练过程中累计的running_mean和running_var。如果训练完忘记调model.eval(),或者做推理的代码里一直开着model.train()状态,结果就是同样的输入每次跑出来输出都不一样,精度忽高忽低。这个坑最常见,也最好修,只要在推理前加一行model.eval()就能解决。
还有一个迁移学习里的经典坑:用预训练ResNet-18做微调时,如果只冻结backbone但没处理BN层,会导致网络行为不稳定。因为卷积权重冻结了,BN的统计量还在随当前batch变化,而小batch下统计量噪声很大,整个推理结果会飘。我在代码里一般会对要冻结的层做逐层检查,BN的requires_grad和train/eval状态分开处理,这个小细节能省掉很多调试时间。
5.3 预训练模型最后一层怎么改
从ImageNet预训练模型迁移到自己的分类任务时,最后一层fc输出必须换成自己的类别数。替换fc之后,新fc层权重是随机初始化的,而前面backbone是ImageNet的权重,如果直接用同一个学习率训练,随机初始化的fc层梯度比backbone大得多,很容易把预训练权重冲坏。
经验做法是先冻结backbone,只训练fc层练几个epoch,让分类头先收敛,再解锁backbone,用较小的学习率整体微调。或者在优化器里给fc层单独设置一个更大的学习率,比如设为backbone的10倍。这个技巧在数据量不大时尤其管用,能明显提升微调后的最终精度。
5.4 部署和加速上的一个判断
ResNet-18真正吸引我的地方不只是精度,而是它在CPU和低算力设备上也能跑起来。跟ResNet-50相比,18的参数量和计算量少了将近一半多,但不少任务的精度差距只在个位数以内。在工业场景里,如果模型要放边缘设备,我通常会先用18跑通完整流程,再做INT8量化或者蒸馏。如果精度还不够,再考虑更大的模型,而不是一上来就上ResNet-50。
另外,ResNet-18作为骨干网络时,后面往往还会接FPN做目标检测、接FCN做语义分割、接各种注意力模块做特征增强。它几乎成了整个视觉领域的“通用底座”。这也是为什么即使新模型层出不穷,我依旧建议把ResNet-18作为打底网络的原因——它简单、稳定、普适,几乎所有训练技巧都可以在它身上先验证。
最后分享一点个人经验:不管你现在做分类、检测还是分割,我都建议花一个下午,不借助现成库,手写一遍ResNet-18。把BasicBlock的forward、维度变化的每个细节彻底弄清楚以后,再看其他网络结构也会轻松很多。这算是我自己学习路线里收获最大的一次手写复现。跑通那一刻,你会对“深度学习模型不过是张量流动”这句话有非常直观的体会。