游乐游手机版
首页/AI教程/文章详情

PyTorch深度学习实战:手算ResNet-18卷积网络完整步骤详解

时间:2026-05-29 20:15
ResNet-18通过引入残差连接解决深层网络梯度消失问题。其核心是残差块,包含两个3×3卷积、批量归一化和ReLU激活。当特征图尺寸或通道数变化时,使用1×1卷积调整跳跃连接,确保输入与残差输出维度匹配,从而有效训练深层网络。

ResNet-18 深度拆解:残差网络的核心原理与代码实现

在深度学习的世界里,经常有人问这样一个问题:网络越深,效果一定越好吗?

答案显然是否定的。如果只是简单地把网络层数堆上去,训练反而会变得更困难,甚至出现梯度消失或退化问题——深度越大,训练效果反而可能变差。

ResNet(残差网络)就是为了解决这个困境而诞生的。它的核心思路非常巧妙:加一条“捷径”,让数据可以跳跃传播。

今天重点拆解的是 ResNet-18,这个“18”代表网络总共有 18 层(主要指包含可学习参数的卷积层和全连接层)。它的核心特点就是“残差连接”(Residual Connection),正是靠着这条捷径,信息能在网络中跳跃传播,从而避免梯度消失,让深层网络的训练变得可行。

下面这张图是 ResNet-18 的网络简图(假设输入的张量形状为 3×64×64)。从图中可以看到,结构分为四个 stage,完整的结构图在最后。

ResNet-18 的基本架构可以概括为:

  • 残差块(Residual Block)负责特征提取,每个块内部包含两个 3×3 卷积。
  • 跳跃连接(Shortcut)让数据既可以通过卷积层,也可以直接“抄近路”传递到下一层。

残差连接

假设输入是 X,普通 CNN 的目标是直接学一个映射 F(X),但 ResNet 让网络学的是 F(X) - X。这样一来,我们可以重写成:

其中,F(X) 是卷积层学习的内容(即残差),X 是输入的跳跃连接(shortcut),最终输出就是 F(X) + X。

残差块通常包含两个卷积层,每个卷积层后面跟着批量归一化(Batch Normalization)和激活函数(如 ReLU)。这些层负责提取和转换输入特征。

那么问题来了:为什么残差块会有两种模式?

现在我们手动模拟一下残差块的运算过程:

  1. 给定 3×3 的输入张量。
  2. 通过第一个卷积层,批量归一化,激活函数(ReLU,负数变成 0)。
  3. 通过第二个卷积层,批量归一化,激活函数(ReLU,负数变成 0),得到 F(X)。
  4. F(X) + X 得到最后的输出。

需要注意:F(X) + X 可以直接相加的前提是两者的大小和通道数完全一致。所以当两者不一致时,就需要增加一个分支(用 1×1 卷积进行调整),这就是需要 shortcut 的情况:

  • 可以通过设置 1×1 卷积的卷积核数量来调整通道数。
  • 可以通过设置 1×1 卷积的填充 padding=0 来调整大小。

那么,什么时候两者会不一致呢?

  • 步长 ≠ 1 时:意味着输入的尺寸会缩小。比如 stride=2 时,H×W 变为 H/2×W/2。由于 x 形状变小了,需要用 1×1 卷积让 x 也变小,以便与 F(x) 匹配。
  • in_channels != out_channels 时:输入通道数不等于输出通道数,意味着 x 的通道数不能直接加到 F(x) 上。例如,之前的特征图有 64 个通道,当前块的 F(x) 计算出了 128 个通道。这样 x 是 64 通道,而 F(x) 是 128 通道,无法相加。解决办法:用 1×1 卷积进行升维/降维,把 x 变成与 F(x) 相同的通道数。

残差块的实现

代码实现

import torch
import torch.nn as nn
import torch.nn.functional as F

# ===== 定义 BasicBlock =====
class BasicBlock(nn.Module):
    expansion = 1
    def __init__(self, in_channels, out_channels, stride=1):
        super(BasicBlock, self).__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)
        # 如果尺寸或通道不同,调整 shortcut
        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):
        print("输入 x 形状:", x.shape)
        identity = self.shortcut(x)
        if len(self.shortcut) > 0:
            print("shortcut 调整后形状:", identity.shape)
        else:
            print("shortcut 未调整(直接跳接)")
        out = self.conv1(x)
        print("conv1 输出:", out.shape)
        out = self.bn1(out)
        print("bn1 输出:", out.shape)
        out = F.relu(out)
        print("relu1 输出:", out.shape)
        out = self.conv2(out)
        print("conv2 输出:", out.shape)
        out = self.bn2(out)
        print("bn2 输出:", out.shape)
        out += identity
        print("残差相加后:", out.shape)
        out = F.relu(out)
        print("relu2 输出:", out.shape)
        print("-" * 50)
        return out

# ===== 二、创建一个实例并测试 =====
# 情况1:输入输出通道相同,不降采样
block1 = BasicBlock(in_channels=3, out_channels=3, stride=1)
x1 = torch.randn(1, 3, 32, 32)  # batch=1, 通道=3, 尺寸=32x32
print("===== 测试 block1(stride=1)=====")
out1 = block1(x1)
print("最终输出:", out1.shape)

# 情况2:输入输出通道不同,降采样
block2 = BasicBlock(in_channels=3, out_channels=4, stride=2)
x2 = torch.randn(1, 3, 32, 32)
print("\n===== 测试 block2(stride=2)=====")
out2 = block2(x2)
print("最终输出:", out2.shape)

现在一步步图解这个过程:

情况1:输入输出通道相同,不降采样

首先,经过第一个卷积、归一化和激活函数,大小仍然是 3×32×32。

然后经过第二个卷积、归一化,大小仍然是 3×32×32。到这里,第一个支路计算结束。

接着计算残差分支。由于这个残差模块的输出通道数和输入通道数一样,步长也是 1,所以不需要残差分支,输出就是原始的 X。然后把 X 和另一个分支的 F(X) 逐元素相加,再经过一个 ReLU 激活函数处理,残差模块计算结束。

情况2:输入输出通道不相同,降采样

首先,经过第一个卷积(4 个大小 3×3、步长为 2 的卷积)、归一化和激活函数。由于卷积核的数量是 4,最后输出通道数也是 4;步长为 2,所以图片大小变为原来的一半,最终得到 4×16×16。

然后经过第二个卷积(4 个大小 3×3、步长为 1 的卷积)、归一化,大小仍然是 4×16×16。至此,第一个支路计算完成。

接着计算残差分支。由于残差模块的输出通道数和输入通道数不一致,所以残差分支的输出是原始的 X 经过 shortcut 后的结果(1×16×16)。

最终输出是原始分支的输出 F(x) 和残差分支的输出 X 相加,再经过 ReLU 激活函数,得到 4×16×16 大小的特征图。

make_layer:堆叠多个 BasicBlock

ResNet 共包含 4 个 layer,每个 layer 中有多个 num_blocks。为了更简洁地表示,可以用一个 layer 函数来集成多个残差层。

def _make_layer(self, out_channels, num_blocks, stride):
    layers = []  # 创建一个空列表,用于存储这一层的 BasicBlock
    # 第一个 BasicBlock 的 stride 为输入参数 stride
    layers.append(BasicBlock(self.in_channels, out_channels, stride))
    # 更新当前通道数
    self.in_channels = out_channels
    # 接下来的 num_blocks - 1 个 BasicBlock 都使用 stride=1
    for _ in range(1, num_blocks):
        layers.append(BasicBlock(self.in_channels, out_channels, stride=1))
    # 将所有的 BasicBlock 堆叠成一个 nn.Sequential 模块
    return nn.Sequential(*layers)

这里简单解释一下这个过程:

  • 初始化 layers 列表:用于存储构建的每一个 BasicBlock,最终返回的是 nn.Sequential(一个有序的模块容器)。
  • 添加第一个 BasicBlock:使用传入的 stride,并且将当前的输入通道数(self.in_channels)作为输入通道数,输出通道数为 out_channels。stride 影响卷积操作的步长,通常第一个 BasicBlock 需要通过卷积来改变特征图的尺寸(例如降采样)。
  • 更新当前通道数:每次添加一个 BasicBlock 后,输入通道数变为该 BasicBlock 输出的通道(out_channels)。
  • 添加后续的 BasicBlock:从第 2 个 BasicBlock 开始,使用 stride=1,即不改变特征图尺寸。这是为了保持相同的空间分辨率,通常在残差网络的后续块中使用步长为 1 来保证特征图尺寸不变。
  • 返回 nn.Sequential:最后通过 nn.Sequential 将所有的 BasicBlock 按顺序组合成一层。nn.Sequential 会按照列表中的顺序依次执行每个 BasicBlock。

来看一个简单的例子:

# ---------- 包含 _make_layer 的简单 ResNet 片段 ----------
class MiniResNet(nn.Module):
    def __init__(self):
        super(MiniResNet, self).__init__()
        self.in_channels = 3
        # 使用 _make_layer 创建一层:不降采样,保持 64 通道
        self.layer1 = self._make_layer(out_channels=64, num_blocks=2, stride=1)

    def _make_layer(self, out_channels, num_blocks, stride):
        layers = []
        layers.append(BasicBlock(self.in_channels, out_channels, stride))
        self.in_channels = out_channels
        for _ in range(1, num_blocks):
            layers.append(BasicBlock(self.in_channels, out_channels, stride=1))
        return nn.Sequential(*layers)

    def forward(self, x):
        print("input:", x.shape)
        x = self.layer1(x)
        print("after layer1:", x.shape)  # 64 通道, 空间尺寸不变
        return x

# ---------- 测试 ----------
model = MiniResNet()
# 假设输入是常见的 32x32 RGB 图像
inp = torch.randn(1, 3, 32, 32)
out = model(inp)

完整代码

import torch
import torch.nn as nn
import torch.nn.functional as F

class BasicBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super(BasicBlock, self).__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.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):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += self.shortcut(x)
        return F.relu(out)

class ResNet18(nn.Module):
    def __init__(self, num_classes=2):
        super(ResNet18, self).__init__()
        self.in_channels = 64
        self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.layer1 = self._make_layer(64, 2, stride=1)
        self.layer2 = self._make_layer(128, 2, stride=2)
        self.layer3 = self._make_layer(256, 2, stride=2)
        self.layer4 = self._make_layer(512, 2, stride=2)
        self.a vgpool = nn.AdaptiveA vgPool2d((1, 1))
        self.fc = nn.Linear(512, num_classes)

    def _make_layer(self, out_channels, num_blocks, stride):
        layers = []
        layers.append(BasicBlock(self.in_channels, out_channels, stride))
        self.in_channels = out_channels
        for _ in range(1, num_blocks):
            layers.append(BasicBlock(self.in_channels, out_channels, stride=1))
        return nn.Sequential(*layers)

    def forward(self, x):
        print("输入形状: ", x.shape)

        out = self.conv1(x)
        print("conv1输出: ", out.shape)
        out = self.bn1(out)
        print("bn1输出:   ", out.shape)
        out = F.relu(out)
        print("relu输出:  ", out.shape)

        out = self.layer1(out)
        print("layer1输出:", out.shape)

        out = self.layer2(out)
        print("layer2输出:", out.shape)

        out = self.layer3(out)
        print("layer3输出:", out.shape)

        out = self.layer4(out)
        print("layer4输出:", out.shape)

        out = self.a vgpool(out)
        print("a vgpool输出:", out.shape)

        out = torch.flatten(out, 1)
        print("flatten输出:", out.shape)

        out = self.fc(out)
        print("fc输出:    ", out.shape)

        return out

# 测试网络(打印结构+输出形状)
if __name__ == "__main__":
    model = ResNet18(num_classes=2)
    print("="*50)
    print("ResNet18 网络结构:")
    print(model)  # 打印网络结构
    print("="*50)

    x = torch.randn(1, 3, 32, 32)  # 输入:1张3通道32x32图像
    print("\n各层输出形状:")
    model(x)

以下是运行后打印的网络结构和各层输出形状,可以清晰地看到 ResNet-18 每一层的构成与数据流变化:

==================================================
ResNet18 网络结构:
ResNet18(
  (conv1): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (layer1): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (shortcut): Sequential()
    )
    (1): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (shortcut): Sequential()
    )
  )
  (layer2): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (shortcut): Sequential(
        (0): Conv2d(64, 128, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (shortcut): Sequential()
    )
  )
  (layer3): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (shortcut): Sequential(
        (0): Conv2d(128, 256, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (shortcut): Sequential()
    )
  )
  (layer4): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (shortcut): Sequential(
        (0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (shortcut): Sequential()
    )
  )
  (a vgpool): AdaptiveA vgPool2d(output_size=(1, 1))
  (fc): Linear(in_features=512, out_features=2, bias=True)
)
==================================================

各层输出形状:
输入形状:  torch.Size([1, 3, 32, 32])
conv1输出:  torch.Size([1, 64, 32, 32])
bn1输出:    torch.Size([1, 64, 32, 32])
relu输出:   torch.Size([1, 64, 32, 32])
layer1输出: torch.Size([1, 64, 32, 32])
layer2输出: torch.Size([1, 128, 16, 16])
layer3输出: torch.Size([1, 256, 8, 8])
layer4输出: torch.Size([1, 512, 4, 4])
a vgpool输出: torch.Size([1, 512, 1, 1])
flatten输出: torch.Size([1, 512])
fc输出:     torch.Size([1, 2])
来源:https://developer.aliyun.com/article/1738485
上一篇文库AI文档助手革新你的写作方式 下一篇AI文档编辑助手赋能文档编辑未来的新技术
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
GPT Workspace通过GPT-5强化Google Workspace,文档表格邮件创作效率与智能化提升
AI教程 · 2026-05-29

GPT Workspace通过GPT-5强化Google Workspace,文档表格邮件创作效率与智能化提升

GPT Workspace 产品介绍:GPT-5 如何增强 Google Workspace 工作效率 如果你每天都在使用 Google Workspace 进行文档撰写、表格处理、邮件沟通和演示制作,一定深有体会:大量重复性的办公任务耗费了宝贵的时间。现在,GPT Workspace 将 GPT-

AI助手提升年终总结与周报效率的精准营销策略
AI教程 · 2026-05-29

AI助手提升年终总结与周报效率的精准营销策略

适合需求:在信息爆炸的时代,企业所承受的竞争压力几乎覆盖了所有维度,其中营销领域尤为令人困扰。无论是撰写年终总结还是生成周报,精准的营销策略已成为不可或缺的需求——没有谁愿意在庞杂的数据中迷失方向。当我们复盘营销活动时,总会思考:过去哪些数字营销策略真正发挥了效果?哪些内容营销策略有待改进?然而实际

Afri Studio 非洲创意工作室
AI教程 · 2026-05-29

Afri Studio 非洲创意工作室

Afri Studio是什么先来聊聊Afri Studio——它是Afri AI团队推出的一款AI媒体创作工作室,目标很明确:把原本高高在上的智能技术拉下神坛,让普通用户也能轻松生成高质量的文本、图像、音频等内容。换句话说,这是一个面向内容创作者、博主、营销人员、艺术家的“AI工具箱”,帮你高效搞定

Geniea专注Midjourney提示词优化提升创意生成效率
AI教程 · 2026-05-29

Geniea专注Midjourney提示词优化提升创意生成效率

Geniea产品详解:Midjourney提示优化工具Geniea是一款专注于Midjourney提示词优化的智能平台,致力于帮助创作者快速生成高质量且富有创意的提示方案。无论您需要电影镜头、食品摄影还是汽车广告等场景的提示词,只需输入简单指令,系统便会自动输出优化后的提示文本,大幅提升创作效率。提

幼儿园大班毕业典礼方案PPT AI轻松制作精彩回顾
AI教程 · 2026-05-29

幼儿园大班毕业典礼方案PPT AI轻松制作精彩回顾

使用情景 每年毕业季来临之际,幼儿园大班毕业典礼的筹备工作,总是牵动着众多老师、家长和孩子们的心弦。这不仅仅是一场简单的活动,更是孩子们人生中首个重要的成长仪式,标志着他们告别幼儿时光、迈向新阶段的里程碑。对于家长而言,这也是一次充满感怀的“毕业”,意味着一段陪伴旅程的暂时落幕。 如何让这场典礼既温