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激活,得到这个块的最终输出。

这里有几个关键细节,是我在编码时踩过坑的:

  1. 维度必须一致:跳连接要求相加的两个张量(F(x)和x)在宽度、高度和通道数上必须完全一致。在BasicBlock中,默认设计就是保持维度不变,所以两个3x3卷积都使用了padding=1来保持空间尺寸,并且卷积的输出通道数等于输入通道数。
  2. 相加之后才激活:注意看,最后一个ReLU是在相加操作之后。也就是说,是 ReLU(F(x) + x),而不是 F(x) + ReLU(x)。这个顺序很重要,它保证了信息流的完整性。
  3. 身份映射(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卷积。我们来拆解一下:

  1. 第一个1x1卷积(降维):它的作用是把输入的高通道数“压缩”到一个较小的维度(通常是输入的1/4)。这大大减少了后续3x3卷积的计算量。比如输入是256通道,这里先降到64通道。
  2. 中间的3x3卷积(核心计算):这是在降维后的特征图上进行主要的空间特征提取。因为通道数已经减少,所以这个昂贵的大卷积核操作成本变得可以接受。
  3. 最后一个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仍然是一个强大且省心的起点。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐