残差块与分组残差块

深入理解残差连接的设计原理,掌握ResNet和ResNeXt中残差块和分组残差块的实现方法

发布于 2025-08-06 更新于 2025-08-062,928 字 7 分钟
残差块与分组残差块的文章封面
展开文章目录
  1. 1. 残差块与分组残差块的结构
  2. 1.1 残差块
  3. 1.2 分组残差块
  4. 2. 思路与代码实现
  5. 2.1 残差块
  6. 2.2 分组残差块
  7. 总结

传统神经网络的每一层直接学习从输入到输出的映射,使得网络的层层堆叠就是函数的相互嵌套,累积的依赖性和协同效应、信号在层中的衰减、局部最小值等,显著增大了模型训练的难度。

1. 残差块与分组残差块的结构

1.1 残差块

残差块结构示意图

典型的残差块结构沿用 VGG 网络的卷积层参数,从步幅为 1、通道数与输入一致的 3×3 卷积层开始,经过批量归一化和 ReLU 函数激活后,到达第二个卷积层并再次批量归一化,得到被称为残差映射img的输出。然后,残差映射值与来自跳跃连接的原始输入img相加,得到img。最终被 ReLU 函数激活输出。

ResNet 的后续版本 (ResNet v2) 调整了每个卷积块的内部顺序,由“卷积层 (Conv2d) → 批量归一化层 (BatchNorm) → 激活函数 (ReLU)”改为“批量归一化层 (BatchNorm) → 激活函数 (ReLU) → 卷积层 (Conv2d)”,称为卷积块的预激活 (pre-activate) 架构。

ResNet v2 预激活架构

在后激活架构中,卷积层后的 BatchNorm 层与 ReLU 函数分别改变数据的分布特征并非线性化。而预激活架构的数据经批量归一化并激活后,才能作为卷积层的输入,使原始输出信号”纯净”而不发生”变形”(减少了额外的扰动)。有助于模型最大限度地维持特征的原始分布,并通过跳跃连接使原始输入和真实”增量 (residual)“相加,实现恒等映射。这种先统一数据分布,再由卷积层学习特征的方式促进了模型的快速收敛,使反向传播中的梯度更易于流动,进一步支持了深层网络的训练。

1.2 分组残差块

传统 CNN 主要通过增加深度(层数)提升模型的表达能力,通过增加宽度(通道数)促进信息的传递。残差块缓解了更深的模型难以训练的问题,但没有考虑因通道数的增加而导致的计算成本增长——卷积核尺寸为img,输入通道数为img、输出通道数为img时的计算复杂度为img

ResNeXt分组残差块结构

ResNeXt 使用分组卷积 (grouped convolution)img个输入通道并行地分为img组,每组由img个通道组成。这些组独立地进行特征提取,分别产生img个通道的输出。最后连接为img个通道。这样,计算复杂度和参数数量都减少了img倍,为img

ResNeXt 引入的分组卷积思想对后来的轻量级网络设计(如 MobileNet、ShuffleNet)产生了重要影响,成为减少网络参数和计算量的重要技术。

2. 思路与代码实现

2.1 残差块

残差块(后激活)的实现思路如下:

  • 使用 PyTorch,通过继承nn.Module实现ResNetBlock残差块,并重载__init__()方法自定义模块结构、重载forward()方法自定义数据的流向;

  • 提供in_channelsout_channels分别作为残差块的输入输出通道数。为了适应可能较深的网络,提供参数stride用于对空间尺寸的下采样。提供参数bottleneck_ratio以支持瓶颈 (bottleneck) 结构:减少中间层的通道数以减少计算量,并在输出前恢复维度,同时不牺牲模型的表达力。在进行残差连接中的维度匹配时,提供可选的downsample参数用于灵活地自定义下采样模块,而不是仅限于使用 1×1 卷积;

  • 残差块的数据只有主路径main_path和跳跃连接shortcut两个流向:

  • 标准残差块的主路径用于特征提取:由两个 3×3 卷积层堆叠,在第一个卷积层中执行下采样,最后一个卷积层不使用激活函数。当使用瓶颈结构时,中间层的输出数据通道数变为原始的 ¼(默认),然后在标准残差块主路径中恢复通道数;

  • 跳跃连接用于将输入直接传递到输出,学习恒等映射:为了避免形状不匹配,当空间尺寸被stride下采样或输入输出通道数不一致时,使用 1×1 卷积层调整输入数据的维度;

  • 每个卷积层的输出都被批量归一化,因此不必在卷积层中处理偏置项(bias=False),避免冗余计算;

  • 向前传播时,分别计算来自主路径和跳跃连接的输出,并逐元素相加。最后激活求和结果;

  • 以 ReLU 函数作为激活函数。

2.1.1 代码实现

from typing import Optional

import torch.nn.functional as F
from torch import nn, Tensor


class ResNetBlock(nn.Module):
    def __init__(
            self,
            in_channels: int,
            out_channels: int, *,
            stride: int = 1,
            bottleneck_ratio: int = 4,
            downsample: Optional[nn.Module] = None):
        """
        后激活的残差块

        :param in_channels: 输入通道数
        :param out_channels: 输出通道数
        :param stride: 卷积步长
        :param bottleneck_ratio: 瓶颈比率,控制中间层宽度
        :param downsample: 下采样层,用于维度匹配
        """
        super().__init__()

        # 计算中间层通道数
        assert bottleneck_ratio >= 1, f'瓶颈比率 ({bottleneck_ratio}) 应大于或等于 1'
        mid_channels = out_channels // bottleneck_ratio if bottleneck_ratio > 1 else out_channels

        if bottleneck_ratio > 1:
            # 1x1 Conv -> 3x3 Conv -> 1x1 Conv
            assert mid_channels > 0, f'中间通道数 ({mid_channels}) 必须大于 0'

            self.main_path = nn.Sequential(
                nn.Conv2d(in_channels, mid_channels, kernel_size=1, stride=1, padding=0, bias=False),
                nn.BatchNorm2d(mid_channels),
                nn.ReLU(),

                nn.Conv2d(mid_channels, mid_channels, kernel_size=3, stride=stride, padding=1, bias=False),
                nn.BatchNorm2d(mid_channels),
                nn.ReLU(),

                nn.Conv2d(mid_channels, out_channels, kernel_size=1, stride=1, padding=0, bias=False),
                nn.BatchNorm2d(out_channels)
            )
        else:
            # 3x3 Conv -> 3x3 Conv
            self.main_path = nn.Sequential(
                nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False),
                nn.BatchNorm2d(out_channels),
                nn.ReLU(),

                nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False),
                nn.BatchNorm2d(out_channels)
            )

        # 匹配维度用于跳跃连接
        if downsample is not None:
            self.shortcut = downsample
        elif stride != 1 or in_channels != out_channels:  # 空间尺寸、通道数发生变化时,再调整维度
            self.shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False)
        else:
            self.shortcut = nn.Identity()  # 恒等映射

    def forward(self, x: Tensor) -> Tensor:
        out = self.main_path(x) + self.shortcut(x)  # 主路径与跳跃连接相加
        out = F.relu(out)  # 激活加和后的结果
        return out

2.1.2 模型测试

使用torchinfo库的summary函数执行更便利的输出维度测试。

不使用瓶颈结构:
from torchinfo import summary

model = ResNetBlock(128, 32, bottleneck_ratio=1) summary(model, input_size=(4, 128, 64, 64))

==========================================================================================
Layer (type
) Output Shape Param #

ResNetBlock [4, 32, 64, 64] — ├─Sequential: 1-1 [4, 32, 64, 64] — │ └─Conv2d: 2-1 [4, 32, 64, 64] 36,864 │ └─BatchNorm2d: 2-2 [4, 32, 64, 64] 64 │ └─ReLU: 2-3 [4, 32, 64, 64] — │ └─Conv2d: 2-4 [4, 32, 64, 64] 9,216 │ └─BatchNorm2d: 2-5 [4, 32, 64, 64] 64 ├─Conv2d: 1-2 [4, 32, 64, 64] 4,096

Total params: 50,304 Trainable params: 50,304 Non-trainable params: 0 Total mult-adds (Units.MEGABYTES): 822.08

Input size (MB): 8.39 Forward/backward pass size (MB): 20.97 Params size (MB): 0.20 Estimated Total Size (MB): 29.56 ==========================================================================================

仅使用瓶颈结构:
from torchinfo import summary

model = ResNetBlock(128, 32, bottleneck_ratio=4) summary(model, input_size=(4, 128, 64, 64))

==========================================================================================
Layer (type
) Output Shape Param #

ResNetBlock [4, 32, 64, 64] — ├─Sequential: 1-1 [4, 32, 64, 64] — │ └─Conv2d: 2-1 [4, 8, 64, 64] 1,024 │ └─BatchNorm2d: 2-2 [4, 8, 64, 64] 16 │ └─ReLU: 2-3 [4, 8, 64, 64] — │ └─Conv2d: 2-4 [4, 8, 64, 64] 576 │ └─BatchNorm2d: 2-5 [4, 8, 64, 64] 16 │ └─ReLU: 2-6 [4, 8, 64, 64] — │ └─Conv2d: 2-7 [4, 32, 64, 64] 256 │ └─BatchNorm2d: 2-8 [4, 32, 64, 64] 64 ├─Conv2d: 1-2 [4, 32, 64, 64] 4,096

Total params: 6,048 Trainable params: 6,048 Non-trainable params: 0 Total mult-adds (Units.MEGABYTES): 97.52

Input size (MB): 8.39 Forward/backward pass size (MB): 16.78 Params size (MB): 0.02 Estimated Total Size (MB): 25.19 ==========================================================================================

既使用下采样又使用瓶颈结构:
from torchinfo import summary

model = ResNetBlock(128, 32, stride=3, bottleneck_ratio=4) summary(model, input_size=(4, 128, 64, 64))

==========================================================================================
Layer (type
) Output Shape Param #

ResNetBlock [4, 32, 22, 22] — ├─Sequential: 1-1 [4, 32, 22, 22] — │ └─Conv2d: 2-1 [4, 8, 64, 64] 1,024 │ └─BatchNorm2d: 2-2 [4, 8, 64, 64] 16 │ └─ReLU: 2-3 [4, 8, 64, 64] — │ └─Conv2d: 2-4 [4, 8, 22, 22] 576 │ └─BatchNorm2d: 2-5 [4, 8, 22, 22] 16 │ └─ReLU: 2-6 [4, 8, 22, 22] — │ └─Conv2d: 2-7 [4, 32, 22, 22] 256 │ └─BatchNorm2d: 2-8 [4, 32, 22, 22] 64 ├─Conv2d: 1-2 [4, 32, 22, 22] 4,096

Total params: 6,048 Trainable params: 6,048 Non-trainable params: 0 Total mult-adds (Units.MEGABYTES): 26.32

Input size (MB): 8.39 Forward/backward pass size (MB): 3.83 Params size (MB): 0.02 Estimated Total Size (MB): 12.24 ==========================================================================================

2.2 分组残差块

分组残差块ResNeXtBlock(预激活)的实现思路与残差块类似。主要区别在于:

  • 使用预激活的卷积块
  • torch.nn.Conv2d层引入groups参数,实现分组卷积。当groups为默认值 1 时,执行标准卷积;当groups大于 1 时,数据基于通道被分为多个独立的组单独执行卷积操作。这在保持模型表达能力不变的条件下,显著减少了计算量和参数数量;
  • 由于使用的是预激活的卷积块架构,需移除跳跃连接中的批量归一化层;
  • 使用bottleneck_ratio参数,支持对瓶颈结构中间层的通道缩减比例提供更灵活的控制,使用可选的downsample参数支持自定义下采样模块,而不是仅限于使用 1×1 卷积。

2.2.1 代码实现

class ResNeXtBlock(nn.Module):
    def __init__(
            self,
            in_channels: int,
            out_channels: int, *,
            stride: int = 1,
            groups: int = 1,
            bottleneck_ratio: int = 4,
            downsample: Optional[nn.Module] = None):
        """
        预激活的分组残差块

        :param in_channels: 输入通道数
        :param out_channels: 输出通道数
        :param stride: 卷积步长
        :param groups: 分组卷积的组数
        :param bottleneck_ratio: 瓶颈比率,控制中间层宽度
        :param downsample: 下采样层,用于维度匹配
        """
        super().__init__()

        # 计算中间层通道数
        assert bottleneck_ratio >= 1, f'瓶颈比率 ({bottleneck_ratio}) 应大于或等于 1'
        mid_channels = out_channels // bottleneck_ratio if bottleneck_ratio > 1 else out_channels

        if bottleneck_ratio > 1:
            # 1x1 Conv -> 3x3 Conv -> 1x1 Conv
            assert mid_channels % groups == 0, f'中间通道数 ({mid_channels}) 必须能被卷积分组数 ({groups}) 整除'
            assert mid_channels > 0, f'中间通道数 ({mid_channels}) 必须大于 0'

            self.main_path = nn.Sequential(
                nn.BatchNorm2d(in_channels), nn.ReLU(),
                nn.Conv2d(in_channels, mid_channels, kernel_size=1, stride=1, padding=0, bias=False),

                nn.BatchNorm2d(mid_channels), nn.ReLU(),
                nn.Conv2d(mid_channels, mid_channels, kernel_size=3, stride=stride, padding=1, groups=groups,
                          bias=False),

                nn.BatchNorm2d(mid_channels), nn.ReLU(),
                nn.Conv2d(mid_channels, out_channels, kernel_size=1, stride=1, padding=0, bias=False)
            )
        else:
            # 3x3 Conv -> 3x3 Conv
            assert out_channels % groups == 0, f'输出通道数 ({out_channels}) 必须能被卷积分组数 ({groups}) 整除'

            self.main_path = nn.Sequential(
                nn.BatchNorm2d(in_channels), nn.ReLU(),
                nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, groups=groups,
                          bias=False),

                nn.BatchNorm2d(out_channels), nn.ReLU(),
                nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, groups=groups, bias=False)
            )

        # 匹配维度用于跳跃连接
        if downsample is not None:
            self.shortcut = downsample
        elif stride != 1 or in_channels != out_channels:  # 空间尺寸、通道数发生变化时,再调整维度
            self.shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False)
        else:
            self.shortcut = nn.Identity()

    def forward(self, x: Tensor) -> Tensor:
        return self.main_path(x) + self.shortcut(x)

2.2.2 模型测试

使用torchinfo库的summary函数执行更便利的输出维度测试。

不使用瓶颈结构:
from torchinfo import summary

model = ResNeXtBlock(128, 32, bottleneck_ratio=1) summary(model, input_size=(4, 128, 64, 64))

==========================================================================================
Layer (type
) Output Shape Param #

ResNeXtBlock [4, 32, 64, 64] — ├─Sequential: 1-1 [4, 32, 64, 64] — │ └─BatchNorm2d: 2-1 [4, 128, 64, 64] 256 │ └─ReLU: 2-2 [4, 128, 64, 64] — │ └─Conv2d: 2-3 [4, 32, 64, 64] 36,864 │ └─BatchNorm2d: 2-4 [4, 32, 64, 64] 64 │ └─ReLU: 2-5 [4, 32, 64, 64] — │ └─Conv2d: 2-6 [4, 32, 64, 64] 9,216 ├─Conv2d: 1-2 [4, 32, 64, 64] 4,096

Total params: 50,496 Trainable params: 50,496 Non-trainable params: 0 Total mult-adds (Units.MEGABYTES): 822.08

Input size (MB): 8.39 Forward/backward pass size (MB): 33.55 Params size (MB): 0.20 Estimated Total Size (MB): 42.15 ==========================================================================================

使用瓶颈结构与分组卷积:
from torchinfo import summary

model = ResNeXtBlock(128, 32, groups=4, bottleneck_ratio=4) summary(model, input_size=(4, 128, 64, 64))

==========================================================================================
Layer (type
) Output Shape Param #

ResNeXtBlock [4, 32, 64, 64] — ├─Sequential: 1-1 [4, 32, 64, 64] — │ └─BatchNorm2d: 2-1 [4, 128, 64, 64] 256 │ └─ReLU: 2-2 [4, 128, 64, 64] — │ └─Conv2d: 2-3 [4, 8, 64, 64] 1,024 │ └─BatchNorm2d: 2-4 [4, 8, 64, 64] 16 │ └─ReLU: 2-5 [4, 8, 64, 64] — │ └─Conv2d: 2-6 [4, 8, 64, 64] 144 │ └─BatchNorm2d: 2-7 [4, 8, 64, 64] 16 │ └─ReLU: 2-8 [4, 8, 64, 64] — │ └─Conv2d: 2-9 [4, 32, 64, 64] 256 ├─Conv2d: 1-2 [4, 32, 64, 64] 4,096

Total params: 5,808 Trainable params: 5,808 Non-trainable params: 0 Total mult-adds (Units.MEGABYTES): 90.44

Input size (MB): 8.39 Forward/backward pass size (MB): 29.36 Params size (MB): 0.02 Estimated Total Size (MB): 37.77 ==========================================================================================

既使用下采样又使用瓶颈结构与分组卷积:
from torchinfo import summary

model = ResNeXtBlock(128, 32, stride=3, groups=4, bottleneck_ratio=4) summary(model, input_size=(4, 128, 64, 64))

==========================================================================================
Layer (type
) Output Shape Param #

ResNeXtBlock [4, 32, 22, 22] — ├─Sequential: 1-1 [4, 32, 22, 22] — │ └─BatchNorm2d: 2-1 [4, 128, 64, 64] 256 │ └─ReLU: 2-2 [4, 128, 64, 64] — │ └─Conv2d: 2-3 [4, 8, 64, 64] 1,024 │ └─BatchNorm2d: 2-4 [4, 8, 64, 64] 16 │ └─ReLU: 2-5 [4, 8, 64, 64] — │ └─Conv2d: 2-6 [4, 8, 22, 22] 144 │ └─BatchNorm2d: 2-7 [4, 8, 22, 22] 16 │ └─ReLU: 2-8 [4, 8, 22, 22] — │ └─Conv2d: 2-9 [4, 32, 22, 22] 256 ├─Conv2d: 1-2 [4, 32, 22, 22] 4,096

Total params: 5,808 Trainable params: 5,808 Non-trainable params: 0 Total mult-adds (Units.MEGABYTES): 25.48

Input size (MB): 8.39 Forward/backward pass size (MB): 20.11 Params size (MB): 0.02 Estimated Total Size (MB): 28.53 ==========================================================================================


总结

本文介绍了残差连接的核心概念和两种主要实现方式:

🔗 残差块 (ResNet)

  • 跳跃连接:通过残差学习简化深层网络训练
  • 梯度流动:缓解梯度消失问题,支持 100+ 层网络
  • 架构演进:从后激活到预激活架构的优化

🌐 分组残差块 (ResNeXt)

  • 分组卷积:将通道分组,减少计算复杂度
  • 基数概念:多路径并行提供多样化特征表示
  • 效率提升:在保持性能的同时降低参数量和计算量

💡 技术影响

  • 深层网络训练:残差连接成为深度网络的标准组件
  • 轻量化网络:分组卷积思想影响了 MobileNet、ShuffleNet 等轻量级网络设计
  • 架构创新:为现代深度学习网络架构奠定了重要基础

如果这篇记录对你有帮助,可以留下一句回应。

前往留言

DISCUSSION

讨论与回应

欢迎补充细节、指出问题,或分享与这篇文章有关的经验。

昵称与邮箱为必填项,邮箱仅用于头像和回复通知,不会公开。网址可以留空。

正在准备留言区…

演示赞赏界面

谢谢你愿意支持长期写作。

第一阶段不会发起支付或收集任何信息。接入真实赞赏渠道后,这里会展示清楚的金额、渠道和完成状态。

输入关键词开始搜索 · 按 Esc 关闭
打开完整搜索页 →