要理解 PyTorch 实现 ResNet-18,我们可以把它拆成 “搭积木” 的过程:先造好最核心的 “小积木”(残差块),再用这些积木拼出 “整个模型骨架”(从输入到输出的完整流程),最后验证模型是否能跑通。全程用 “生活化类比 + 代码拆解”,即使不懂复杂原理也能看明白。

前置知识:PyTorch 实现模型的核心逻辑

在 PyTorch 里写模型,本质是定义一个 继承自 torch.nn.Module 的类,这个类要包含两个关键部分:

  1. __init__ 方法:“准备积木”—— 定义模型需要的所有组件(比如卷积层、残差块、全连接层);
  2. forward 方法:“拼积木”—— 定义数据在模型里的流动路径(输入→卷积→残差块→输出)。

我们就按照 “先做小积木,再拼大模型” 的顺序来实现 ResNet-18。

第一步:造核心 “小积木”——BasicBlock(基础残差块)

ResNet-18 的核心是 BasicBlock(2 层卷积的残差块),它就像模型的 “最小功能单元”,负责提取图像特征并通过 “残差连接” 避免梯度消失。

先看 BasicBlock 的结构(类比 “迷你加工站”):

  • 输入数据 → 1 层 3×3 卷积 → 批量归一化(BN)→ ReLU 激活 → 1 层 3×3 卷积 → BN → 残差连接(加原始输入)→ 最终 ReLU 激活。
代码拆解 BasicBlock(带通俗注释)
import torch
import torch.nn as nn  # PyTorch的神经网络工具箱

class BasicBlock(nn.Module):
    # 初始化:定义残差块里的所有“小零件”
    def __init__(self, in_channels, out_channels, stride=1):
        super(BasicBlock, self).__init__()
        # 1. 第一层卷积:3×3卷积核,步长stride(控制特征图尺寸是否缩小)
        self.conv1 = nn.Conv2d(
            in_channels=in_channels,  # 输入特征图的“通道数”(比如64个通道=64种特征)
            out_channels=out_channels, # 输出特征图的通道数
            kernel_size=3,             # 卷积核大小(3×3,提取局部特征)
            stride=stride,             # 步长(1=尺寸不变,2=尺寸缩小一半)
            padding=1,                 # 边缘填充(保证卷积后尺寸符合预期)
            bias=False                 # 因为后面有BN,BN会处理偏置,这里设为False
        )
        # 2. 第一层卷积后的批量归一化(BN):让数据分布更稳定,加速训练
        self.bn1 = nn.BatchNorm2d(out_channels)
        # 3. ReLU激活函数:给模型加“非线性”,让它能学习复杂特征(比如从边缘学到纹理)
        self.relu = nn.ReLU(inplace=True)  # inplace=True:节省内存
        
        # 4. 第二层卷积:和第一层结构类似,但步长固定为1(不改变尺寸)
        self.conv2 = nn.Conv2d(
            in_channels=out_channels,
            out_channels=out_channels,
            kernel_size=3,
            stride=1,
            padding=1,
            bias=False
        )
        self.bn2 = nn.BatchNorm2d(out_channels)
        
        # 5. 残差连接的“适配层”:当输入输出通道数/尺寸不一样时,用1×1卷积调整
        # 比如:输入通道64,输出通道128,直接加会“尺寸不匹配”,需要用1×1卷积把64→128
        self.downsample = None  # 默认没有适配层(输入输出一致时)
        if stride != 1 or in_channels != out_channels:
            self.downsample = nn.Sequential(  # 用“序列容器”把层打包
                nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(out_channels)
            )
    
    # 定义数据流动路径(forward=向前传播)
    def forward(self, x):
        residual = x  # 先把原始输入存起来(对应“残差连接的捷径”)
        
        # 1. 走“正常卷积路径”:conv1 → bn1 → relu → conv2 → bn2
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)
        
        out = self.conv2(out)
        out = self.bn2(out)
        
        # 2. 残差连接:如果需要适配,先调整原始输入的通道/尺寸,再和卷积结果相加
        if self.downsample is not None:
            residual = self.downsample(x)  # 适配原始输入
        out += residual  # 核心:卷积结果 + 原始输入(残差连接)
        
        # 3. 最后激活,输出该残差块的结果
        out = self.relu(out)
        return out
通俗理解 BasicBlock:

就像 “加工苹果”:

  • 原始苹果(x)→ 洗苹果(conv1)→ 擦干(bn1)→ 切小块(relu)→ 加糖(conv2)→ 装盒(bn2)→ 最后把 “原始苹果(residual)” 和 “加工后的苹果(out)” 混合 → 最终成品(激活后的 out)。
  • 如果原始苹果太大(输入输出尺寸不匹配),先把原始苹果切小(downsample)再混合。

第二步:拼 “大模型”——ResNet-18 完整结构

ResNet-18 的整体结构,我们在之前的介绍里提过(6 个阶段),现在用 BasicBlock 把这些阶段 “拼起来”:
输入图像 → 初始卷积层 → 初始 BN → ReLU → 最大池化 → 4 组残差块(共 8 个 BasicBlock)→ 全局平均池化 → 全连接层 → 输出类别

代码拆解 ResNet-18(带通俗注释)
class ResNet18(nn.Module):
    # 初始化:拼出整个模型的“骨架”
    def __init__(self, num_classes=1000):  # num_classes:分类任务的类别数(比如ImageNet是1000类)
        super(ResNet18, self).__init__()
        # 1. 初始处理层:把输入图片(比如3通道RGB图)转成64通道特征图,同时缩小尺寸
        self.in_channels = 64  # 后续残差块的“输入通道数”初始值
        self.conv1 = nn.Conv2d(
            in_channels=3,        # 输入:3通道(RGB彩色图)
            out_channels=64,      # 输出:64通道特征图
            kernel_size=7,        # 7×7大卷积核:快速压缩尺寸
            stride=2,             # 步长2:图片尺寸缩小一半(比如224×224→112×112)
            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  # 3×3池化核:尺寸再缩小一半(112×112→56×56)
        )
        
        # 2. 4组残差块(共8个BasicBlock,对应ResNet-18的“18层”中16个卷积层)
        # 每组残差块的参数:(块数, 输出通道数, 步长)
        self.layer1 = self._make_layer(BasicBlock, 64, 2, stride=1)  # 2个块,输出64通道,尺寸56×56(不变)
        self.layer2 = self._make_layer(BasicBlock, 128, 2, stride=2) # 2个块,输出128通道,尺寸28×28(缩小一半)
        self.layer3 = self._make_layer(BasicBlock, 256, 2, stride=2) # 2个块,输出256通道,尺寸14×14(缩小一半)
        self.layer4 = self._make_layer(BasicBlock, 512, 2, stride=2) # 2个块,输出512通道,尺寸7×7(缩小一半)
        
        # 3. 全局平均池化:把7×7×512的特征图,转成1×1×512的向量(每个通道取平均值)
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))  # 不管输入尺寸,输出都是(1,1)
        
        # 4. 全连接层:把512维向量,转成“类别数”维的输出(比如1000类,输出1000个数值)
        self.fc = nn.Linear(512, num_classes)
    
    # 辅助函数:批量创建残差块(避免重复写代码)
    def _make_layer(self, block, out_channels, blocks_num, stride=1):
        layers = []  # 用列表存所有残差块
        
        # 第一块残差块:可能需要步长stride(缩小尺寸)或适配通道,所以单独创建
        layers.append(block(self.in_channels, out_channels, stride))
        self.in_channels = out_channels  # 更新后续块的“输入通道数”(和当前输出通道一致)
        
        # 剩下的blocks_num-1块:步长固定为1(不改变尺寸),通道数已适配
        for _ in range(blocks_num - 1):
            layers.append(block(self.in_channels, out_channels, stride=1))
        
        # 用“序列容器”把所有块打包,返回一个“组”
        return nn.Sequential(*layers)
    
    # 定义整个模型的数据流动路径
    def forward(self, x):
        # 1. 初始处理:conv1 → bn1 → relu → maxpool
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.maxpool(x)
        
        # 2. 4组残差块:逐层提取更复杂的特征
        x = self.layer1(x)  # 56×56×64 → 56×56×64
        x = self.layer2(x)  # 56×56×64 → 28×28×128
        x = self.layer3(x)  # 28×28×128 → 14×14×256
        x = self.layer4(x)  # 14×14×256 → 7×7×512
        
        # 3. 池化+全连接:输出类别
        x = self.avgpool(x)  # 7×7×512 → 1×1×512
        x = torch.flatten(x, 1)  # 把(1×1×512)展平成(512,)的向量(去掉空间维度)
        x = self.fc(x)  # 512 → num_classes(比如1000)
        
        return x

第三步:验证模型 —— 让 ResNet-18 跑起来

写好模型后,我们需要 “喂点数据”,验证模型是否能正常输出结果(相当于 “试运转”)。

测试代码(带通俗注释)
# 1. 创建ResNet-18模型实例(比如分类1000类)
model = ResNet18(num_classes=1000)
# 设为“评估模式”(如果是训练,需要用model.train())
model.eval()

# 2. 模拟一张输入图片:PyTorch要求输入格式是“(批次大小, 通道数, 高度, 宽度)”
# 这里模拟“1张3通道RGB图,尺寸224×224”(ImageNet的标准输入尺寸)
fake_image = torch.randn(1, 3, 224, 224)  # randn:生成符合正态分布的随机数据(模拟图片像素)

# 3. 让数据流过模型,得到输出
with torch.no_grad():  # 评估时不需要计算梯度,节省内存
    output = model(fake_image)

# 4. 查看输出结果
print("模型输出形状:", output.shape)  # 应该是(1, 1000):1个样本,1000个类别得分
print("预测概率最高的类别索引:", torch.argmax(output, dim=1).item())  # 取得分最高的类别(0-999之间)
输出结果示例:
模型输出形状: torch.Size([1, 1000])
预测概率最高的类别索引: 456

这说明模型能正常工作:输入一张 “假图片”,输出 1000 个类别得分,并找到得分最高的类别(456 类,具体是什么类取决于训练数据)。

关键补充:ResNet-18 的 “18 层” 到底在哪?

很多人会疑惑 “代码里没看到 18 层啊”,这里明确计算:
ResNet-18 的 “18 层可训练层”= 卷积层(16 层) + 全连接层(2 层)

  • 初始卷积层:1 层(conv1);
  • 4 组残差块:每组 2 个 BasicBlock,每个 Block 含 2 层卷积 → 4×2×2=16 层?不,初始卷积层单独算,残差块里的卷积层是 4 组 ×2 块 ×2 层 = 16 层?不对,重新算:
    正确计算:初始卷积层(1) + 4 组残差块(每组 2 块 ×2 层卷积 = 4 层,4 组共 16 层) + 全连接层(1 层)?不,PyTorch 官方 ResNet-18 的 “18 层” 定义是 16 层卷积层 + 2 层全连接层,但实际代码中全连接层只有 1 层(fc)—— 核心是 “关注残差连接的设计”,层数统计是学术定义,不影响使用。

简单记:只要用了 “BasicBlock + 残差连接”,且结构符合 “初始层 + 4 组残差块 + 池化 + 全连接”,就是正确的 ResNet-18

一句话总结 PyTorch 实现 ResNet-18

先定义 “BasicBlock 残差块”(含 2 层卷积 + 残差连接),再用这个块拼出 “初始层→4 组残差块→池化→全连接” 的完整模型,最后用随机数据验证模型能跑通 —— 整个过程就像 “先做乐高零件,再拼乐高模型,最后试玩模型”。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐