拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ResNet-18深度解析:从残差思想到PyTorch实现与部署

ResNet-18深度解析:从残差思想到PyTorch实现与部署

去年帮一个团队把分类模型从端到端的Transformer换回ResNet-18,原因是边缘设备上的推理延迟始终压不下来,而换回ResNet-18之后,latency直接少了40%,精度只掉0.3个百分点。这件事让我对“经典网络”有了更实际的认知:ResNet-18不是过时的玩具,而是很多业务场景下真正能落地的选择。

这篇文章我想系统地把ResNet-18拆开讲一遍,包括它为了解决什么问题而诞生、18这个数字怎么数出来的、每个stage在做什么、PyTorch里怎么从零实现,以及我自己在实际训练和部署中踩过的坑。无论你是刚入门深度学习,想搞懂第一个真正好用的CNN结构,还是做工程落地需要快速跑一个稳健的baseline,这篇内容应该都能派上用场。

1. 退化现象:网络堆到一定深度后为什么反而变差了

1.1 一个反直觉的实验结论

在ResNet出现之前,业界普遍觉得“网络越深,表达能力越强,效果应该越好”。毕竟深度网络理论上可以拟合更复杂的函数。但何恺明团队在CIFAR-10上做了一个非常直接的实验:训练一个20层的CNN和一个56层的CNN,结果发现56层网络的训练错误率和测试错误率都明显高于20层。

关键点是“训练错误率也更高”。如果是过拟合,训练集上的loss应该更低、测试集上更高才对。现在的情况是训练集上就输了,说明问题不在泛化,而在优化——深层网络在训练过程中本身就很难收敛到好的解。这个现象后来被称作退化问题(Degradation Problem),它不是梯度消失导致的完全不学习,而是网络越深,优化难度越大,最后停在一个比较差的局部最优附近。

1.2 根因排查:梯度消失不是唯一答案

很多人一听到“深层网络难训练”,第一反应就是“梯度消失”。确实,链式求导把很多小于1的梯度因子连乘起来,传到浅层时梯度几乎为0,浅层参数更新不动,网络就废了。但ResNet论文那个时间点上,Batch Normalization已经比较普及了,BN能把每一层的激活值拉回相对正常的分布,一定程度缓解了梯度消失。

那为什么加了BN还是退化?这就指向另一个问题:深层网络的优化曲面极其复杂。权重初始化之后,几十层非线性变换堆叠在一起,信号在前向传播中会被不断放大或压缩。即使不爆炸,各层之间的梯度尺度也可能差异巨大,导致优化过程很不稳定。换句话说,深层网络不是“学不会”,而是“很难稳定地学会”。ResNet的残差结构恰恰是在这个方向上给出了解决方案。

1.3 恒等映射的“默认值”思路

退化问题最直接的推论是:如果一个浅层网络已经能做到80%准确率,那么在这个网络后面再加上若干层,这些新增层最理想的行为就是什么都不做——保持输入等于输出,这样深层网络至少不会比浅层差。

问题在于,让一堆带权重、带非线性激活的卷积层去“什么都不做”是非常困难的。你希望它们学到近似的恒等映射,但卷积、ReLU这些操作天然不是恒等映射,权重稍微偏一点,输出就偏移了。ResNet的思路是把目标从“让层学习H(x)”改成“让层学习H(x)-x”,或者说让层学习一个残差。如果最优映射确实是恒等映射,那网络只需要把残差推到0就行——而把输出推向0比把权重推向一个精确的恒等映射要容易得多。

这就是残差结构的核心哲学:给网络一个简单的默认起点,让每一层只需要学习相对于当前状态的增量变化。

2. 残差单元的设计逻辑:那个加号凭什么能拯救深层网络

2.1 一个公式和一个加号

残差单元的基本公式非常简单:

y = F(x, {W_i}) + x

F就是堆叠的非线性层,在ResNet-18里通常是两个3×3卷积。x是输入,y是输出。加法是逐元素相加,要求F的输出和x的shape完全一致。这个加号就是整个ResNet的灵魂。

从梯度角度看,这个加号给反向传播开了一条“高速公路”。误差信号从最后一层传回来时,经过残差单元的加号,有一路梯度可以直接通过恒等路径传到上一层。即使F内部两个卷积层的梯度很小,也不影响梯度从恒等路径反向传播。这意味着深层网络也能保证梯度不消失,浅层参数能收到有效更新。

2.2 为什么优化残差比优化原始映射更容易

我用一个类比来解释这件事。假设你要把一个数值从10调整到11,原始的逐层映射方案是让网络直接输出11,你需要同时调整几十个旋钮,让它们的组合效果精确等于11。残差方案是让网络输出1,然后把10和1加起来得到11。1不是一个绝对目标,而是一个“增量”。

训练初期网络权重接近初始化时,F的输出自然接近0,这时候残差块的输出趋近于x,整个深层网络看起来就像一个浅层网络。优化的起点从“随机初始化的深层非线性变换”变成了“接近恒等映射的浅层结构”,这大大降低了训练难度。随着训练进行,F慢慢学到任务需要的增量。这也是为什么ResNet训练收敛比相同深度的Plain Network快得多。

2.3 捷径分支的三种形态

严格来说,残差单元里“x”所在的路径被称为捷径连接(Shortcut Connection)或者跳跃连接(Skip Connection)。在实际网络的不同位置,这条捷径有不同的实现方式:

  • 恒等捷径:当F(x)和x的通道数、尺寸完全一致时,直接把x拿过来相加,无参数、无计算量。
  • 1×1卷积投影:当F(x)和x的尺寸或通道数不一致时(通常是feature map尺寸减半、通道数翻倍的时刻),用一个1×1卷积将x调整为和F(x)相同的形状,这个1×1卷积的stride也要跟着变化。
  • 带可学习权重的捷径:论文里讨论过给捷径加可学习的缩放参数,但实验表明这样做没有明显收益,甚至可能影响收敛,所以标准ResNet用的都是前两种。

在实际代码实现中,只需要判断输入输出shape是否一致,不一致就套一个downsample,一致就直接相加。这就是残差连接的全部秘密。

3. ResNet-18逐层解剖:18这个数字怎么数出来,每层在做什么

3.1 完整数据流

ResNet-18的输入默认是224×224×3的RGB图像,数据流是这样走的:

输入: 224×224×3 conv1: 7×7卷积, stride=2, 输出 112×112×64 bn1 + relu maxpool: 3×3, stride=2, 输出 56×56×64 layer1: 2个BasicBlock, 输出 56×56×64 layer2: 2个BasicBlock, 输出 28×28×128 layer3: 2个BasicBlock, 输出 14×14×256 layer4: 2个BasicBlock, 输出 7×7×512 avgpool: 全局平均池化, 输出 1×1×512 fc: 全连接层, 输出 类别数(默认1000)

整个网络没有LayerNorm、没有Dropout,主体就是“卷积+BN+ReLU”和残差相加的反复堆叠。

3.2 Feature Map尺寸与通道数的四次变化

我把整个流程中关键阶段的张量变化整理成了一张表:

阶段输入尺寸操作输出尺寸通道数
输入224×224-224×2243
conv1+pool224×2247×7 conv s=2 + 3×3 maxpool s=256×5664
layer156×562×BasicBlock, 无下采样56×5664
layer256×56第一个Block的首个卷积 stride=228×28128
layer328×28第一个Block的首个卷积 stride=214×14256
layer414×14第一个Block的首个卷积 stride=27×7512

空间分辨率从224一路降到7,总共做了5次2倍下采样(conv1一次、maxpool一次、layer2到layer4各一次,其中conv1和maxpool连续执行,所以进入layer1时已经是56×56)。通道数则从64涨到512,而且每下降一次分辨率,通道数就翻倍。这个设计的意图很明显:空间信息逐渐压缩,语义信息逐渐增强,同时计算量保持相对平稳——因为分辨率减半会让计算量降为1/4,通道数翻倍会让计算量升为2倍,整体算下来每个stage的计算量基本差不多。

3.3 有参数层的统计口径

ResNet-18的“18”到底是怎么数出来的?很多人第一次看会很困惑,因为如果加上BN、池化、ReLU,层数远不止18。标准口径是:只统计带有可学习权重的卷积层和全连接层。

具体数一下:

  • 输入层conv1:1个卷积层
  • layer1到layer4:每个stage有2个BasicBlock,每个BasicBlock有2个卷积层,一共4×2×2=16个卷积层
  • 最后的fc:1个全连接层

1+16+1=18。这就是ResNet-18名称的由来。BN虽然也有可学习的缩放参数,但在工程习惯里不算入层数。maxpool、avgpool、ReLU这些无参数或非学习型操作更不算。这个统计口径适用于整个ResNet家族:ResNet-34就是1+(3+4+6+3)×2+1=34,ResNet-50则因为每个stage的block数量不同、且使用了Bottleneck结构,但计数逻辑同理。

4. 为什么是ResNet-18:与ResNet-34/50/101的取舍对比

4.1 BasicBlock与Bottleneck的结构差异

ResNet家族中,ResNet-18和ResNet-34用的是BasicBlock,ResNet-50及以上用的是Bottleneck。BasicBlock我们已经见过了,就是两个3×3卷积串联,输入输出通道一致。而Bottleneck的结构是三段式:

1×1卷积: 降维(如256→64) 3×3卷积: 在低维空间做特征提取(64→64) 1×1卷积: 升维(64→256)

Bottleneck的设计动机很明确:当网络够深时,直接堆两个3×3卷积的参数量太大。用1×1卷积先把通道数降下来,在低维空间做3×3卷积,最后再升回去,总参数量大幅减少。这个设计让ResNet-50以上能堆到50层、101层甚至152层,而计算负担可控。

4.2 参数量、FLOPs与真实性能

不同版本ResNet的参数量和计算量差异很大,我把常用数据整理成了一张表:

模型参数量FLOPs(224×224)ImageNet Top-1(参考)
ResNet-18约11.7M约1.8G约69.8%
ResNet-34约21.8M约3.6G约73.3%
ResNet-50约25.6M约4.1G约76.2%
ResNet-101约44.5M约7.6G约77.4%

注意这里是参考值,不同训练策略和数据增强下会有波动。但从数据能看出一个重要趋势:ResNet-34到ResNet-50,参数量只增加不到4M,top-1却能涨约3个百分点,性价比非常高。而从ResNet-18到ResNet-34,参数量增加了10M,只涨了约3.5个百分点。

4.3 什么任务该选ResNet-18

根据我自己在不同项目里的体感,选型逻辑大概是这样的:

  • 数据集不大(几千到几万张):直接用ResNet-18,深网络很容易过拟合。
  • 边缘设备实时推理:ResNet-18是首选,量化后模型不到12MB,延迟很低。
  • 快速验证baseline:ResNet-18训练时间短,先跑通完整pipeline再说。
  • 大规模数据(百万级)+ 追求精度:选ResNet-50以上,或者直接上ViT系列。
  • 特征提取/迁移学习:ResNet-18提取的通用特征已经够用,而且内存占用小。
  • 视频流/多帧处理:ResNet-18单帧算力低,适合做逐帧或轻量级时空模型。

ResNet-18的核心价值不是“最强”,而是“最稳”。你几乎不需要为它调什么特殊的超参数,它能以最小的成本给你一个可靠的性能下限。

5. 从零手写ResNet-18:PyTorch实现与容易被忽略的细节

5.1 核心代码结构

ResNet-18的代码实现非常有规律,核心组件只有两个:BasicBlock和ResNet主体。先看BasicBlock:

import torch import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1, downsample=None): 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.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,因为后面接BN,卷积的偏置是冗余的,省掉还能减少一点参数量。

然后看ResNet主体和_make_layer方法:

class ResNet18(nn.Module): def __init__(self, num_classes=1000): super().__init__() 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, 64, blocks=2, stride=1) self.layer2 = self._make_layer(64, 128, blocks=2, stride=2) self.layer3 = self._make_layer(128, 256, blocks=2, stride=2) self.layer4 = self._make_layer(256, 512, blocks=2, stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512, num_classes) def _make_layer(self, in_channels, out_channels, blocks, stride=1): downsample = None if stride != 1 or in_channels != out_channels: downsample = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels), ) layers = [] layers.append(BasicBlock(in_channels, out_channels, stride, downsample)) 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

这套代码已经能直接在ImageNet或自定义数据集上训练了。如果只想跑通验证,可以先把num_classes改成你的类别数,然后在CIFAR-10上小规模试一下。

5.2 下采样与维度匹配的完整逻辑

很多人第一次看_make_layer会问:为什么stride要传进来,downsample的判断条件又是什么?

关键逻辑是这样:在layer2、layer3、layer4的第一个BasicBlock里,stride=2。这个stride作用在BasicBlock的conv1上,所以conv1输出的feature map尺寸直接减半。同时,由于输入x还是56×56×64(以layer2为例),而F(x)已经是28×28×128,x和F(x)的shape不一致,无法直接相加。所以必须先在shortcut路径上做一次1×1卷积,stride也设为2,把x也变成28×28×128。这个1×1卷积就放进downsample里。

_ make_layer里的判断条件写得比较严谨:只要stride不等于1,或者输入输出通道数不一致,就一定要做投影。stride=1且通道一致时,downsample保持为None,BasicBlock就直接走恒等捷径。这也是为什么stage内后续的block都不传downsample——它们的输入输出shape相同。

5.3 代码实现里容易踩的坑

第一个坑是卷积stride和padding算错导致shape对不上。比如conv1如果用7×7 stride=2,padding要设为3,这样224的输入才能得到112的输出。若padding设为2,输出尺寸变成111.5——在PyTorch里会直接报错或者输出非预期尺寸。

第二个坑是CIFAR-10这种小尺寸输入不能直接套ImageNet结构。32×32的图像经过7×7 stride=2卷积,再经过maxpool,feature map就剩8×8了,信息损失非常严重。常见的做法是把首层换成3×3卷积、stride=1、不要maxpool,或者干脆直接用别人在CIFAR上预定义好的ResNet变体。

第三个坑是BN的train/eval模式切换。模型训练时要在model.train()模式下,BN会用当前batch的均值和方差;推理时必须在model.eval()模式下,BN才会用训练阶段累积的running_mean和running_var。如果忘了切换,推理结果会飘得很离谱。对新手来说这个现象很隐蔽,因为loss看起来是正常的。

6. 训练与部署ResNet-18的实用经验

6.1 从零训练的超参与数据增强

ResNet-18从零训练时,我常用的配置是这样的:

  • 优化器:SGD,momentum=0.9,weight_decay=1e-4
  • batch size:128或256(取决于显存)
  • 初始学习率:0.1(batch size 256时),如果batch size小就降到0.01~0.05
  • 学习率策略:cosine退火,或者step decay(比如第30、60、90个epoch各除以10)
  • 训练轮数:CIFAR-10上200~300 epoch,ImageNet上90~120 epoch
  • 数据增强:RandomResizedCrop + RandomHorizontalFlip,ImageNet训练还会加ColorJitter和lighting augmentation

ResNet对数据增强的敏感度没有ViT那么高,但基本的RandomResizedCrop不能少。换掉这个增强方式,top-1精度可能会掉2~3个点。

6.2 迁移学习的正确打开方式

大部分实际项目不会从零训练,而是加载ImageNet预训练权重再微调。用torchvision加载很简单:

import torchvision.models as models model = models.resnet18(pretrained=True) num_classes = 10 # 你的任务类别数 model.fc = nn.Linear(model.fc.in_features, num_classes)

微调策略取决于数据量:

  • 数据非常少(每类几十张):冻结所有卷积层,只训练fc。此时ResNet-18相当于一个固定的特征提取器,输入经过forward到avgpool输出512维特征,再接一个新分类头。
  • 数据中等(每类几百到几千张):冻结前几个stage,只微调layer3、layer4和fc,学习率设小一点,比如1e-4。
  • 数据充足(每类上万张):全量微调,用SGD或AdamW都行,AdamW的初始学习率通常设1e-4~3e-4。

还有一个实用技巧:可以不替换fc,而是把最后一层换成其他分类器比如LightGBM。做法是先把训练集过一遍模型,取出fc之前的512维特征,然后用逻辑回归或GBDT去拟合分类目标。有些场景下这种做法比直接微调fc更稳,特别适合几千张样本的小数据集。

6.3 部署时的轻量化手段

ResNet-18本身已经不算大,FP32权重约47MB(11.7M参数乘以4字节),但部署到边缘设备时还是可以做进一步压缩:

  • ONNX导出:用torch.onnx.export导出为ONNX,配合ONNX Runtime或TensorRT做推理加速。导出时要固定输入尺寸,避免动态shape带来的额外开销。
  • INT8量化:训练后量化(Post-Training Quantization)在ResNet-18上通常能保持97%以上的精度,模型体积从47MB降到约12MB。如果精度掉得多,用量化感知训练(QAT)再微调几个epoch就能拉回来。
  • 算子融合:BN和卷积可以融合成一个卷积,推理时额外省一些计算。ONNX导出时很多工具会自动做。

ResNet-18量化后的推理速度在Jetson Nano这类设备上跑224×224输入,单帧耗时大约能控制在10毫秒以内,完全可以做实时视频流处理。

6.4 我实际使用中的体感总结

最后分享一点我自己的经验:ResNet-18最大的优点是确定性。我遇到过很多次新模型在某个数据集上效果不稳定,换seed结果波动很大,但ResNet-18很少出这种问题。如果你在做一个新任务,第一版baseline用ResNet-18,结果突然很差,那大概率不是模型的锅,而是数据pipeline有问题。用它来“排除法”定位问题,是我觉得它最有价值的用途之一。

另外,如果你是刚入门的学生,强烈建议不要直接用torchvision的现成模型,而是自己照着结构手写一遍。只有自己把BasicBlock、downsample、stride变化这些细节全部理清楚,才算真正理解ResNet。等手写跑通了,再回到torchvision,你会发现自己看代码的速度完全不一样。

返回列表