1. 深度残差网络ResNet到底解决了什么问题
第一次接触ResNet是在做一个图像分类的小项目,当时用的是一个20层左右的卷积网络,训练集准确率死活卡在70%上下,怎么调学习率、加数据增强都不管用。后来换了个56层的网络,想着层数多了表达能力更强,结果训练误差反而比20层的还高。这个现象当时让我很困惑——按理说深层网络至少不应该比浅层网络差,因为多出来的层哪怕什么都不学,做个恒等映射也行。但现实就是,网络越深,越难训练。
ResNet(Deep Residual Network,深度残差网络)就是冲着这个问题来的。它要解决的核心问题不是过拟合,而是退化问题(degradation problem)——当网络深度增加到一定程度后,训练误差不降反升。注意,这不是测试误差,是训练误差本身就在变大,说明模型连训练数据都拟合不好了。
ResNet的核心思路用一个词概括就是“残差学习”。传统网络让每一层直接学习目标映射H(x),ResNet换了个思路,让网络学习残差映射F(x) = H(x) - x,然后通过跳跃连接(skip connection)把输入x直接加到输出上,最终输出变成F(x) + x。如果最优映射就是恒等映射,那网络只需要把F(x)学成0就行了,这比直接学一个恒等映射容易得多。
这个设计看起来简单,但它带来的影响是深远的。ResNet让训练上百层甚至上千层的网络成为可能,152层的ResNet在ImageNet上拿下了2015年的冠军,top-5错误率降到3.57%,首次超越了人类水平(约5.1%)。更重要的是,残差连接这个思想后来被广泛借鉴到各种网络架构中,包括Transformer里的残差连接、DenseNet的密集连接等等,几乎成了深度学习模型设计的标配。
这篇文章适合谁看?如果你正在学深度学习,或者在工作中需要搭建图像分类、目标检测的骨干网络,又或者你只是好奇为什么ResNet这么重要,那接下来的内容应该能帮到你。我会从设计思路、核心结构、实操细节、常见问题几个角度展开,尽量把每个“为什么”讲清楚。
2. ResNet的核心设计思路拆解
2.1 为什么深层网络会退化
要理解ResNet,先得搞清楚退化问题的根源。假设有一个浅层网络A,准确率不错。现在在A后面堆几层变成深层网络B。理论上B的解空间包含A,因为多出来的层只要学成恒等映射,B的表现至少和A一样。但实际训练中,让多层非线性变换去逼近恒等映射非常困难。
打个比方,这就像让你用一堆弯弯曲曲的线条去画一条直线。理论上你能画出来,但实际操作中很难画得笔直。神经网络中的非线性激活函数(ReLU、Sigmoid等)就像那些弯曲的线条,多层叠加后,想要精确还原输入信号,难度指数级上升。
另一个角度是梯度问题。反向传播时,梯度要穿过很多层才能到达浅层。即使使用了Batch Normalization,梯度在深层网络中仍然容易出现衰减或爆炸。虽然BN缓解了这个问题,但没有从根本上解决“深层网络难以优化”的本质困难。
2.2 残差学习的数学直觉
ResNet的残差块可以形式化地表示为:
y = F(x, {Wi}) + x其中x是输入,F(x, {Wi})是要学习的残差映射,y是输出。当输入输出维度不一致时(比如通道数变化或空间尺寸减半),需要对x做一个线性投影Ws来匹配维度:
y = F(x, {Wi}) + Ws x这个设计的精妙之处在于:如果F(x)的最优解是0,那网络输出就是x,相当于这一层什么都没做,信息直接透传。这比让网络学一个恒等映射H(x)=x要容易得多,因为把权重推向0比推向恒等变换简单。
从梯度角度看,反向传播时:
∂L/∂x = ∂L/∂y * (1 + ∂F/∂x)那个“1”保证了梯度至少能以原样传回去,不会因为层数多而消失。这就是残差连接被称为“梯度高速公路”的原因。
2.3 两种残差块的设计取舍
ResNet里有两种残差块:BasicBlock和Bottleneck。
BasicBlock用于较浅的ResNet(如ResNet-18、ResNet-34),结构是两个3x3卷积串联,每个卷积后面跟BN和ReLU,最后加上跳跃连接再激活。结构简单,参数量适中。
Bottleneck用于较深的ResNet(如ResNet-50、ResNet-101、ResNet-152),结构是1x1卷积降维 → 3x3卷积 → 1x1卷积升维。为什么要这么设计?因为深层网络参数量大,直接用两个3x3卷积计算量太高。Bottleneck先用1x1把通道数压缩到1/4,做完3x3卷积后再恢复,这样计算量大幅降低,同时保持了表达能力。
举个例子,假设输入输出都是256通道。BasicBlock的参数量大约是2 × 3 × 3 × 256 × 256 ≈ 118万。Bottleneck的参数量是1×1×256×64 + 3×3×64×64 + 1×1×64×256 ≈ 7万,差了将近17倍。这就是为什么ResNet-50虽然比ResNet-34深得多,但计算量反而可控。
实操建议:如果你的任务比较简单、数据量不大,优先用ResNet-18或ResNet-34,BasicBlock足够。如果要做大规模图像分类或作为检测分割的骨干网络,ResNet-50起步,用Bottleneck更划算。
2.4 下采样与维度匹配的处理
ResNet的残差块在通道数翻倍时,特征图尺寸会减半(stride=2),这时候跳跃连接上的x和F(x)维度不一致,需要做投影。ResNet的做法是用一个stride=2的1x1卷积对x做下采样和通道变换,保证相加时维度对齐。
这里有个细节:投影用的1x1卷积后面通常不加BN和ReLU,因为它只是做维度匹配,不需要额外的非线性。有些实现会在投影后加BN,但原论文里没有。实测下来,加不加BN对最终精度影响不大,但加了会让训练稍微稳定一点。
3. ResNet网络结构的完整拆解
3.1 整体架构一览
ResNet的整体结构可以分成四个阶段(stage),每个阶段由若干个残差块堆叠而成。以ResNet-50为例:
| 阶段 | 输出尺寸 | 结构 | 重复次数 |
|---|---|---|---|
| conv1 | 112×112 | 7×7, 64, stride 2 | 1 |
| maxpool | 56×56 | 3×3, stride 2 | 1 |
| stage1 | 56×56 | Bottleneck 64→64→256 | 3 |
| stage2 | 28×28 | Bottleneck 128→128→512 | 4 |
| stage3 | 14×14 | Bottleneck 256→256→1024 | 6 |
| stage4 | 7×7 | Bottleneck 512→512→2048 | 3 |
| avgpool | 1×1 | 全局平均池化 | 1 |
| fc | 1000 | 全连接 | 1 |
输入图像先经过一个7×7的大卷积核(stride=2)快速降采样,然后maxpool再降一次,之后进入四个stage。每个stage的第一个残差块负责通道翻倍和空间降采样,后续残差块保持维度不变。
3.2 残差块内部的数据流
以Bottleneck为例,一个残差块内部的数据流是这样的:
- 输入x(假设256通道,56×56)
- 1×1卷积,输出64通道,BN,ReLU
- 3×3卷积,输出64通道,stride=1,padding=1,BN,ReLU
- 1×1卷积,输出256通道,BN(注意这里不加ReLU)
- 跳跃连接:如果输入输出维度一致,直接加;否则用1×1卷积投影
- 相加后过ReLU
注意第4步的BN后面不加ReLU,因为要等跳跃连接加完之后再激活。这个顺序很重要,如果提前激活了,残差路径上的信息就被截断了。
3.3 不同深度ResNet的配置差异
ResNet系列有多个版本,主要区别在层数和每个stage的重复次数:
| 模型 | 层数 | stage重复次数 | 参数量 | Top-1准确率(ImageNet) |
|---|---|---|---|---|
| ResNet-18 | 18 | [2,2,2,2] | 11.7M | 69.8% |
| ResNet-34 | 34 | [3,4,6,3] | 21.8M | 73.3% |
| ResNet-50 | 50 | [3,4,6,3] | 25.6M | 76.1% |
| ResNet-101 | 101 | [3,4,23,3] | 44.5M | 77.4% |
| ResNet-152 | 152 | [3,8,36,3] | 60.2M | 78.3% |
注意ResNet-18和ResNet-34用的是BasicBlock,ResNet-50及以上用的是Bottleneck。层数计算方式是:1个conv1 + 每个stage的残差块数×每个块的卷积层数 + 1个fc。比如ResNet-50:1 + (3+4+6+3)×3 + 1 = 50。
3.4 跳跃连接的几种变体
原论文里跳跃连接主要用恒等映射,只有在维度不匹配时才用投影。后来有不少改进版本:
- 投影捷径(Projection Shortcut):所有跳跃连接都用1x1卷积,精度略高但参数量增加。
- 带参数的恒等映射:给恒等映射乘一个可学习的缩放因子,让网络自己调整残差路径的权重。
- DropBlock/Stochastic Depth:训练时随机丢弃一些残差块,起到正则化作用,防止过拟合。
实测下来,对于大多数任务,原版的恒等映射+维度匹配投影已经够用了。Stochastic Depth在训练超深网络(如ResNet-200以上)时效果比较明显,浅层网络用不用差别不大。
4. 从零实现ResNet的关键步骤
4.1 环境准备与依赖
我平时用PyTorch做实验,版本建议1.10以上,CUDA版本根据显卡驱动选。核心依赖就几个:
pip install torch torchvision pip install numpy matplotlib pip install tensorboard # 可选,用于可视化训练过程如果你用TensorFlow/Keras,也可以用tf.keras.applications.ResNet50直接调预训练模型,但自己实现一遍对理解结构帮助很大。
4.2 残差块的代码实现
先实现BasicBlock:
import torch import torch.nn as nn 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.relu = nn.ReLU(inplace=True) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels * self.expansion: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels * self.expansion, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels * self.expansion) ) def forward(self, x): identity = self.shortcut(x) out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += identity out = self.relu(out) return out再实现Bottleneck:
class Bottleneck(nn.Module): expansion = 4 def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels * self.expansion) self.relu = nn.ReLU(inplace=True) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels * self.expansion: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels * self.expansion, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels * self.expansion) ) def forward(self, x): identity = self.shortcut(x) out = self.relu(self.bn1(self.conv1(x))) out = self.relu(self.bn2(self.conv2(out))) out = self.bn3(self.conv3(out)) out += identity out = self.relu(out) return out注意几个细节:卷积层都设了bias=False,因为后面跟了BN,bias会被BN的偏移抵消掉,加了也是浪费参数。expansion变量用来控制输出通道的倍数,BasicBlock是1倍,Bottleneck是4倍。
4.3 搭建完整的ResNet
有了残差块,搭建整个网络就简单了:
class ResNet(nn.Module): def __init__(self, block, layers, 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.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) self.layer1 = self._make_layer(block, 64, layers[0], stride=1) self.layer2 = self._make_layer(block, 128, layers[1], stride=2) self.layer3 = self._make_layer(block, 256, layers[2], stride=2) self.layer4 = self._make_layer(block, 512, layers[3], stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512 * block.expansion, num_classes) def _make_layer(self, block, out_channels, num_blocks, stride): strides = [stride] + [1] * (num_blocks - 1) layers = [] for s in strides: layers.append(block(self.in_channels, out_channels, s)) self.in_channels = out_channels * block.expansion return nn.Sequential(*layers) def forward(self, x): x = self.relu(self.bn1(self.conv1(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调用方式:
def resnet50(num_classes=1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes) def resnet18(num_classes=1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes)4.4 训练配置与参数选择
训练ResNet有几个关键参数需要调:
学习率:原论文用0.1(batch size 256),配合SGD momentum 0.9。如果你用更小的batch size,学习率要按比例缩小。我一般用0.01到0.05起步,跑几个epoch看loss曲线再调整。
权重衰减:1e-4是标配,防止过拟合。如果数据量小,可以适当加大到5e-4。
学习率调度:原论文用step decay,每30个epoch降10倍。现在更常用Cosine Annealing或OneCycle,收敛更平滑。我实测Cosine Annealing在大多数任务上比step decay好一两个点。
Batch Normalization:ResNet对BN非常依赖,去掉BN后精度会掉很多。如果显存不够导致batch size太小(比如小于8),BN的效果会打折扣,这时候可以考虑用GroupNorm替代。
数据增强:RandomResizedCrop + RandomHorizontalFlip是标配,颜色抖动(ColorJitter)看任务加不加。如果数据量少,可以加RandAugment或MixUp。
实操心得:训练ResNet时,前几个epoch loss下降很快,但验证集准确率可能波动很大。别急着调参,至少等到第10个epoch再看趋势。另外,BN的running mean和running std需要一定数量的batch才能估计准确,如果batch size太小,可以调大momentum(比如0.01)让统计量更新更快。
5. 常见问题与排查技巧实录
5.1 训练loss不下降或震荡严重
这是最常见的问题。排查顺序如下:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| loss完全不降 | 学习率太小或太大 | 尝试1e-3到1e-1之间的几个值 |
| loss震荡剧烈 | batch size太小 | 增大batch size或降低学习率 |
| loss先降后升 | 过拟合 | 加数据增强、增大weight decay |
| loss降但精度不升 | 标签有问题 | 检查数据加载和标签对应关系 |
| 训练初期loss为NaN | 学习率过大或数据有异常值 | 降低学习率,检查输入数据范围 |
我踩过的一个坑:有一次用自定义数据集训练ResNet-50,loss一直卡在2.3左右不降。排查了半天发现是数据预处理时没有做归一化,像素值还在0-255范围,导致BN的输入分布太分散。加上Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])之后,loss正常下降。
5.2 显存不够怎么办
ResNet-50在batch size 32、输入224×224的情况下,大约需要8GB显存。如果显存不够,有几个办法:
- 减小batch size:最直接,但太小会影响BN效果。可以配合梯度累积,比如batch size设为8,累积4次再更新,等效于batch size 32。
- 混合精度训练:用
torch.cuda.amp,显存占用能降30%-40%,速度还能提升。 - 梯度检查点:用
torch.utils.checkpoint,用计算时间换显存,适合超深网络。 - 减小输入尺寸:从224降到128或160,显存占用按平方比例下降,但精度会受影响。
# 混合精度训练示例 from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for images, labels in dataloader: optimizer.zero_grad() with autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()5.3 迁移学习怎么用ResNet
实际项目中很少从零训练ResNet,大多数时候用预训练权重做迁移学习。PyTorch里加载预训练模型:
import torchvision.models as models # 加载预训练ResNet-50 model = models.resnet50(pretrained=True) # 替换最后的全连接层 num_features = model.fc.in_features model.fc = nn.Linear(num_features, num_classes) # num_classes是你的类别数 # 冻结前面的层(可选) for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True迁移学习有几个策略:
- 只训练fc层:适合数据量很小(几百张)、和目标域差异不大的情况。训练快,不容易过拟合。
- 训练stage4+fc:适合数据量中等(几千张)的情况。解冻最后一个stage,让高层特征适应新任务。
- 全部解冻微调:适合数据量大(几万张以上)的情况。学习率要设小一点,比如1e-4,避免破坏预训练权重。
注意:用预训练模型时,输入预处理必须和预训练时一致。ImageNet预训练用的是mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 0.225],输入尺寸224×224。如果这些不一致,预训练权重的优势会大打折扣。
5.4 BN层在推理时的坑
BN层在训练和推理时的行为不一样。训练时用当前batch的均值和方差,推理时用running mean和running std。如果你在训练结束后直接拿模型做推理,但忘了调model.eval(),BN层会继续用batch统计量,导致结果不稳定。
# 推理前必须调用 model.eval() with torch.no_grad(): outputs = model(images)另一个坑是:如果训练时batch size很小,running mean和running std估计不准,推理时精度会下降。解决办法是训练快结束时用较大的batch size跑几个epoch,让BN统计量更新得更准确。或者用torch.nn.SyncBatchNorm在多卡上同步统计量。
5.5 常见问题速查表
| 问题 | 排查方向 | 快速修复 |
|---|---|---|
| 训练精度远高于验证精度 | 过拟合 | 加数据增强、Dropout、weight decay |
| 验证精度波动大 | BN统计量不稳定 | 增大batch size或调大BN momentum |
| 模型推理速度慢 | 计算量太大 | 换更小的ResNet或做模型剪枝 |
| 迁移学习效果差 | 预训练域和目标域差异大 | 解冻更多层、用更小的学习率 |
| 多卡训练精度下降 | BN统计量不同步 | 用SyncBatchNorm |
| 输入尺寸不是224 | 全连接层维度不匹配 | 用AdaptiveAvgPool2d自适应 |
6. ResNet的变体与后续影响
6.1 从ResNet衍生出的经典架构
ResNet之后,残差连接成了深度学习模型的标准组件。几个重要的衍生方向:
ResNeXt:把残差块里的3x3卷积换成分组卷积,增加基数(cardinality),在相同参数量下精度更高。核心思想是“分组卷积+残差连接”,比单纯加深网络更有效。
Wide ResNet:减少深度、增加宽度。比如WRN-16-8,只有16层但每层通道数是原来的8倍,在CIFAR上表现很好。这说明深度不是唯一重要的维度,宽度同样关键。
DenseNet:把残差连接的“相加”改成“拼接”,每一层都接收前面所有层的输出。特征复用更充分,但显存占用更大。
EfficientNet:用神经架构搜索找到深度、宽度、分辨率的最优组合,残差连接仍然是基础模块。
6.2 残差思想在非图像领域的应用
残差连接的影响远不止图像分类。在自然语言处理中,Transformer的每个子层都用了残差连接:
output = LayerNorm(x + Sublayer(x))这里的x + Sublayer(x)就是残差连接,保证了梯度能直接传回浅层。没有残差连接,训练几十层的Transformer几乎不可能。
在语音识别、推荐系统、强化学习等领域,残差连接也随处可见。可以说,ResNet最大的贡献不是某个具体网络结构,而是提供了一种“让深层网络可训练”的通用范式。
6.3 什么时候不该用ResNet
ResNet不是万能的。以下几种情况可以考虑其他方案:
- 移动端/嵌入式设备:ResNet-50的参数量和计算量对手机来说还是太大。MobileNet、ShuffleNet用深度可分离卷积,更适合移动端。
- 极小数据集:如果训练样本只有几百张,ResNet容易过拟合。用更小的网络或传统特征+分类器可能更好。
- 需要极高推理速度:ResNet的串行结构导致推理延迟较高。一些并行结构(如RepVGG)在推理时可以融合成单路,速度更快。
- 3D数据:ResNet原本是为2D图像设计的。处理3D点云或视频时,需要用3D卷积或专门设计的网络(如PointNet、SlowFast)。
我个人经验是:ResNet适合作为基线模型和骨干网络,但在特定场景下需要根据计算预算、数据量、延迟要求做取舍。不要因为ResNet流行就无脑用,理解它的适用边界比会用更重要。
7. 一些实操中的零散经验
训练ResNet这些年,攒了一些不太上得了论文但很实用的经验,分享几个:
关于学习率预热:深层ResNet训练初期梯度比较大,直接用大学习率容易震荡。用5个epoch做线性预热,从1e-4慢慢升到初始学习率,训练会稳定很多。这个技巧在batch size很大时尤其重要。
关于标签平滑:ImageNet上训练ResNet时,标签平滑(label smoothing)能提升0.2%左右的精度。实现很简单,把one-hot标签里的1改成0.9,0改成0.1/(num_classes-1)。对防止过拟合有一定帮助。
关于EMA:指数移动平均(Exponential Moving Average)对模型权重做平滑,推理时用EMA权重,精度通常能提升0.3%-0.5%。实现也不复杂,每次更新时ema_weight = decay * ema_weight + (1-decay) * weight,decay一般设0.999。
关于测试时增强:推理时对输入做多次变换(如翻转、多尺度裁剪),把结果平均,能提升1%左右的精度。代价是推理时间成倍增加,看场景取舍。
关于模型保存:不要只保存最后一个epoch的权重。验证集精度最高的那个checkpoint往往比最后一个好。我一般每个epoch都存,最后挑最好的。如果磁盘空间紧张,至少保存最近5个和最好的那个。
关于随机种子:深度学习实验有随机性,同一个配置跑两次结果可能差0.5%。做对比实验时,固定随机种子,跑至少3次取平均,结论才可靠。
这些经验看起来琐碎,但实际项目中往往就是这些细节决定了模型能不能上线。ResNet本身的结构已经非常成熟了,真正拉开差距的是训练策略和工程细节。