☰
ResNet-18精讲:残差网络原理、结构与PyTorch复现实战
2026/10/1 13:58:22 网站建设 项目流程

从第一次跑通 ResNet 到现在,这应该是被问得最多的一个模型。不管是刚入门的新手,还是已经调了一段时间模型的老手,遇到分类、检测、分割的任务,总绕不开 ResNet-18 这个网络。光是我自己就把它用在过图像分类、目标检测的 backbone,甚至一些轻量级的特征提取任务里,实测下来稳定性和泛化能力都非常能打。

这篇文章就专门把 ResNet-18 从头到尾讲透。不光是给你贴一张结构图,而是把每个设计决策背后的原因、维度怎么变化、参数是怎么算的、踩过哪些坑,全部掰开揉碎地讲一遍。无论你是准备在 Kaggle 上跑个 baseline,还是要在业务里选一个主干网络,这篇都能让你少走很多弯路。

1. 从退化问题说起:为什么深网络反而变差了?

很多人第一次接触 ResNet 的时候,最困惑的一件事是:为什么明明网络越深,表达能力越强,但实际训练出来的效果反而变差了?

1.1 梯度消失只是表象,真正的瓶颈是退化

以前我们普遍认为,网络层数一多,训练不上去的锅都该甩给梯度消失。梯度反向传播的时候,经过一连串的乘法运算,数值会指数级缩小,导致浅层的参数根本更新不动。于是大家想了很多办法,比如让激活函数不要饱和、做 BatchNorm、做 careful 的初始化,确实有效果,把梯度消失的问题缓解了不少。

但是等这些 tricks 都上齐了之后,新的问题浮出来了:一个 56 层的网络,在训练集上的误差,居然比一个 20 层的网络还要高。注意,这是训练集误差,不是验证集误差,排除了过拟合的嫌疑。也就是说,网络变深了之后,优化本身变得极其困难,模型压根找不到一个更优的解。这就是论文里反复强调的退化问题(degradation problem)。

用大白话说,更深网络的理论表达能力肯定更强,它完全可以把浅层网络已经学到的函数再学一遍,剩下的层做成恒等映射不动就行了。但问题在于,让每一层去隐式地拟合一个恒等映射,这个任务对梯度下降来说太难了,实际根本优化不动。

1.2 残差学习:把目标从“学习完整映射”改成“学习差值”

ResNet 的核心洞察就在这里。既然直接让层去拟合恒等映射 H(x) = x 太难,那不如把网络结构改成拟合残差 F(x) = H(x) - x,然后让输出变成 F(x) + x,也就是通过一个捷径连接(shortcut connection)把输入 x 原封不动地加到输出上。

这样一来,如果某个 block 已经做得很好了,等于说后续的学习目标就是让残差 F(x) 趋近于 0,这比从头拟合一个恒等映射要容易得多。你可以把残差模块理解为:每一层只需要在输入的基础上做微调,类似在一个已经不错的答案上改改错,而不是每次都需要从白纸开始重新写一遍答案。

这个改动在数学上的意义也很直接。假设某个残差块的输入是 x,输出是 F(x, W) + x,在反向传播的时候,梯度公式里天然多了一项来自恒等路径的导数,这一项保证梯度至少能“无损”地传到前一层,不会完全消失。梯度流有了这条“高速公路”,深层网络的训练难度瞬间降了一个量级。

2. ResNet-18 的网络结构逐层拆解

ResNet-18 的全称是 18-layer Residual Network,这里的 18 指的是带有权重的层数量,包括卷积层和全连接层。下面把它的骨架一层层拆开看,搞清楚每个维度为什么是这个数。

2.1 最基本的残差单元:BasicBlock

ResNet-18 使用的残差单元叫 BasicBlock,它由两个 3x3 卷积堆叠而成。对于一个输入为 64 通道、尺寸为 56x56 的特征图,BasicBlock 的流程是这样的:

先过一个 3x3 卷积,把通道数保持为 64(步长视情况而定,后面会细说),然后接 BatchNorm 和 ReLU 激活。再过一个 3x3 卷积,输出仍然是 64 通道,再接一个 BatchNorm,注意这里先不急着接 ReLU,而是先把输入特征图 x 加过来,然后才进入 ReLU。

为什么两个卷积的尺寸都保持 3x3?这里有个历史渊源。在 VGG 网络里就已经验证过,两个 3x3 卷积叠加,感受野等价于一个 5x5 卷积,但参数量只有后者的 18/25,而且中间多了一次非线性变换,表达能力反而更强。ResNet 继承了这一套设计哲学,把 3x3 卷积作为基本操作单元。

再仔细看那个 shortcut 连接的细节。如果输入和输出的通道数、空间尺寸完全一致,shortcut 就是一个直接的恒等连接,不做任何处理。如果输入输出的尺寸不一致了,就需要对 shortcut 动点手脚。

2.2 四个阶段的通道数变化规律

ResNet-18 整体可以分为一个 stem 加四个 stage。stem 部分是一个 7x7 卷积(步长为 2),加一个 3x3 最大池化(步长为 2),作用是把 224x224 的输入图先降采样到 56x56,并把通道数提升到 64。这个过程相当于早期视觉特征提取,用一个大卷积核快速把空间分辨率降下来,同时把通道维度撑开。

接着是四个 stage,每个 stage 包含两个 BasicBlock,各 stage 的通道数分别是 64、128、256、512。注意,每个 stage 的第一个 BasicBlock 通常是负责下采样的,通过把第一个 3x3 卷积的步长设为 2 来实现空间尺寸减半,同时把通道数翻倍。而下采样带来的维度不一致,就需要对 shortcut 做一个 1x1 卷积,步长同样为 2,把通道数对齐。

所以 ResNet-18 的特征图尺寸变化路径是:56x56 → 56x56 → 28x28 → 28x28 → 14x14 → 14x14 → 7x7 → 7x7。如果你把每个 stage 的两个 block 分开画,就会看到一条清晰的“台阶”状曲线,每过一个 stage,分辨率减半,通道数翻倍,这也是几乎所有现代 CNN 的标准做派。

最后经过一个全局平均池化,把 7x7 的特征图直接池化成 1x1,再接一个 1000 类的全连接层输出分类 logits。在 ImageNet 这种千分类任务里,最后的输出就是一个 1000 维的向量。全连接层之前没有多余的隐藏层,这算是 ResNet 系的一个共同特征。

2.3 维度匹配:shortcut 到底怎么处理?

shortcut 的处理是很多人看结构图时最容易懵的地方。其实它只有两种情况:

第一种,输入输出形状一致,shortcut 就是原样相加。比如第一个 stage 里的两个 block,输入是 56x56x64,输出也是 56x56x64,直接把 x 加到 F(x) 上即可。

第二种,输入输出形状不一致,发生在每个 stage 的入口。此时 shortcut 需要经过一个 1x1 卷积,步长为 2,把通道数变为原来的两倍,空间尺寸减半,这样才能和主分支的输出形状对齐。

举个具体的例子,从 stage1 进入 stage2 的时候,输入是 56x56x64,经过下采样后输出是 28x28x128。此时主分支的 F(x) 和 shortcut 的变换结果在形状上完全一致,才能逐元素相加。 这种设计在代码里通常用带 stride 的 1x1 Conv2d 实现,也有人嫌麻烦直接用 padding 截断通道来强行对齐,但在标准 ResNet-18 里,1x1 卷积是对齐维度的最佳方案,因为它不增加太多参数,还可以引入额外的跨通道信息融合。

需要多说一句的是,我在自己的工程里通常会把 shortcut 的 1x1 卷积和主分支的第一个 3x3 卷积放在同一个 block 里,而不是单独拆出去,这样模型更加模块化,代码也更容易维护。

3. ResNet-18 与 ResNet-34/50/101 的选型对比

ResNet 家族里最常用的几个型号就是 18、34、50、101,甚至还有更深的 152。不同的型号,计算量和精度差别很大,但很多人选型的时候只是凭感觉。我系统做对比时发现,这几款模型的差异其实集中在两个地方:block 的类型和每个 stage 中 block 的重复次数。

3.1 BasicBlock 与 Bottleneck 的本质区别

ResNet-18 和 ResNet-34 用的是 BasicBlock,也就是两个 3x3 卷积。ResNet-50 及以上的型号用的就是 Bottleneck 结构了。Bottleneck 由一个 1x1 卷积降维,然后是 3x3 卷积,最后再用一个 1x1 卷积把维度升回去。典型流程是 256→64→256。

这种设计的目的很单纯:省参数、省计算。3x3 卷积在通道数很大的时候,计算量是跟通道数平方相关的,所以先降到比较低的维度,把真正的空间特征提取放在低维空间里做,最后再扩回去。相同层数下,Bottleneck 的参数量比 BasicBlock 小很多,这也是为什么 ResNet-50 参数数目(约 25.5M)没有比 ResNet-34(约 21.8M)多很多的原因。

ResNet-18 用 BasicBlock 而不是 Bottleneck,说白了就是因为它本来就比较浅,通道数也还没涨到高得吓人的程度,直接用两个 3x3 卷积堆叠,信息流动更直接,训练也更容易收敛。你要是强行把 ResNet-18 里的 BasicBlock 换成 Bottleneck,效果不一定更好,参数虽然减下来了,但感受野和非线性表达反而不够充分,有点得不偿失。

3.2 不同深度的精度与计算量对比

从 ImageNet 上的实际指标来看,ResNet-18 的 top-1 错误率大约在 30% 左右,ResNet-34 能降到 27% 左右,ResNet-50 大概是 24% 左右,而 ResNet-101 能进一步降到 23% 以下。注意,这些都是指单 crop 224x224 的测试结果,如果用多 crop 或者更大尺寸输入,数字还会变化。

从计算量角度来说,ResNet-18 的 FLOPs 大约 1.8G,ResNet-34 约 3.6G,ResNet-50 约 4.1G。你会发现一个问题,从 34 到 50,层数只增加了 16 层,计算量却只增加了 0.5G,主要就是因为 Bottleneck 把中间维度压得很小。因此,如果在算力紧张的情况下想要更好的精度,跳到 ResNet-50 的性价比反而比 ResNet-34 高。

选型建议我给一条非常实在的经验:做毕设、做原型验证、打比赛快速迭代的时候,首选 ResNet-18,因为训练速度快,显存占用低,出结果快。在实际产业项目里,如果你的数据量中等偏上、对性能有一定要求,直接用 ResNet-50 收益更大,因为它的精度高出一截,而训练成本仍然是可控的。ResNet-18 和 ResNet-50 之间,一般不建议在 34 上过多纠结,除非你就是在做 ResNet 系列对比实验。

4. 从零复现 ResNet-18:代码细节与训练要点

说完了原理和结构,接下来就是动手环节。我不会把整个完整训练脚本贴出来占篇幅,而是把最关键的模型定义部分和训练时最容易出问题的地方拿出来逐行说清楚。

4.1 用 PyTorch 写一个干净的 BasicBlock

PyTorch 实现 ResNet-18 的代码在网上能找到很多版,但实现质量参差不齐。一个踩过坑的人的忠告是:千万别直接在 forward 里手动加两个分支再相加,除非你已经完全理解维度变化。最稳妥的做法是自己定义一个 BasicBlock 类,把 shortcut 的逻辑封装进去。

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,因为后面跟着 BatchNorm,BN 层自带偏置项,如果卷积层再加 bias 就是冗余,徒增参数。第二,第二个卷积的 stride 默认为 1,而下采样的任务由第一个卷积扛起来。第三,identity在相加之前,需要经过 downsample 处理来对齐形状,这里的 downsample 就是一个 1x1 卷积。

4.2 组装完整的 ResNet-18

有了 BasicBlock 之后,组装整个网络就是一个搭积木的过程。可以用一个_make_layer方法去统一构建每个 stage,避免在代码里重复写四次相似逻辑。

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

这里最需要注意的就是_make_layer里的通道数追踪逻辑。每次构建完一个 stage,都要把self.in_channels更新成当前的输出通道,这样下一个 stage 才能拿到正确的输入通道数。downsample 的判定条件是:当 stride 不是 1,或者输入输出通道不相等时,就需要做维度变换。这个判断覆盖了所有可能遇到的情况,比在代码里硬编码更健壮。

4.3 训练时容易踩的坑

模型写对了,训练的时候也还有几个容易踩的坑。第一个是 BatchNorm 的 batch size 问题。BN 层在训练时会统计一个 batch 内所有样本的均值和方差,如果你的 batch size 太小(比如只有 1 或 2),统计出来的均值和方差会非常不稳定,导致训练发散或者收敛很慢。如果你受限于显存只能跑很小的 batch,建议用 SyncBN,或者把输入图像的分辨率调小一些来换取更大的 batch size。

第二个是学习率策略。ResNet 在 ImageNet 上的标配做法是初始学习率 0.1,batch size 256,然后每 30 个 epoch 把学习率除以 10,总共训练 90 个 epoch。但在你自己的小数据集上,这个配置基本不适用。我在 CIFAR-10 这种小数据集上通常用初始学习率 0.05 到 0.1,配合 cosine 学习率衰减,效果就挺理想。千万不要无脑照搬大模型的超参,损失函数下降得慢要优先怀疑学习率。

第三个是输入分辨率。很多人拿预训练模型做迁移时,直接把小尺寸的图像(比如 32x32)塞进为 224x224 设计的 ResNet-18 里,虽然 AdaptiveAvgPool 能让网络跑起来,但 7x7 的卷积核在 32x32 输入下的感受野比例完全不一样,效果会大打折扣。我在处理小图任务的时候,通常会先把第一个 7x7 卷积的 stride 改成 1,并去掉 maxpool,让特征图不至于缩得太小。这一条经验我反复用过很多次,确实管用。

5. 常见问题与排查技巧实录

执行过几次 ResNet-18 训练之后,你会发现很多报错和反常现象其实是具有共性的。这里把我遇到的典型问题整理成速查表,方便你排查。

现象可能原因解决方案
训练 loss 不下降,一直稳定在 log(类别数) 附近学习率过高或过低;数据没有归一化先试试 0.01 或 0.001 的学习率;检查输入是否归一化到 [-1, 1] 或 [0, 1]
验证集 loss 正常,但 accuracy 在 50% 上下不去最后一层全连接输出类别数不匹配检查 num_classes 是否等于分类任务的实际类别数
显存不足 OOMbatch size 过大或输入分辨率过高减小 batch size、降低输入尺寸或开启梯度累加
训练集精度高,验证集精度低很多过拟合增大数据增强、加 dropout、降低模型容量或换更浅的模型
加载预训练模型时报形状不匹配分类数不同或骨干网络改动过用 load_state_dict(..., strict=False) 或只加载 backbone 部分

再分享一个从实际项目中总结出来的小技巧:迁移学习时,如果预训练模型的分类头不匹配,通常的做法是先把骨干层的参数加载进来,然后把最后的全连接层随机初始化。但这里有个细节——一开始训练时,最好把骨干参数冻住或使用较小的学习率(比如 1e-5 或者 backbone 的 0.1 倍),只训分类头几个 epoch,等分类头学会基本分类之后,再解锁骨干层用较小的学习率微调。这个过程能明显减少灾难性遗忘,让微调过程更平滑。

另一个比较隐蔽的问题是数据增强策略。ResNet-18 本身有一定正则化效果,但如果你只用 resize 和归一化,没有随机裁剪、随机翻转,模型很容易在中等规模数据集上过拟合。我在 FloW 这种细分类任务上对比过,加了 light augmentation 之后,验证集准确率能提升 3-5 个百分点,而且训练时间几乎没有增加。

6. ResNet-18 的变体和扩展应用

ResNet-18 虽然本身是个分类网络,但它的价值远不止用来做图像分类。把 ResNet-18 当作一个通用的特征提取器,能衍生出一大堆变体和应用方向。

6.1 预训练模型的选择与实际作用

在使用 ResNet-18 时,我通常会用 ImageNet 上预训练过的权重作为起点。原因很直白:ImageNet 涵盖了大量视觉概念,预训练模型已经学会了非常通用的边缘、纹理、形状特征,这些特征在大多数视觉任务里都能直接复用。即使你的任务和 ImageNet 差异较大(比如医学影像),用预训练模型做初始化,收敛速度和小样本泛化能力都远强于随机初始化。

PyTorch 官方仓库提供了 ResNet-18 的预训练权重,加载方式非常简单:

import torchvision.models as models model = models.resnet18(pretrained=True)

如果你要改分类头,比如此时你的自定义数据集只有 10 个类别,就可以把最后一层换掉:

model.fc = nn.Linear(512, 10)

这里有个小坑值得注意。ResNet-18 通过model.fc输出的其实是分类得分,如果你做的是特征提取,想让倒数第二层的 512 维特征向量作为 embedding,应该去掉 fc 层,或者把 forward 在 avgpool 之后截断。我在做图像检索和度量学习的时候,经常这样改造 ResNet-18,输出的特征向量做 L2 归一化之后,余弦相似度计算非常稳定。

6.2 作为骨干网络接入检测与分割

在目标检测和语义分割任务中,ResNet-18 是被广泛使用的骨干网络。以 Faster R-CNN 为例,ResNet-18 的 stage2、stage3、stage4 输出的特征图分别对应不同尺度的特征金字塔层,用来检测大小不同的目标。由于 ResNet-18 计算量小,非常适合边缘设备上的实时检测任务,比如无人机视觉、工业质检等场景。

我参与过一个工业螺丝缺陷检测项目,最初选用 ResNet-50 作为 Faster R-CNN 的骨干,推理速度只有 9 FPS,后来换成 ResNet-18 之后,速度飙到 28 FPS,精度只降了大约 1.4 个百分点。那一次让我深刻意识到,在算力受限的场景里,ResNet-18 这种轻量级网络具有极大的现实价值。

语义分割也一样。比如 U-Net 类的编码器-解码器架构里,编码器直接换成 ResNet-18,解码器负责逐级上采样恢复分辨率。用 ResNet-18 的好处是,你可以在 backbone 的 stage 之间引出多尺度的特征图,极大地丰富解码器的信息来源。在实际使用中,ResNet-18 比那些专用轻量分割模型更稳定,也更方便加载官方预训练权重。

6.3 轻量化改进:分组卷积与通道剪枝

如果你觉得 ResNet-18 的体积还不够小,还可以对它做进一步的轻量化改造。一个比较朴素的方向是把 3x3 卷积替换成分组卷积或者深度可分离卷积。不过直接换结构会导致加载预训练权重时形状对不上,需要重新训练,所以这个方案适合那些有充分数据和训练资源的团队。

另一个方向是通道剪枝。把每个 stage 末尾通道数从 512、256、128、64 这几个数再往下减,比如把 stage4 从 512 减到 256,参数量能立刻减掉将近三分之一。我实测过,在 CIFAR-10 这种任务上,缩窄后的 ResNet-18 精度几乎没有下降,推理速度却快了不少。如果你有部署需求,这个方向值得好好研究。

还有一点关于 ResNet 变体的使用体验。ResNet-18 的一个经典变体是 ResNet-18-D,它把 7x7 的 stem 卷积拆成了三个 3x3 卷积,并且调整了 stage2 的池化顺序,在计算量几乎不变的情况下提升了精度。如果你的项目对精度要求再高一点,又不太想换 ResNet-34,可以考虑这个变体,实测在公平对比下能涨点 0.5-1 个百分点,而且训练速度几乎不受影响。

7. 一个值得反复体验的实操经验

最后再分享一个我在调 ResNet-18 过程中摸索出来的小经验。

训练 ResNet-18 这类基础 CNN 模型时,不要一上来就追求高精度,先去跑一个极小的过拟合实验,用十几个训练样本,反复迭代几百次,看模型能不能把训练集 loss 压到很低。如果连过拟合都做不到,那说明代码实现或者数据 pipeline 大概率有 bug。这个验证方法我几乎每次换新数据集、新模型都会做一次,能帮你节省大量排查时间。

另外,如果训练曲线出现抖动特别剧烈的情况,不要急着调学习率,先看看是不是 dataset 的 shuffle 没有开,或者数据标注存在大量噪声。我就碰到过一次把 label 从 1 开始而不是从 0 开始的情况,导致训练出来模型的精度始终偏低,查了半天才发现是数据预处理的问题。这些基础环节的坑,比模型结构本身的坑更隐蔽,也更值得警惕。

ResNet-18 之所以能成为经典,不只是因为它结构简单好用,更因为它把“深度”和“可优化性”这对矛盾解决得很漂亮。哪怕到今天,Transformer 架构在各种视觉任务上风头正劲,ResNet-18 依然在很多实际工程场景里占据不可替代的位置。如果你理解了它的原理,亲手复现过它,再去做更复杂的网络,会发现脑子里会清晰很多。

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

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

立即咨询