残差网络的介绍及ResNet-18的搭建(pytorch版)

文章目录

  • 前言
  • 1.为什么需要残差网络?
  • [2.ResNet 的核心创新:残差块与残差连接​](#2.ResNet 的核心创新:残差块与残差连接)
    • [2.1 什么是 "残差"?](#2.1 什么是 “残差”?)
    • [2.2. 残差块的两种结构](#2.2. 残差块的两种结构)
      • [2.2.1恒等映射残差块(Identity Block)](#2.2.1恒等映射残差块(Identity Block))
      • [2.2.2 1×1 卷积残差块(Conv Block)](#2.2.2 1×1 卷积残差块(Conv Block))
  • 3.搭建ResNet-18
  • 结语

前言

在深度学习领域,"更深的网络性能更好" 曾是研究者们的共识 ------ 理论上,网络层数越多,能捕捉的特征越复杂,拟合能力也越强。但在 2015 年之前,当网络深度超过 20 层后,研究者们发现了一个致命问题:梯度消失 / 梯度爆炸导致模型无法训练,甚至出现 "深度退化" 现象(深层网络的测试误差反而比浅层网络更高)。而残差网络(Residual Network,简称 ResNet)的出现,彻底打破了这一困境,不仅让 1000 层以上的超深网络成为可能,更成为如今计算机视觉领域的 "基石架构" 之一。本篇博客主要介绍残差网络以及如何搭建残差网络,以ResNet-18为例,原始论文地址:ResNet

1.为什么需要残差网络?

在 ResNet 诞生前,传统卷积神经网络(如 AlexNetVGG)的深度通常在 10-20 层。当研究者尝试将网络层数提升到 50 层、100 层时,遇到了两个核心问题:​

1.1梯度消失 / 梯度爆炸​

深度学习的训练依赖反向传播模型通过计算损失函数对各层参数的梯度,不断调整参数以降低误差。但梯度在反向传播过程中,会经过多层权重的 "乘积"

如果每一层的梯度绝对值小于 1,经过几十层后,梯度会趋近于 0(梯度消失)

如果每一层的梯度绝对值大于 1,经过几十层后,梯度会趋近于无穷大(梯度爆炸)。​

无论是梯度消失还是爆炸,都会导致深层网络的参数无法有效更新:浅层参数几乎不动,深层参数更新混乱,模型最终无法收敛。

1.2深度退化现象

即使通过 权重初始化、Batch Normalization 等技术缓解了梯度问题,研究者还发现了更奇怪的现象:当网络深度超过一定阈值后,测试误差会随着层数增加而上升。

正是为了解决这两个痛点,微软亚洲研究院的何凯明团队在 2015 年的 ImageNet 竞赛中提出了 ResNet,一举夺冠并引发深度学习架构的 "深度革命"。

2.ResNet 的核心创新:残差块与残差连接​

ResNet 的核心思想非常简洁:在传统网络的基础上,增加 残差连接"(Residual Connection),让网络可以直接学习 "残差" 而非 "完整特征"。

2.1 什么是 "残差"?

假设传统网络中,某一层的输入为x ,期望输出为H(x) (即该层需要学习的完整特征)。ResNet 没有让该层直接学习H(x) ,而是引入了一条 "shortcut path"(捷径),将输入x直接传递到该层的输出端,让该层学习 "残差"F(x) = H(x) - x

最终该层的输出为:H(x) = F(x) + x

这里的F(x)就是 "残差",而x通过捷径直接传递的过程,就是 "残差连接"。

为什么要学习残差?因为当网络需要学习 "恒等映射"(即H(x) = x,该层不需要改变特征)时,传统网络需要让参数学习到H(x) = x,这在深层网络中很难实现;而 ResNet 只需让F(x) = 0(残差为 0)即可,大大降低了学习难度。

2.2. 残差块的两种结构

ResNet 的基本组成单元是 "残差块"(Residual Block),根据输入输出特征图的尺寸是否一致,分为两种结构:

2.2.1恒等映射残差块(Identity Block)

当输入x的特征图尺寸(高度、宽度)和通道数,与残差块的输出特征图尺寸一致时,使用这种结构。此时,残差连接可以直接将x与F(x)相加(元素 - wise add)。

其结构流程为:

  1. 输入x经过第一个卷积层(Conv2d),激活函数为 ReLU;
  2. 经过第二个卷积层(Conv2d),此时不使用 ReLU;
  3. 通过残差连接,将原始输入x与第二个卷积层的输出相加;
  4. 经过 ReLU 激活函数,得到残差块的输出。

如下图所示:

代码如下:

python 复制代码
import torch
import torch.nn as nn
import torch.nn.functional as F


class IdentityBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        """
        初始化恒等映射残差块
        :param in_channels: 输入特征图的通道数
        :param out_channels: 输出特征图的通道数(ResNet-18中in_channels=out_channels)
        :param stride: 卷积步长(默认1,不改变尺寸)
        """
        super(IdentityBlock, self).__init__()
        # 第一层卷积:3×3卷积(提取特征)+ ReLU(激活函数)
        self.conv1 = nn.Conv2d(
            in_channels=in_channels,
            out_channels=out_channels,
            kernel_size=3,  # 3×3卷积核
            stride=stride,
            padding=1,  # padding=1保证输入输出尺寸一致(H/W不变)
        )

        # 第二层卷积:3×3卷积(进一步提取特征,无ReLU,后续与捷径相加后再激活)
        self.conv2 = nn.Conv2d(
            in_channels=out_channels,
            out_channels=out_channels,
            kernel_size=3,
            stride=1,
            padding=1,
        )

    def forward(self, x):
        """前向传播:输入x → 卷积→ReLU → 卷积→ 加捷径 → ReLU"""
        residual = x  # 捷径:保存原始输入(恒等映射)

        # 第一层卷积+ReLU
        out = self.conv1(x)
        out = F.relu(out)

        # 第二层卷积(无ReLU)
        out = self.conv2(out)

        # 残差连接:输出 + 捷径(恒等映射)
        out += residual
        # 最终激活
        out = F.relu(out)

        return out

if __name__ == '__main__':
    Net = IdentityBlock(3, 3)
    input=torch.randn(3,32,32)
    output=Net(input)
    print(output.shape)

2.2.2 1×1 卷积残差块(Conv Block)

当输入x的特征图尺寸或通道数,与残差块的输出不一致时(例如网络需要下采样或调整通道数),直接相加会出现 "维度不匹配" 的问题。此时,需要在残差连接中增加一个1×1 卷积层,将x的维度调整为与F(x)一致,再进行相加。

代码如下:

python 复制代码
class ConvBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=2):
        """
        初始化1×1卷积调整残差块
        :param in_channels: 输入特征图的通道数
        :param out_channels: 输出特征图的通道数(通常是输入的2倍)
        :param stride: 卷积步长(默认2,实现下采样,H/W变为原来的1/2)
        """
        super(ConvBlock, self).__init__()
        # 主路径:3×3卷积(stride=2下采样)+ ReLU → 3×3卷积
        self.conv1 = nn.Conv2d(
            in_channels=in_channels,
            out_channels=out_channels,
            kernel_size=3,
            stride=stride,  # 步长=2,下采样
            padding=1
        )

        self.conv2 = nn.Conv2d(
            in_channels=out_channels,
            out_channels=out_channels,
            kernel_size=3,
            stride=1,
            padding=1,
        )

        # 捷径:1×1卷积(调整通道数+下采样)+ BN(确保维度与主路径输出一致)
        self.shortcut = nn.Conv2d(
            in_channels=in_channels,
            out_channels=out_channels,
            kernel_size=1,  # 1×1卷积(仅调整通道数,不改变特征图内容)
            stride=stride,  # 与主路径一致,实现下采样
        )


    def forward(self, x):
        """前向传播:输入x → 主路径卷积→ReLU → 主路径卷积 → 捷径卷积 → 相加 → ReLU"""
        # 主路径
        out = self.conv1(x)
        out = F.relu(out)

        out = self.conv2(out)

        # 捷径路径(1×1卷积调整维度)
        residual = self.shortcut(x)

        # 残差连接:主路径输出 + 调整后的捷径
        out += residual
        out = F.relu(out)

        return out

if __name__ == '__main__':
    # Net = IdentityBlock(3, 3)
    Net=ConvBlock(3,6)
    input=torch.randn(3,32,32)
    output=Net(input)
    print(output.shape)

3.搭建ResNet-18

了解上述两种结构后,下面开始搭建经典网络结构ResNet-18

3.1ResNet-18介绍

ResNet-18 的整体结构遵循 "输入层→4 个残差块组→全局平均池化→全连接层",具体配置如下:​

  • 输入层:7×7 卷积(下采样)+ 最大池化
  • 残差块组 1(通道 64):2 个恒等映射块(无下采样,尺寸不变)
  • 残差块组 2(通道 128):1 个 Conv Block(下采样)+ 1 个 Identity Block
  • 残差块组 3(通道 256):1 个 Conv Block(下采样)+ 1 个 Identity Block
  • 残差块组 4(通道 512):1 个 Conv Block(下采样)+ 1 个 Identity Block
  • 输出层:全局平均池化 + 全连接层(输出类别数,如 ImageNet 的 1000 类)

3.2代码部分

这里为了避免代码的过度重复,引入了_make_layer()函数,此函数用于批量创建残差块组。

代码如下:

python 复制代码
   def _make_layer(self, out_channels, num_blocks, stride):
        """
        批量创建残差块组
        :param out_channels: 该组残差块的输出通道数
        :param num_blocks: 该组包含的残差块数量
        :param stride: 该组第一个残差块的步长(用于下采样)
        :return: 残差块组(nn.Sequential)
        """
        layers = []
        # 每组的第一个残差块:若stride≠1或输入通道≠输出通道,用ConvBlock(调整维度)
        if stride != 1 or self.in_channels != out_channels:
            layers.append(ConvBlock(self.in_channels, out_channels, stride))
        else:
            layers.append(IdentityBlock(self.in_channels, out_channels, stride))
        # 更新输入通道数(后续块的输入=当前块的输出)
        self.in_channels = out_channels

        # 每组剩余的残差块:均为IdentityBlock(维度已匹配)
        for _ in range(1, num_blocks):
            layers.append(IdentityBlock(self.in_channels, out_channels))

        return nn.Sequential(*layers)

此时创建ResNet-18,代码如下:

python 复制代码
class ResNet18(nn.Module):
    def __init__(self, num_classes=1000):
        """
        初始化ResNet-18
        :param num_classes: 输出类别数(默认1000,对应ImageNet数据集;若为CIFAR-10则设为10)
        """
        super(ResNet18, self).__init__()
        # 1. 输入层:7×7卷积(下采样)+ BN + ReLU + 最大池化
        self.in_channels = 64  # 输入层卷积后的通道数(固定为64)
        self.conv1 = nn.Conv2d(
            in_channels=3,  # 输入图像为RGB三通道
            out_channels=self.in_channels,
            kernel_size=7,
            stride=2,  # 步长=2,下采样(H/W从224→112)
            padding=3,  # 7×7卷积+padding=3,保证尺寸计算:(224-7+2*3)/2 +1 = 112
            bias=False
        )
        self.bn1 = nn.BatchNorm2d(self.in_channels)
        self.maxpool = nn.MaxPool2d(
            kernel_size=3,
            stride=2,  # 进一步下采样(H/W从112→56)
            padding=1
        )

        # 2. 残差块组(共4组,对应ResNet-18的结构)
        self.layer1 = self._make_layer(out_channels=64, num_blocks=2, stride=1)  # 无下采样(56→56)
        self.layer2 = self._make_layer(out_channels=128, num_blocks=2, stride=2)  # 下采样(56→28)
        self.layer3 = self._make_layer(out_channels=256, num_blocks=2, stride=2)  # 下采样(28→14)
        self.layer4 = self._make_layer(out_channels=512, num_blocks=2, stride=2)  # 下采样(14→7)

        # 3. 输出层:全局平均池化 + 全连接层
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))  # 自适应池化,无论输入尺寸,输出(1,1)特征图
        self.fc = nn.Linear(512, num_classes)  # 512通道→num_classes类别

    def forward(self, x):
        """ResNet-18前向传播完整流程"""
        # 输入层
        out = self.conv1(x)
        out = self.bn1(out)
        out = F.relu(out)
        out = self.maxpool(out)

        # 残差块组
        out = self.layer1(out)
        out = self.layer2(out)
        out = self.layer3(out)
        out = self.layer4(out)

        # 输出层
        out = self.avgpool(out)  # 输出尺寸:(batch_size, 512, 1, 1)
        out = torch.flatten(out, 1)  # 展平:(batch_size, 512)
        out = self.fc(out)  # 最终输出:(batch_size, num_classes)

        return out

3.3完整代码及测试

根据以上信息介绍,完整代码如下:

python 复制代码
import torch
import torch.nn as nn
import torch.nn.functional as F


class IdentityBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        """
        初始化恒等映射残差块
        :param in_channels: 输入特征图的通道数
        :param out_channels: 输出特征图的通道数(ResNet-18中in_channels=out_channels)
        :param stride: 卷积步长(默认1,不改变尺寸)
        """
        super(IdentityBlock, self).__init__()
        # 第一层卷积:3×3卷积(提取特征)+ ReLU(激活函数)
        self.conv1 = nn.Conv2d(
            in_channels=in_channels,
            out_channels=out_channels,
            kernel_size=3,  # 3×3卷积核
            stride=stride,
            padding=1,  # padding=1保证输入输出尺寸一致(H/W不变)
        )

        # 第二层卷积:3×3卷积(进一步提取特征,无ReLU,后续与捷径相加后再激活)
        self.conv2 = nn.Conv2d(
            in_channels=out_channels,
            out_channels=out_channels,
            kernel_size=3,
            stride=1,
            padding=1,
        )

    def forward(self, x):
        """前向传播:输入x → 卷积→ReLU → 卷积→ 加捷径 → ReLU"""
        residual = x  # 捷径:保存原始输入(恒等映射)

        # 第一层卷积+ReLU
        out = self.conv1(x)
        out = F.relu(out)

        # 第二层卷积(无ReLU)
        out = self.conv2(out)

        # 残差连接:输出 + 捷径(恒等映射)
        out += residual
        # 最终激活
        out = F.relu(out)

        return out


class ConvBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=2):
        """
        初始化1×1卷积调整残差块
        :param in_channels: 输入特征图的通道数
        :param out_channels: 输出特征图的通道数(通常是输入的2倍)
        :param stride: 卷积步长(默认2,实现下采样,H/W变为原来的1/2)
        """
        super(ConvBlock, self).__init__()
        # 主路径:3×3卷积(stride=2下采样)+ ReLU → 3×3卷积
        self.conv1 = nn.Conv2d(
            in_channels=in_channels,
            out_channels=out_channels,
            kernel_size=3,
            stride=stride,  # 步长=2,下采样
            padding=1
        )

        self.conv2 = nn.Conv2d(
            in_channels=out_channels,
            out_channels=out_channels,
            kernel_size=3,
            stride=1,
            padding=1,
        )

        # 捷径:1×1卷积(调整通道数+下采样)+ BN(确保维度与主路径输出一致)
        self.shortcut = nn.Conv2d(
            in_channels=in_channels,
            out_channels=out_channels,
            kernel_size=1,  # 1×1卷积(仅调整通道数,不改变特征图内容)
            stride=stride,  # 与主路径一致,实现下采样
        )


    def forward(self, x):
        """前向传播:输入x → 主路径卷积→ReLU → 主路径卷积 → 捷径卷积 → 相加 → ReLU"""
        # 主路径
        out = self.conv1(x)
        out = F.relu(out)

        out = self.conv2(out)

        # 捷径路径(1×1卷积调整维度)
        residual = self.shortcut(x)

        # 残差连接:主路径输出 + 调整后的捷径
        out += residual
        out = F.relu(out)

        return out


class ResNet18(nn.Module):
    def __init__(self, num_classes=1000):
        """
        初始化ResNet-18
        :param num_classes: 输出类别数(默认1000,对应ImageNet数据集;若为CIFAR-10则设为10)
        """
        super(ResNet18, self).__init__()
        # 1. 输入层:7×7卷积(下采样)+ BN + ReLU + 最大池化
        self.in_channels = 64  # 输入层卷积后的通道数(固定为64)
        self.conv1 = nn.Conv2d(
            in_channels=3,  # 输入图像为RGB三通道
            out_channels=self.in_channels,
            kernel_size=7,
            stride=2,  # 步长=2,下采样(H/W从224→112)
            padding=3,  # 7×7卷积+padding=3,保证尺寸计算:(224-7+2*3)/2 +1 = 112
            bias=False
        )
        self.bn1 = nn.BatchNorm2d(self.in_channels)
        self.maxpool = nn.MaxPool2d(
            kernel_size=3,
            stride=2,  # 进一步下采样(H/W从112→56)
            padding=1
        )

        # 2. 残差块组(共4组,对应ResNet-18的结构)
        self.layer1 = self._make_layer(out_channels=64, num_blocks=2, stride=1)  # 无下采样(56→56)
        self.layer2 = self._make_layer(out_channels=128, num_blocks=2, stride=2)  # 下采样(56→28)
        self.layer3 = self._make_layer(out_channels=256, num_blocks=2, stride=2)  # 下采样(28→14)
        self.layer4 = self._make_layer(out_channels=512, num_blocks=2, stride=2)  # 下采样(14→7)

        # 3. 输出层:全局平均池化 + 全连接层
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))  # 自适应池化,无论输入尺寸,输出(1,1)特征图
        self.fc = nn.Linear(512, num_classes)  # 512通道→num_classes类别

    def _make_layer(self, out_channels, num_blocks, stride):
        """
        批量创建残差块组
        :param out_channels: 该组残差块的输出通道数
        :param num_blocks: 该组包含的残差块数量
        :param stride: 该组第一个残差块的步长(用于下采样)
        :return: 残差块组(nn.Sequential)
        """
        layers = []
        # 每组的第一个残差块:若stride≠1或输入通道≠输出通道,用ConvBlock(调整维度)
        if stride != 1 or self.in_channels != out_channels:
            layers.append(ConvBlock(self.in_channels, out_channels, stride))
        else:
            layers.append(IdentityBlock(self.in_channels, out_channels, stride))
        # 更新输入通道数(后续块的输入=当前块的输出)
        self.in_channels = out_channels

        # 每组剩余的残差块:均为IdentityBlock(维度已匹配)
        for _ in range(1, num_blocks):
            layers.append(IdentityBlock(self.in_channels, out_channels))

        return nn.Sequential(*layers)

    def forward(self, x):
        """ResNet-18前向传播完整流程"""
        # 输入层
        out = self.conv1(x)
        out = self.bn1(out)
        out = F.relu(out)
        out = self.maxpool(out)

        # 残差块组
        out = self.layer1(out)
        out = self.layer2(out)
        out = self.layer3(out)
        out = self.layer4(out)

        # 输出层
        out = self.avgpool(out)  # 输出尺寸:(batch_size, 512, 1, 1)
        out = torch.flatten(out, 1)  # 展平:(batch_size, 512)
        out = self.fc(out)  # 最终输出:(batch_size, num_classes)

        return out

if __name__ == '__main__':
    input=torch.randn(3,3,224,224)
    Net=ResNet18()
    output=Net(input)
    print(output.shape)

此处测试输入的是批量大小为3的三通道彩色图,图片尺寸大小为224×224,该任务完成的是1000分类。输出结果output尺寸为:

3.4 为什么叫ResNet-18?

仔细观察本网络结构,依次为:输入层(卷积层,调整数据规格,此处为第一层)、四个残差组(每个残差组有两个BLOCKS,即残差块,Identity Block 或者Conv Block,每个Block包括两个卷积层,所以此处有2×2×4=16层网络)、全连接层(此处为最后一层),共计18层网络结构,因此称为ResNet-18

结语

本篇博客主要介绍了如何从零搭建一个ResNet-18网络,可以使用该网络结构实现分类问题,可以动手实现利用该网络在CIFAR-10数据集、MINST数据集等公开数据集进一步熟悉,希望能够对你有所帮助!

相关推荐
NAGNIP8 小时前
一文搞懂深度学习中的通用逼近定理!
人工智能·算法·面试
冬奇Lab9 小时前
一天一个开源项目(第36篇):EverMemOS - 跨 LLM 与平台的长时记忆 OS,让 Agent 会记忆更会推理
人工智能·开源·资讯
冬奇Lab9 小时前
OpenClaw 源码深度解析(一):Gateway——为什么需要一个"中枢"
人工智能·开源·源码阅读
AngelPP13 小时前
OpenClaw 架构深度解析:如何把 AI 助手搬到你的个人设备上
人工智能
宅小年13 小时前
Claude Code 换成了Kimi K2.5后,我再也回不去了
人工智能·ai编程·claude
九狼13 小时前
Flutter URL Scheme 跨平台跳转
人工智能·flutter·github
ZFSS13 小时前
Kimi Chat Completion API 申请及使用
前端·人工智能
天翼云开发者社区14 小时前
春节复工福利就位!天翼云息壤2500万Tokens免费送,全品类大模型一键畅玩!
人工智能·算力服务·息壤
知识浅谈14 小时前
教你如何用 Gemini 将课本图片一键转为精美 PPT
人工智能
Ray Liang15 小时前
被低估的量化版模型,小身材也能干大事
人工智能·ai·ai助手·mindx