ResNet中的残差块、瓶颈结构与跳连接:深度学习中的关键设计解析
1. 从“学不动”到“学得深”:为什么我们需要ResNet?
如果你和我一样,从经典的LeNet、AlexNet、VGG一路学过来,到了准备动手搭建一个更深的网络时,心里肯定会犯嘀咕:网络层数是不是越多越好?理论上,更深的网络能学习更复杂、更抽象的特征,识别能力应该更强。但现实很骨感,我在早期尝试堆叠卷积层时,经常遇到一个让人头疼的问题:网络深度增加到一定程度后,模型的性能不仅不提升,反而会显著下降。更诡异的是,训练误差和测试误差都一起变大了。这可不是过拟合,因为过拟合通常是训练误差小、测试误差大。这种现象,在论文里被称为“网络退化”。
我当时百思不得其解,明明给了模型更强的表达能力,它怎么就“摆烂”了呢?后来才明白,问题出在“优化”上。传统的深度神经网络,可以看作是在学习一个从输入到输出的复杂映射函数 H(x)。当网络很深时,这个函数变得极其复杂。在反向传播更新参数时,梯度需要经过层层传递。每经过一层,梯度都可能被激活函数(比如ReLU)或其导数“打折扣”。层数一多,传到最前面几层的梯度就变得微乎其微,几乎为零,导致这些层的参数几乎得不到有效更新。这就是臭名昭著的“梯度消失”问题。虽然像Batch Normalization(批标准化)这样的技术能缓解这个问题,让梯度的数值稳定在正常范围,但它解决不了梯度“相关性”衰减的问题。你可以想象成,BN只是把信号音量调正常了,但信号在长途传输中依然会失真、走样。
ResNet的提出,就像是在这个困局中打开了一扇天窗。它的核心思想异常简洁而深刻:既然让网络直接去拟合一个复杂的映射 H(x) 这么困难,那不如让它去拟合一个“残差”。什么是残差?就是目标输出 H(x) 和输入 x 之间的差值 F(x) = H(x) - x。这样一来,网络需要学习的映射就变成了 H(x) = F(x) + x。这个“+ x”就是整个ResNet的灵魂所在,它就是我们今天要重点聊的“跳连接”。
这个设计的精妙之处在于,它把学习目标从“一切从零开始”变成了“在已有的基础上做微调”。如果某一层啥也没学到,那F(x)就趋近于0,输出H(x)就约等于输入x,至少网络性能不会退化。这相当于给深度网络训练加了一个“恒等映射”的保底,让增加深度这件事,至少不会带来坏处。有了这个保底,我们才敢放心大胆地把网络做到几十层、上百层甚至上千层。接下来,我们就深入这个“残差”世界,看看它的核心组件是如何工作的。
2. 残差块的核心:BasicBlock与Bottleneck结构拆解
ResNet的成功,不是靠一个模糊的概念,而是靠一个个精心设计的“残差块”堆砌起来的。在PyTorch的官方实现里,你会主要遇到两种残差块:BasicBlock和Bottleneck。别看名字简单,里面的门道可不少。
2.1 BasicBlock:轻量而经典的双层结构
BasicBlock是ResNet用于较浅网络(如ResNet-18, ResNet-34)的基本单元。我第一次看到它的结构图时,觉得它特别清晰。它通常由两层3x3的卷积层构成,中间夹着批标准化和ReLU激活函数。
它的数据流向是这样的:输入x进来后,走两条路。主路是正经的卷积路径:x -> Conv1 -> BN1 -> ReLU -> Conv2 -> BN2。旁路就是那条神奇的“跳连接”,它直接把输入x原封不动地拿过来。最后,在主路的第二个BN输出之后,将主路的输出F(x)和旁路的x逐元素相加,然后再经过一个ReLU激活,得到这个块的最终输出。
这里有几个关键细节,是我在编码时踩过坑的:
- 维度必须一致:跳连接要求相加的两个张量(F(x)和x)在宽度、高度和通道数上必须完全一致。在BasicBlock中,默认设计就是保持维度不变,所以两个3x3卷积都使用了
padding=1来保持空间尺寸,并且卷积的输出通道数等于输入通道数。 - 相加之后才激活:注意看,最后一个ReLU是在相加操作之后。也就是说,是 ReLU(F(x) + x),而不是 F(x) + ReLU(x)。这个顺序很重要,它保证了信息流的完整性。
- 身份映射(Identity Mapping):这是跳连接最理想的情况。当网络已经学习得很好,不需要额外调整时,我们可以让F(x)学习为0,这样输出就等于输入,实现了无损的信息传递。
下面是一个用PyTorch实现的简化版BasicBlock,你可以清晰地看到这个流程:
import torch.nn as nn
class BasicBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1):
super().__init__()
# 主路:两个3x3卷积
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)
# 跳连接处理:如果输入输出维度或步长不一致,需要用1x1卷积调整
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 = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
# 将主路输出与经过shortcut处理的输入相加
out += self.shortcut(identity)
out = self.relu(out) # 相加后再激活
return out
2.2 Bottleneck:深度网络的效率利器
当网络变得非常深(如ResNet-50, 101, 152)时,全部使用3x3卷积的BasicBlock会带来巨大的计算量。这时候,Bottleneck结构就派上用场了。它的设计非常巧妙,核心思想是“先压缩,再处理,再扩展”。
一个标准的Bottleneck块包含三个卷积层,顺序是:1x1卷积 -> 3x3卷积 -> 1x1卷积。我们来拆解一下:
- 第一个1x1卷积(降维):它的作用是把输入的高通道数“压缩”到一个较小的维度(通常是输入的1/4)。这大大减少了后续3x3卷积的计算量。比如输入是256通道,这里先降到64通道。
- 中间的3x3卷积(核心计算):这是在降维后的特征图上进行主要的空间特征提取。因为通道数已经减少,所以这个昂贵的大卷积核操作成本变得可以接受。
- 最后一个1x1卷积(升维):将通道数再“扩展”回原来的维度(通常是输入的4倍),以便与跳连接的输入x进行相加。例如,从64通道升回256通道。
为什么叫“瓶颈”呢?你可以想象一个沙漏,中间窄,两边宽。数据流就像沙子,先被压缩(变窄)通过一个高效的处理区(3x3卷积),然后再恢复原状。这个设计在保持甚至增强模型表达能力的同时,显著降低了计算复杂度和参数量。实测下来,对于深层网络,使用Bottleneck比全部使用BasicBlock要快得多,而且效果更好。
在Bottleneck中,跳连接的处理逻辑和BasicBlock一样:如果输入x的维度与最终输出维度一致,就直接相加;如果不一致(通常发生在每个Stage的第一个块,需要下采样并增加通道数),就需要通过一个包含1x1卷积和BN的shortcut层来调整x的维度和步长。
3. 跳连接:不止是解决梯度消失的“高速公路”
跳连接是残差思想的物理实现,但它带来的好处,远不止是解决梯度消失那么简单。我刚开始理解它,也只是觉得它是个“梯度捷径”,但用多了才发现,它的内涵丰富得多。
3.1 对抗梯度消失与网络退化
这是跳连接最广为人知的作用。在普通网络中,梯度反向传播是一条“单行线”,必须经过每一层。跳连接相当于在层与层之间修建了多条“高架桥”或“应急车道”。梯度可以直接从深层“跳跃”回浅层,避免了在漫长主路上传递造成的损耗。
用一个不太严谨但很形象的比喻:传统网络像是一个必须从一楼爬到一百楼的楼梯,爬到后面肯定没力气了(梯度消失)。而带有跳连接的ResNet,就像是在每10层楼设置了一个直达电梯,你可以随时从高层坐电梯快速回到低层,确保低层的员工(浅层参数)也能及时收到高层的指令(梯度)。
论文中的实验数据也证明了这一点:在极深的网络上,Plain Net(无跳连接)的训练误差会随着深度增加而上升(退化),而ResNet的训练误差则能持续下降。
3.2 实现特征的多尺度融合
这是我后来在目标检测和语义分割任务中体会特别深的一点。跳连接天然地实现了不同网络层特征图的融合。浅层网络的特征图分辨率高,包含丰富的细节信息(如边缘、纹理),但语义性弱;深层网络的特征图语义性强(能识别出“狗”、“车”),但分辨率低,细节丢失严重。
跳连接将浅层的高分辨率、低语义特征与深层的低分辨率、高语义特征直接相加,相当于让模型同时“看到”细节和整体。这在很多需要精细定位的任务中至关重要。比如,在U-Net、FPN(特征金字塔网络)这类结构中,跳连接被广泛用于融合不同层级的特征,从而在分割或检测中同时获得准确的类别判断和位置信息。
3.3 赋予模型动态深度的能力
这是一个非常有趣的观点。有了跳连接,模型在训练时,每个残差块实际上有了选择权:它可以努力地学习一个复杂的F(x),也可以“偷懒”让F(x)接近于0。如果对于某个输入,某个块发现“什么都不做”(即输出等于输入)就是最优解,那么通过训练,它的权重就会倾向于让F(x)归零。
这意味着,ResNet并不是一个僵化的、所有路径都必须工作的固定深度网络。它更像一个动态深度的网络集合,对于不同的样本,信息流经的有效深度可能是不同的。这极大地增强了模型的灵活性和鲁棒性。从信息论角度看,跳连接也缓解了信息在深度网络中的逐层损耗问题,让原始输入信息能更无损地传递到深层。
4. 从理论到实践:构建你自己的ResNet
理解了原理,不亲手搭一个总觉得缺点什么。下面我们就抛开那些庞大的预训练模型,从一个极简的、可运行的ResNet-18开始,看看这些残差块是如何组装起来的。
一个完整的ResNet通常分为几个“阶段”(Stage)。每个阶段开始时,特征图的空间尺寸会减半(通过设置卷积stride=2),同时通道数会增加一倍,以保持计算量的平衡。在每个阶段内部,则堆叠多个残差块,保持空间尺寸和通道数不变。
我们以ResNet-18为例,它使用BasicBlock,结构是:[Conv1, BN1, ReLU, MaxPool] -> Stage1: 2个BasicBlock -> Stage2: 2个BasicBlock -> Stage3: 2个BasicBlock -> Stage4: 2个BasicBlock -> AvgPool, FC。
这里的关键是,下采样(空间尺寸减半)和通道翻倍的操作,通常发生在每个Stage的第一个残差块里。在这个块中,主路的第一个卷积层会设置stride=2,同时跳连接也需要通过一个stride=2的1x1卷积来同步调整尺寸和通道。
import torch
import torch.nn as nn
def make_layer(block, in_channels, out_channels, num_blocks, stride=1):
# 构建一个阶段(Stage)
layers = []
# 第一个块可能需要下采样和调整通道
layers.append(block(in_channels, out_channels, stride))
# 后续的块保持尺寸和通道不变
for _ in range(1, num_blocks):
layers.append(block(out_channels, out_channels, stride=1))
return nn.Sequential(*layers)
class SimpleResNet18(nn.Module):
def __init__(self, num_classes=10):
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 = make_layer(BasicBlock, 64, 64, num_blocks=2, stride=1) # Stage1
self.layer2 = make_layer(BasicBlock, 64, 128, num_blocks=2, stride=2) # Stage2,下采样
self.layer3 = make_layer(BasicBlock, 128, 256, num_blocks=2, stride=2)# Stage3,下采样
self.layer4 = make_layer(BasicBlock, 256, 512, num_blocks=2, stride=2)# Stage4,下采样
# 全局平均池化和全连接层
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512, num_classes)
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
# 实例化一个模型,并打印结构
model = SimpleResNet18(num_classes=1000)
print(model)
运行这段代码,你可以看到一个清晰的ResNet-18结构。你可以尝试修改make_layer中的num_blocks,或者将BasicBlock替换为Bottleneck(同时需要调整通道数的变化规则),来构建ResNet-34或ResNet-50。动手改一改参数,看看输出张量的尺寸变化,比读十篇理论文章理解得都透彻。
5. 超越ResNet:跳连接思想的演进与变体
ResNet的成功点燃了深度学习社区对网络结构设计,特别是“连接方式”的热情。跳连接这个简单的“加法”操作,衍生出了一系列有趣且强大的变体。
DenseNet(密集连接网络) 可以说是将跳连接思想发挥到了极致。在DenseNet的每个“密集块”里,每一层都会接收前面所有层输出的拼接作为输入。这不再是简单的“一条”捷径,而是构建了一个全连接的前馈图。这种设计极大地促进了特征重用,让网络可以用更少的参数和计算量达到更好的性能,并且梯度流动极其顺畅。你可以把它理解为ResNet的“超级增强版”,信息流通的路径更多、更密集。
Inception-ResNet 则是将Google的Inception模块(多分支并行卷积)与残差连接相结合。每个Inception模块的输出会与模块的输入相加。这种融合同时获得了Inception结构提取多尺度特征的能力和ResNet稳定训练深网络的能力,在当时的ImageNet竞赛中表现非常出色。
Pre-Activation ResNet(又名ResNet v2) 这是何恺明团队对原始ResNet的一个改进。它调整了残差块内BN、ReLU和卷积的顺序,提出了“BN-ReLU-Conv”作为预激活的单元。具体来说,就是把激活和标准化操作放在卷积操作之前。实验表明,这种结构能产生更平滑的梯度流,形成一种“反向传播友好的”恒等映射,使得网络更容易训练,尤其对于极深的网络(如1000层以上)效果更稳定。
这些变体告诉我们,跳连接不仅仅是一个固定套路。它的核心哲学是构建信息传输的捷径,打破层的线性堆叠。无论是加、是拼接,还是更复杂的组合,只要遵循这个哲学,就有可能设计出更高效、更强大的网络结构。在实际项目中,选择哪种结构往往需要根据具体任务、数据量和计算资源来权衡。对于大多数视觉任务,标准的ResNet-50/101仍然是一个强大且省心的起点。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)