做图像分类、目标检测、语义分割这类任务的,肯定绕不开ResNet。不管是刚入门深度学习的新手,还是已经在工业界摸爬滚打好几年的老手,ResNet18和ResNet34这两个名字你们一定都见过。我最早接触ResNet还是在几年前做图像分类项目的时候,当时被各种VGG、GoogLeNet的复杂结构搞得头大,后面用了ResNet18之后才真正体会到什么叫“简单好用又稳定”。这篇文章我就带着大家把ResNet18和34的网络结构从头到尾拆一遍,从残差块的设计思路、shortcut连接的细节、BatchNorm的作用,到实际搭建和训练时容易踩的坑,一次性讲清楚。
这篇文章适合正在学习CNN结构的初学者,也适合准备在项目里部署轻量级骨干网络的开发者。读完你不仅能看懂ResNet的每一条卷积、每一个stride是怎么设计的,还能自己动手复现一个可以训练的分类模型。
1. 为什么需要残差结构:深层网络不是越深越好
1.1 堆叠深度的陷阱:退化问题比过拟合更让人头疼
在ResNet出现之前,大家普遍认为网络越深,表达能力越强,效果应该越好。VGG、GoogLeNet都在不断加深度,VGG16到了16层,VGG19到了19层。但是当网络加深到一定程度后,训练集上的误差反而会升高,这不是过拟合,因为训练集本身都拟合不好。这个问题学术界叫“退化问题”(Degradation Problem)。
我最初看到这个现象的时候也觉得很反直觉,毕竟理论上深层网络可以模拟浅层网络,只要把多出来的层训练成恒等映射不就行了?然而现实是,使用随机梯度下降法训练深层网络时,想让某些层直接学到恒等映射是非常困难的。非线性激活函数会不断扭曲输出空间,网络参数在整个反向传播过程中相互耦合,导致优化器很难找到一个让多余层变成“什么都不干”的最优解。
实验现象也很明显:一个56层的网络在训练集上的误差比20层的网络还要高。这就说明不是网络容量不够,而是优化算法根本没法在这么深的网络里找到好的解。
1.2 残差学习的核心思想:让网络去学差值
ResNet的作者提出了一个非常巧妙的思路:既然学习恒等映射很难,那就别让网络直接学目标映射了,改学目标映射和输入之间的差值,也就是残差。
假设我们希望某个浅层输入x经过若干层之后得到理想输出H(x),普通网络结构直接让这些层拟合H(x)。而残差块做的是让这些层去拟合F(x) = H(x) - x,然后在输出端做一个H(x) = F(x) + x。如果理想输出H(x)恰好等于输入x,那残差F(x)只要学到0就可以了。让输出趋近0的技巧很简单,把所有卷积层的权重都拉向0,网络就变成恒等输出了。
这个变化看似微小,却从根本上改变了优化的难度。普通网络要经过层层非线性变换才能得到一个特定的映射,而残差网络只需要不断逼近一个残差项,这个残差项的数值范围通常更小、更容易被优化器捕捉。在反向传播时,梯度的传递路径也多了一条捷径:从输出直接传到输入,梯度不会因为层数深而消失。
1.3 残差块的基本构成
一个标准的BasicBlock长下面这个样子:
输入x -> 3x3卷积(64通道) -> BatchNorm -> ReLU -> 3x3卷积(64通道) -> BatchNorm -> 加上恒等映射 x -> ReLU注意最后的ReLU在shortcut相加之后才做。第二层卷积输出的BatchNorm结果直接和x相加,然后再过ReLU。这里为什么第二个卷积后面不紧跟ReLU?因为如果先过ReLU再相加,那shortcut过去的x本来就是经过ReLU处理的值,直接相加语义上更自然,而且先相加再过ReLU能保证残差块最终的输出是经过一次非线性激活的,提供更强的表达能力。
这种结构最大的特点就是“直接加”这个操作,它不引入任何额外参数,也没有任何计算开销,纯粹是一个张量相加。但就因为这一个加法,整个深层网络的梯度流动问题得到了极大的缓解。
2. ResNet18与34的整体结构
2.1 从输入到输出:一条数据在ResNet里的完整旅程
一张224x224的RGB图像输入ResNet之后,经历了几个大阶段:先是stem部分做快速降采样,然后是四个残差stage逐步提取语义特征,最后是全局平均池化和全连接层做分类。
我用表格把ResNet18和34的整体结构列出来,后面分析的时候会反复用到这张表:
| 阶段 | 输出尺寸 | ResNet18层配置 | ResNet34层配置 |
|---|---|---|---|
| conv1 | 112x112 | 7x7卷积,64通道,stride=2 | 7x7卷积,64通道,stride=2 |
| 池化 | 56x56 | 3x3最大池化,stride=2 | 3x3最大池化,stride=2 |
| stage1 | 56x56 | 2个BasicBlock,64通道 | 3个BasicBlock,64通道 |
| stage2 | 28x28 | 2个BasicBlock,128通道 | 4个BasicBlock,128通道 |
| stage3 | 14x14 | 2个BasicBlock,256通道 | 6个BasicBlock,256通道 |
| stage4 | 7x7 | 2个BasicBlock,512通道 | 3个BasicBlock,512通道 |
| 分类头 | 1x1 | 全局平均池化 + 全连接 | 全局平均池化 + 全连接 |
2.2 四个stage的通道数翻倍规律
stage1到stage4最直观的规律是通道数依次翻倍:64、128、256、512。为什么要翻倍?因为特征图的空间尺寸在不断减半,从56x56到28x28再到14x14、7x7。空间上丢了信息,就需要用更多的通道来保留语义信息。通道数和空间尺寸保持一个此消彼长的平衡,最终在7x7的Feature Map上拥有512个通道,此时每个位置都浓缩了全图的抽象语义特征。
每个stage的第一层BasicBlock通常要执行下采样操作。下采样方式很特殊,不是用池化,而是用stride=2的卷积来做。所以每个stage第一个BasicBlock里面的第一个卷积层stride=2,这样输出特征图的尺寸就减半了。与此同时,输入的通道数可能跟输出不一致,需要进行维度调整,这就引出了下面需要讲的核心问题:shortcut连接的两种形态。
2.3 BasicBlock和Bottleneck的选择边界
ResNet18和34用的都是BasicBlock,也就是只有两个3x3卷积。而ResNet50及以上的网络用的是Bottleneck,三个卷积层:1x1降维、3x3卷积、1x1升维,中间3x3卷积的通道数比两端小。
我在项目里总结了一个选择规律:网络深度在34层以下时用BasicBlock完全足够,参数量不大,训练速度也快。到了50层以上,如果还用BasicBlock,那参数量会爆炸,而且深层网络需要Bottleneck来把主要计算量集中在中间那一层3x3卷积上,提高参数利用率。ResNet18只有1170万参数,ResNet34也就2170万左右,在现在的显卡上简直轻若无物,随便跑。
3. shortcut连接的两种形态与维度对齐
3.1 恒等shortcut:最理想的跳连
如果残差块的输入和输出在通道数和空间尺寸上都一模一样,那可以直接把输入加到输出上。这种形态的shortcut在ResNet中叫恒等映射(Identity Shortcut),不包含任何参数,纯粹是张量加法的关系。
比如stage1里面的BasicBlock,输入是56x56x64,经过两个3x3卷积之后输出还是56x56x64,那直接在输出上加上输入x就行了。这种恒等shortcut的好处不仅仅是优化容易,更关键的是反向传播时梯度可以无损耗地直接从输出流回输入。这个路径上没有卷积,没有激活函数,梯度就是简单地加1,即使层数很深,梯度也能保持完整。
3.2 投影shortcut:维度对不上时的解决方案
当输入和输出的特征图尺寸或通道数不一致时,恒等shortcut就不能用了。这时需要用投影shortcut(Projection Shortcut),一般用1x1卷积实现,步长和残差分支的主路径保持一致。比如stage2的第一个BasicBlock,输入是56x56x64,残差分支通过第一个stride=2的3x3卷积把特征图降到28x28,又通过第二个卷积把通道数升到128。那shortcut路径上就要做一个1x1卷积,stride=2,输出28x28x128,这样才能和张量相加。
这种投影shortcut会引入额外参数,但在ResNet18/34里只在每个stage的第一层出现,量级不大,对整体参数量影响很小。我在部署时经常用这个特性来魔改网络,比如把分类型任务改成检测任务时,需要在更高分辨率处接出特征图,这时只要把某个stage的第一层stride改成1,同时把shortcut的stride同步改成1就行,非常方便。
3.3 为什么下采样用卷积而不是池化
VGG时代下采样都用最大池化,但ResNet在下采样时直接使用stride=2的卷积。这样设计有几个实际好处:卷积带有可学习的参数,当下采样时能够学习到如何保留最重要的信息,而不是简单地取最大值或平均值。最大池化是固定的、无参数的,它的效果上限摆在那里。
另外,如果用池化做下采样,那么池化层前面那层卷积就必须步长为1,这样会导致感受野增长的节奏变慢。直接把stride=2的卷积放在第一个3x3卷积上,也是ResNet训练稳定、收敛快的原因之一。
4. BatchNorm与激活函数的位置关系
4.1 顺序固定:卷积、BN、ReLU
ResNet中的基本顺序是“卷积 -> BatchNorm -> ReLU”,卷积放在最前面,BN在卷积之后、ReLU之前。这个顺序在工程上是经过反复验证的。BN的作用是把卷积输出的分布拉回到均值为0、方差为1的标准正态分布附近,然后再交给ReLU去决定哪些特征保留、哪些置零。
如果把ReLU放在BN前面,也就是“卷积 -> ReLU -> BN”,那BN接收的输入已经被ReLU截断,负半轴的信息全部丢失,BN计算出来的均值和方差就不再准确,训练效果会明显变差。如果是“BN -> 卷积 -> ReLU”,那BN处理的对象根本不是卷积的输出,而是原始输入,但输入的分布经过前一层ReLU之后已经发生了偏移,这种顺序相当于BN白做。
重要:在残差块最后一个BN之后不要立即加ReLU,应该在shortcut相加之后再激活。这一点新手经常搞错,会导致模型训练初期loss下降非常缓慢。
4.2 BN层参数细节
每个BN层里有四个重要参数:可学习的缩放因子gamma和偏移量beta,以及统计量的滑动平均值running_mean和running_var。训练时,当前batch的数据先按batch维度和空间维度计算均值和方差,然后做归一化,再经过gamma和beta做线性变换。同时,running_mean和running_var会按一个动量系数(通常momentum=0.1)更新。
推算一下:训练阶段,BN相当于对当前batch的数据做了白化处理,使得每个通道的输出分布相对稳定,这样后面的层不会因为前层参数的微小变化而剧烈波动,这一点对深层网络至关重要。推理阶段没有batch概念,BN直接用训练好的running_mean和running_var做归一化,再用固定的gamma和beta做变换。所以训练和推理的BN行为是两码事。
我一次迁移学习时遇到的教训:如果加载了预训练模型,但训练时把batch_size设得非常小,比如只有2、4,BN的统计量估计会很不稳定,导致推理精度和训练精度差距巨大。解决办法要么加大batch_size,要么冻结前几层的BN。这一点在做目标检测、分割等小batch任务时尤其明显。
4.3 权重初始化
ResNet对权重初始化有一套比较讲究的策略。卷积层一般使用Kaiming初始化,因为在ReLU存在的情况下,Kaiming初始化能有效避免梯度消失或爆炸。如果使用Xavier初始化,深层网络的方差在每一层都会被ReLU压缩,传到后面的层就衰减没了。
实际应用中,即使是经验丰富的开发者,也几乎不会手动设计初始化方式,直接用PyTorch默认的kaiming初始化就行。但如果你在TensorFlow或Keras里加载了预训练权重,注意确认BN层和卷积层的初始化方式是否匹配,不然前期训练会很不稳定。
5. 用PyTorch从零搭建ResNet18
5.1 代码实现
理解了上面的结构,代码其实就是照着画。我贴一段自己常用的实现,不算最精简,但胜在结构清晰,适合拿来魔改。
import torch import torch.nn as nn def conv3x3(in_channels, out_channels, stride=1): return nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1, downsample=None): super().__init__() self.conv1 = conv3x3(in_channels, out_channels, stride) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = conv3x3(out_channels, out_channels) self.bn2 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) 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 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, 2) self.layer2 = self._make_layer(64, 128, 2, stride=2) self.layer3 = self._make_layer(128, 256, 2, stride=2) self.layer4 = self._make_layer(256, 512, 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如果要把ResNet18改成ResNet34,只需要把不同stage的block数改成3、4、6、3,其他所有逻辑通用。
5.2 网络维度变化验证
写完之后强烈建议你跑一次维度检查,把整个网络的输出shape打印出来,确认每一层的张量变化符合预期。我每次复现网络都会做这一步,不要觉得自己记性多好,改着改着就翻车了。
def check_resnet(): model = ResNet18(num_classes=10) x = torch.randn(1, 3, 224, 224) x = model.conv1(x) x = model.bn1(x) x = model.relu(x) # 112x112x64 x = model.maxpool(x) # 56x56x64 x = model.layer1(x) # 56x56x64 x = model.layer2(x) # 28x28x128 x = model.layer3(x) # 14x14x256 x = model.layer4(x) # 7x7x512 x = model.avgpool(x) # 1x1x512 x = torch.flatten(x, 1) out = model.fc(x) print(out.shape)torch.Size([1, 10])才是正常输出。如果你发现某个stage的输出尺寸对不上,90%的问题出在downsample的stride没同步。
5.3 参数量和计算量
ResNet18的参数量约1170万,ResNet34约2170万。计算量方面,ResNet18输入224x224图像时大约1.8亿次乘加操作,ResNet34大约3.6亿次。这个量级在现在的GPU上完全不是问题,哪怕是在嵌入式设备上,ResNet18经过剪枝量化之后也能跑到不错的帧率。
6. 训练ResNet的实战经验与常见问题
6.1 训练超参设置
训练ResNet18跑CIFAR-10分类是我最推荐的练手项目。输入尺寸需要从224改成32,所以conv1、maxpool和第一个stage需要做相应调整,网络会退化成一个简化版本,但仍然保持ResNet的核心结构。这个简化网络在CIFAR-10上不开预训练就能冲到95%以上的准确率。
如果是全尺寸ImageNet上的训练,常用的策略是:初始学习率0.1(batch_size=256),采用余弦退火或每30个epoch衰减为原来的0.1,weight_decay设1e-4,momentum设为0.9。我之前试过直接用Adam训练ResNet,效果反而不如SGD+momentum稳定,可能是因为Adam对BN层的gradient noise更敏感,在小数据集上尤其如此。
6.2 梯度检查与NaN排查
训练过程中如果出现loss突然变成NaN,首先检查学习率是不是太大了,其次排查数据里有没有包含NaN的样本。再次要看看是否有显存溢出,PyTorch在OOM时不会直接报显存错误,而会抛出奇怪的CUDA错误,容易误导人。把环境变量设置成遇到异常立即抛出栈,能看到真正的报错位置。
另外,如果你用混合精度训练(AMP),出现NaN的概率会明显上升。这时需要检查损失缩放策略,或者给某些关键层单独保持FP32精度。
6.3 面向小数据集的迁移技巧
现在很少有项目真的从随机初始化开始训ResNet的,基本都是加载ImageNet预训练权重再微调。有几个心得分享:
- 微调时初始学习率不要设太大,1e-3到1e-4之间比较稳妥,因为预训练特征已经很好了,学习率太大容易把原有特征破坏掉。
- 如果是完全更换数据集,比如医学图像、遥感图像这种跟ImageNet分布差距很大的场景,建议把前几层(conv1到stage3开头)先冻结,只训练后面的层,等loss不再下降了再解冻所有层做细调。
- BN层在迁移时特别容易出问题:如果目标任务batch_size比预训练时小很多,BN的running_mean和running_var就会乱跳。这种情况可以在训练初期冻结BN,只更新卷积参数。
- 数据增强对ResNet的效果提升非常显著。简单的RandomHorizontalFlip加RandomCrop就能带来好几个百分点的提升,如果数据量少还可以试试MixUp和CutMix,不过要留意和BN之间的配合。
6.4 常见问题速查表
| 问题表现 | 可能原因 | 解决方案 |
|---|---|---|
| 训练loss不降 | 学习率太低或初始化不合适 | 调大学习率、检查BN是否冻结 |
| 训练loss下降但val loss上升 | 过拟合 | 加数据增强、调大weight_decay |
| loss输出NaN | 学习率过高、数据含异常值 | 降低学习率、检查数据清洗 |
| 推理精度远低于训练 | BN的batch_size太小 | 加大batch、冻结部分BN |
| 显存OOM | 输入尺寸过大 | 缩小输入、开混合精度、减小batch |
| 梯度爆炸 | 初始化方式不对 | 使用kaiming初始化、梯度裁剪 |
7. 魔改ResNet的几个方向
ResNet18/34能在这么多任务里作为默认骨干网络,有一个很重要的能力:它非常容易魔改。以目标检测为例,FPN和SSD这类模型喜欢在不同层级引出多尺度特征图,ResNet的四个stage天然就能输出四种不同分辨率的特征:56x56、28x28、14x14、7x7,正好匹配多尺度检测的需求。
分割任务里经常要用到高分辨率的特征,通常的改法是去掉stage4或stage4的stride,让特征图保持在14x14或者更大的分辨率。反正ResNet的残差结构让stride变化对梯度流动的影响小得多,改起来非常安全。
我自己最常用的一种改法是把ResNet18的最后两层去掉,直接在stage4后面接自定义头,当作backbone提取特征。这种用法在度量学习、人脸识别、行人重识别里尤其常见,把ResNet当特征提取器,只保留前向传播的特征输出,后面接各种自定义损失函数。而且ResNet输出的特征语义信息已经非常丰富,只需要很少的训练轮数就能达到不错的效果。
另外提一下深度可分离卷积的魔改思路:如果你有部署在移动端的需要,可以把BasicBlock里的两个3x3普通卷积换成Depthwise Separable Convolution。这样参数量能再缩小将近一半,精度只会损失两三个点,但推理速度会快不少。这种魔改版在很多轻量化网络里都能看到影子。
8. 我对ResNet18/34的实际使用体会
我个人做过很多次图像分类和目标检测的工程项目,ResNet18和34始终是我在精度和速度之间权衡时优先考虑的骨干网络。相比更轻量化的MobileNet系列,ResNet18的精度更高、更容易调参;相比ResNet50及以上的大网络,ResNet18训练速度快、显存占用小,而且不那么容易过拟合。在绝大多数中小规模数据集上,ResNet18在这个量级里基本就是性价比之王。
最后分享一个小技巧:如果你要在一个新任务上试ResNet,不要一开始就从头训练。无论你的数据和目标任务跟ImageNet差多远,先加载ImageNet预训练权重,再把最后的全连接层换掉跑一轮看看loss下降情况,这能帮你快速定位问题是在数据还是模型。确认数据没问题之后再考虑要不要从头训。踩过几次坑之后你会发现,训练ResNet最大的坑往往不在网络本身,而在数据、学习率和BN这三者之间的配合上。搞清楚这三个东西,ResNet基本就是随便用都顺手。