\[ \begin{align}\begin{aligned}\newcommand{\ba}{\boldsymbol{a}} \newcommand{\bb}{\boldsymbol{b}} \newcommand{\be}{\boldsymbol{e}} \newcommand{\bq}{\boldsymbol{q}} \newcommand{\bk}{\boldsymbol{k}} \newcommand{\bw}{\boldsymbol{w}} \newcommand{\bx}{\boldsymbol{x}} \newcommand{\by}{\boldsymbol{y}} \newcommand{\bz}{\boldsymbol{z}} \newcommand{\bd}{\boldsymbol{d}} \newcommand{\bv}{\boldsymbol{v}} \newcommand{\bs}{\boldsymbol{s}}\\\newcommand{\btheta}{\boldsymbol{\theta}} \newcommand{\bbeta}{\boldsymbol{\beta}} \newcommand{\bgamma}{\boldsymbol{\gamma}} \newcommand{\bsigma}{\boldsymbol{\sigma}} \newcommand{\md}{\mbox{d}} \newcommand{\bmu}{\boldsymbol{\mu}} \newcommand{\bone}{\boldsymbol{1}} \newcommand{\bzero}{\boldsymbol{0}} \newcommand{\bepsilon}{\boldsymbol{\epsilon}} \newcommand{\bphi}{\boldsymbol{\phi}} \newcommand{\bh}{\boldsymbol{h}} \newcommand{\bc}{\boldsymbol{c}} \newcommand{\br}{\boldsymbol{r}} \newcommand{\bQ}{\boldsymbol{Q}} \newcommand{\bK}{\boldsymbol{K}} \newcommand{\bV}{\boldsymbol{V}} \newcommand{\bSigma}{\boldsymbol{\Sigma}} \newcommand{\bg}{\boldsymbol{g}} \newcommand{\bxi}{\boldsymbol{\xi}} \newcommand{\bvarepsilon}{\boldsymbol{\varepsilon}} \newcommand{\bdelta}{\boldsymbol{\delta}} \newcommand{\bq}{\boldsymbol{q}} \newcommand{\bk}{\boldsymbol{k}} \newcommand{\bJ}{\boldsymbol{J}} \newcommand{\bp}{\boldsymbol{p}} \newcommand{\bi}{\boldsymbol{i}} \newcommand{\bo}{\boldsymbol{o}} \newcommand{\bE}{\boldsymbol{E}} \newcommand{\bH}{\boldsymbol{H}} \newcommand{\bL}{\boldsymbol{L}} \newcommand{\bu}{\boldsymbol{u}} \newcommand{\bLambda}{\boldsymbol{\Lambda}} \newcommand{\trans}{^{\rm\scriptsize T}} \newcommand{\var}{\mathrm{var}}\\\newcommand{\bA}{\boldsymbol{A}} \newcommand{\bB}{\boldsymbol{B}} \newcommand{\bC}{\boldsymbol{C}} \newcommand{\bD}{\boldsymbol{D}} \newcommand{\bG}{\boldsymbol{G}} \newcommand{\bI}{\boldsymbol{I}} \newcommand{\bM}{\boldsymbol{M}} \newcommand{\bP}{\boldsymbol{P}} \newcommand{\bS}{\boldsymbol{S}} \newcommand{\bU}{\boldsymbol{U}} \newcommand{\bW}{\boldsymbol{W}} \newcommand{\bX}{\boldsymbol{X}} \newcommand{\bY}{\boldsymbol{Y}} \newcommand{\bZ}{\boldsymbol{Z}} \newcommand{\cotp}{\textcolor[RGB]{48,209,88}{TP}} \newcommand{\cotn}{\textcolor[RGB]{100,210,255}{TN}} \newcommand{\cofp}{\textcolor[RGB]{94,92,230}{FP}} \newcommand{\cofn}{\textcolor[RGB]{191,90,242}{FN}}\\\newcommand{\numcotp}{\textcolor[RGB]{48,209,88}{50}} \newcommand{\numcotn}{\textcolor[RGB]{100,210,255}{30}} \newcommand{\numcofp}{\textcolor[RGB]{94,92,230}{10}} \newcommand{\numcofn}{\textcolor[RGB]{191,90,242}{10}} \DeclareMathOperator*{\argmin}{arg\,min}\end{aligned}\end{align} \]

用 ResNet-18 分类 CIFAR-10#

学习目标与记号#

  1. 准确解释基本残差块、投影直连分支与 Batch Normalization;

  2. 根据公式和张量维度分析数据增强,并识别常见实现错误;

  3. Python 实现或验证学习率调度;在其他条件相同的情况下,只改变一个需要研究的因素进行比较,并根据实验结果分析该因素可能带来的影响。

  本节沿用统一记号:普通小写字母表示标量,粗体小写字母表示向量,粗体大写字母表示矩阵或高阶张量;层编号写作上标 \([l]\)⁠,样本或时间编号写作下标;转置写作 \(\trans\)⁠。第 \(l\) 层特征图的通道数、高和宽分别记为 \(d_C^{[l]}\)⁠、\(d_H^{[l]}\)\(d_W^{[l]}\)⁠。除非另有说明,程序中的一批图像按照“样本数、通道数、高、宽”的顺序存放,维度写作 \(m\times d_C^{[l]}\times d_H^{[l]}\times d_W^{[l]}\)⁠。正文与练习中的程序都应同时检查数值结果和数组维度。本节练习的参考答案见 ResNet-18 与 CIFAR-10 实现答案⁠。

  残差块的结构动机见 ResNet⁠,恒等直连分支为何在导数中产生 \(\bI\) 的计算过程见 残差连接的梯度推导⁠;块内使用的归一化与非线性分别参见 Batch NormalizationReLU⁠。

  本节实现适配 CIFAR-10 分辨率的 ResNet-18。名称中的“18”按含参数层计数:17 个卷积层加 1 个全连接层,并非 18 个卷积层。训练流程加入数据增强、Batch Normalization 和学习率调度。

  普通网络加深后可能出现训练误差反而升高的退化现象,相近输入点处的梯度相关程度也可能随深度迅速衰减,形成 梯度破碎⁠。ResNet 通过直连路径保留原有表示,让残差分支学习需要补充的修正量,从而改善很深网络的训练条件。

  首先导入数组、绘图、PyTorch 模型与 CIFAR-10 数据加载所需的库,并把 NumPy、PyTorch 以及可用 CUDA 设备的随机种子设为 42。这段代码为后续模型、训练和数据代码准备名称与随机状态,本身不接收图像,也不会输出分类结果。

 1import numpy as np
 2import matplotlib.pyplot as plt
 3import torch
 4import torch.nn as nn
 5import torch.optim as optim
 6import torch.nn.functional as F
 7from torch.utils.data import DataLoader
 8from torchvision import datasets, transforms
 9
10# 设置随机数种子
11np.random.seed(42)
12torch.manual_seed(42)
13if torch.cuda.is_available():
14    torch.cuda.manual_seed_all(42)

  nn 用于定义可训练模块,F 提供 ReLU 和自适应平均池化等函数,optim 创建优化器,DataLoaderdatasetstransforms 负责按批加载及预处理图像。后续代码块依赖这些导入。固定种子可以减少随机初始化等因素造成的差异,但不能保证不同硬件、并行加载方式和软件版本逐位复现。

  接下来定义 ResNet 的基本模块,即残差块。这里的残差分支包含两个 \(3\times3\) 卷积层,每个卷积层后都接一个 Batch Normalization 层;第一个 Batch Normalization 层后使用 ReLU 函数,第二个 Batch Normalization 层的结果先与直连分支相加,再使用 ReLU 函数。

  BasicBlock 的构造函数接收输入通道数、输出通道数和第一层卷积的步幅;前向输入 x 为 NCHW 特征张量。步幅为 1 且通道数不变时,输出与输入同形;否则残差分支和直连分支会同步改变尺寸,最终输出的通道数为 out_channels高、宽按给定步幅缩小。

 1class BasicBlock(nn.Module):
 2    # 定义扩展因子,用于调整输出通道数
 3    expansion = 1
 4
 5    def __init__(self, in_channels, out_channels, stride=1):
 6        """
 7        初始化 BasicBlock 模块。
 8
 9        参数:
10        - in_channels: 输入特征图的通道数
11        - out_channels: 输出特征图的通道数
12        - stride: 卷积步长,默认为 1
13        """
14        super(BasicBlock, self).__init__()
15
16        # 第一个卷积层:3×3 卷积,用于提取特征
17        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
18        # 第一个批归一化层:对卷积后的输出进行归一化
19        self.bn1 = nn.BatchNorm2d(out_channels)
20
21        # 第二个卷积层:3×3 卷积,用于进一步提取特征
22        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False)
23        # 第二个批归一化层:对卷积后的输出进行归一化
24        self.bn2 = nn.BatchNorm2d(out_channels)
25
26        # 定义直连分支
27        self.shortcut = nn.Sequential()  # 默认直连分支是恒等映射
28
29        # 如果步长不为 1 或输入输出通道数不同,则需要调整直连分支
30        if stride != 1 or in_channels != self.expansion * out_channels:
31            self.shortcut = nn.Sequential(
32                # 1×1 卷积,用于调整通道数和空间尺寸
33                nn.Conv2d(in_channels, self.expansion * out_channels, kernel_size=1, stride=stride, bias=False),
34                # 批归一化层
35                nn.BatchNorm2d(self.expansion * out_channels)
36            )
37
38    def forward(self, x):
39        # 第一层卷积 + 批归一化 + ReLU 激活
40        out = F.relu(self.bn1(self.conv1(x)))
41
42        # 第二层卷积 + 批归一化
43        out = self.bn2(self.conv2(out))
44
45        # 将直连分支与残差分支的输出相加
46        out = out + self.shortcut(x)
47
48        # 对相加后的结果应用 ReLU 激活
49        out = F.relu(out)
50
51        return out

  expansion 表示残差块最终输出通道数相对于该阶段基准通道数的倍数。BasicBlock 取 1;ResNet-50 使用的 bottleneck 块通常取 4。它并不表示输出通道必然等于输入通道,跨阶段时仍会由投影分支调整维度。

  代码中的 self.shortcut 表示直连分支。输入输出维度相同时,空的 nn.Sequential() 不改变输入,因此实现恒等映射;步幅不为 1 或通道数不同时,直连分支使用 \(1\times1\) 卷积与 Batch Normalization 调整空间尺寸和通道数。相加前应明确检查直连分支与残差分支的维度完全一致。

为什么直连路径上不使用 ReLU 函数

  直连路径跨过残差分支中的两次卷积变换,并在第二次卷积和 Batch Normalization 之后参与相加。直连分支本身不额外使用 ReLU 函数,因此正值和负值都可以直接传到相加处;如果先对直连分支使用 ReLU 函数,负值会被截断,直连路径保留原有信息和提供修正的能力就会受到限制。这里采用经典的后激活结构,在两条分支相加后再使用 ReLU 函数。

  接下来定义完整 ResNet构造函数接收残差块类型、四个阶段各自的块数以及类别数;前向输入是一批三通道 CIFAR-10 图像,预期输出每张图像的 num_classes 个线性类别得分。

 1class ResNet(nn.Module):
 2    def __init__(self, block, num_blocks, num_classes=10):
 3        """
 4        初始化 ResNet 模型。
 5
 6        参数:
 7        - block: 残差块类型(如 BasicBlock)
 8        - num_blocks: 每个阶段的残差块数量(如 [2, 2, 2, 2] 对应 ResNet-18)
 9        - num_classes: 分类任务的类别数
10        """
11        super(ResNet, self).__init__()
12
13        # 初始通道数
14        self.in_channels = 64
15
16        # 初始卷积层:3×3 卷积,用于提取初步特征
17        self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False)
18        # 批归一化层:对卷积后的输出进行归一化
19        self.bn1 = nn.BatchNorm2d(64)
20
21        # 构建 4 个阶段的残差块堆叠
22        self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1)  # 第一阶段
23        self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2)  # 第二阶段
24        self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2)  # 第三阶段
25        self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2)  # 第四阶段
26
27        # 全连接层:将特征向量映射到类别数量
28        self.linear = nn.Linear(512 * block.expansion, num_classes)
29
30    def _make_layer(self, block, out_channels, num_blocks, stride):
31        """
32        构建一个阶段的残差块堆叠。
33
34        参数:
35        - block: 残差块类型(如 BasicBlock)
36        - out_channels: 输出通道数
37        - num_blocks: 残差块数量
38        - stride: 第一个残差块的步长
39
40        返回:
41        - nn.Sequential: 一个阶段的残差块堆叠
42        """
43        # 定义每个残差块的步长列表
44        # 第一个残差块使用指定的 stride,后续残差块使用 stride=1
45        strides = [stride] + [1] * (num_blocks - 1)
46        layers = []
47
48        # 构建每个残差块
49        for stride in strides:
50            # 添加一个残差块
51            layers.append(block(self.in_channels, out_channels, stride))
52            # 更新输入通道数
53            self.in_channels = out_channels * block.expansion
54
55        # 将残差块堆叠成一个序列
56        return nn.Sequential(*layers)
57
58    def forward(self, x):
59        """
60        前向传播函数。
61
62        参数:
63        - x: 输入图像
64
65        返回:
66        - out: 分类结果
67        """
68        # 初始卷积层 + 批归一化 + ReLU 激活
69        out = F.relu(self.bn1(self.conv1(x)))
70
71        # 通过 4 个阶段的残差块堆叠
72        out = self.layer1(out)  # 第一阶段
73        out = self.layer2(out)  # 第二阶段
74        out = self.layer3(out)  # 第三阶段
75        out = self.layer4(out)  # 第四阶段
76
77        # 全局平均池化:将特征图的空间维度压缩为 1×1
78        out = F.adaptive_avg_pool2d(out, output_size=1)
79
80        # 展平为向量
81        out = out.view(out.size(0), -1)
82
83        # 全连接层:将特征向量映射到类别数量
84        out = self.linear(out)
85
86        return out

  _make_layer 只让每个阶段的第一个块使用指定步幅,后续块保持空间大小;同时更新 self.in_channels使下一个块取得正确输入通道数。对 \(m\times3\times32\times32\) 输入,四阶段输出依次为 \(m\times64\times32\times32\)⁠、\(m\times128\times16\times16\)⁠、\(m\times256\times8\times8\)\(m\times512\times4\times4\)⁠。全局平均池化把空间轴压到 \(1\times1\)⁠,线性层最终返回 \(m\times10\)⁠;该实现专门适配 CIFAR 的小图像,没有照搬 ImageNet 版本的大首层卷积和最大池化。

  最后定义一个简便的 ResNet18 工厂函数。它没有运行时输入,调用后会返回一个由 BasicBlock 构成、四阶段块数为 [2, 2, 2, 2]默认输出 10 类的未训练模型。

1def ResNet18():
2    return ResNet(BasicBlock, [2, 2, 2, 2])

  这里的四个 2 表示每个阶段堆叠两个基本残差块;函数只创建并随机初始化模型,不会自动移动设备、下载数据或开始训练。若任务类别数不是 10,需要扩展函数接口或直接调用 ResNet 并传入 num_classes

  接下来定义训练和测试函数。两者都接收模型、按批返回 (data, target) 的加载器、损失函数和设备;训练函数还接收优化器、训练轮数及可选调度器,返回每轮每隔 100 批记录一次的损失列表。测试函数遍历完整测试集,预期打印平均交叉熵和准确率,但不返回预测数组。

 1# 训练函数
 2def train(
 3    model, train_loader, criterion, optimizer,
 4    num_epochs, device, scheduler=None,
 5):
 6    model.train()
 7    loss_list = []
 8    for epoch in range(num_epochs):
 9        loss_list.append([])
10        for batch_idx, (data, target) in enumerate(train_loader):
11            data, target = data.to(device), target.to(device)
12            optimizer.zero_grad()
13            output = model(data)
14            loss = criterion(output, target)
15            loss.backward()
16            optimizer.step()
17            if batch_idx % 100 == 0:
18                print(f'Epoch {epoch}, Batch {batch_idx}, Loss: {loss.item()}')
19                loss_list[-1].append(loss.item())
20        if scheduler is not None:
21            scheduler.step()
22    return loss_list
23
24# 测试函数
25def test(model, test_loader, criterion, device):
26    model.eval()
27    test_loss = 0
28    correct = 0
29    with torch.no_grad():
30        for data, target in test_loader:
31            data, target = data.to(device), target.to(device)
32            output = model(data)
33            test_loss += criterion(output, target).item() * data.size(0)
34            pred = output.argmax(dim=1, keepdim=True)
35            correct += pred.eq(target.view_as(pred)).sum().item()
36    test_loss /= len(test_loader.dataset)
37    accuracy = 100. * correct / len(test_loader.dataset)
38    print(f'Test Loss: {test_loss:.4f}, Accuracy: {accuracy:.2f}%')

  train 在每批数据上依次移动设备、清空旧梯度、前向计算、反向传播和更新参数,并在每轮结束后把学习率调度向前推进一次。返回的 loss_list 是稀疏抽样的批损失,并不是逐轮平均值。test 切换到评估模式并关闭梯度记录,使 Batch Normalization 使用训练阶段积累的统计量,同时避免不必要的梯度存储。训练函数只在进入时调用一次 model.train()如果在训练期间插入验证并调用 model.eval()恢复训练前还要重新调用 model.train()

  下面对 CIFAR-10 进行预处理并建立数据加载器。代码以 ./data 为存储目录,训练变换随机裁剪和翻转图像,测试变换保持确定性,两者都转为张量并按通道标准化。输出的 train_loadertest_loader 每次提供最多 128 张 \(3\times32\times32\) 图像及其一维类别标签。

 1# 数据预处理
 2transform_train = transforms.Compose([
 3    transforms.RandomCrop(32, padding=4),
 4    transforms.RandomHorizontalFlip(),
 5    transforms.ToTensor(),
 6    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
 7])
 8
 9transform_test = transforms.Compose([
10    transforms.ToTensor(),
11    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
12])
13
14# 加载CIFAR-10数据集
15train_dataset = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train)
16test_dataset = datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test)
17
18train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2)
19test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, num_workers=2)

  上面的代码中,我们在训练数据集上应用了两种数据增强方法:

  1. RandomCrop随机裁剪,将图像随机裁剪为指定大小。

  2. RandomHorizontalFlip随机水平翻转,将图像以 0.5 的概率水平翻转。

  shuffle=True 只用于训练集,测试集保持固定次序。归一化常数必须来自训练数据或既定数据规范,不能用测试集重新估计。本例直接使用官方训练集和测试集,没有从训练集划出验证集;若要选择增强强度、学习率或训练轮数,应先建立独立验证划分。download=True 还意味着首次运行需要网络和可写的数据目录。

  下面选择 CUDA 或 CPU,实例化未训练的 ResNet-18 并移动到该设备,再创建交叉熵损失、Adam 优化器和按轮更新的余弦退火调度器。输入是前面定义的模型结构与可训练参数,输出是随后传给 trainmodelcriterionoptimizerscheduler这段配置本身不会更新模型或打印指标。

1# 实例化 ResNet-18 模型
2device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
3model = ResNet18().to(device)
4
5# 定义损失函数和优化器
6criterion = nn.CrossEntropyLoss()
7optimizer = optim.Adam(model.parameters(), lr=0.001)
8# 学习率调度器
9scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)

  CrossEntropyLoss 直接接收模型的 10 维线性输出和整数类别标签,模型末尾不需要另加 Softmax。Adam 的初始学习率为 0.001;调度器保存并修改优化器中的学习率,但只有调用 scheduler.step() 后曲线才会推进。设备选择只检测 NVIDIA CUDA,不会自动选择 Apple MPS 等其他后端。

  这里使用 CosineAnnealingLR\(T_{\max}\)step 调用内把学习率沿余弦曲线从初值降到 \(\eta_{\min}\)⁠。这个调度器本身不会自动升回初始学习率;若需要周期性地重新提高学习率,应使用带 warm restarts 的调度器。学习率可以写为

\[\eta_t =\eta_{\min} +\frac{1}{2}(\eta_0-\eta_{\min}) \left[1+\cos\!\left(\pi\frac{T_{\mathrm{cur}}}{T_{\max}}\right)\right],\]

其中 \(\eta_t\) 是第 \(t\) 次调度更新后使用的学习率,\(\eta_0\) 是初始学习率,\(\eta_{\min}\) 是最小学习率,\(T_{\mathrm{cur}}\) 是已经执行的调度更新次数,\(T_{\max}\) 是计划完成退火所需的更新次数。

  本节在每轮训练结束后调用一次 scheduler.step()并训练 100 轮,因此设置 \(T_{\max}=100\)⁠,使最后一轮附近的学习率下降到 \(\eta_{\min}\)⁠。如果仍设置 \(T_{\max}=200\) 而只训练 100 轮,程序只会走完余弦曲线的前半段,不能称为已经完成一次退火。若改为每个小批次后调用 step\(T_{\max}\) 也必须按小批次更新次数重新设置。

  最后把模型、训练加载器、损失、优化器、100 个训练轮次、设备和调度器传给 train运行期间预期每隔 100 个小批量打印一次损失,并返回 loss_list该调用会真正修改模型参数,计算量远大于前面的结构定义代码。

1# 训练模型
2loss_list = train(
3    model, train_loader, criterion, optimizer,
4    100, device, scheduler=scheduler,
5)

  一次运行的部分训练输出如下;省略号表示中间日志未全部列出,每行损失只对应当时的一个小批量,而不是整轮平均值:

Epoch 0, Batch 0, Loss: 2.3767738342285156
Epoch 0, Batch 100, Loss: 1.5866512060165405
Epoch 0, Batch 200, Loss: 1.4910191297531128
Epoch 0, Batch 300, Loss: 1.209372878074646
Epoch 1, Batch 0, Loss: 1.1253697872161865
Epoch 1, Batch 100, Loss: 1.1158658266067505
Epoch 1, Batch 200, Loss: 0.740522027015686
Epoch 1, Batch 300, Loss: 1.1217533349990845
Epoch 2, Batch 0, Loss: 0.8137905597686768
Epoch 2, Batch 100, Loss: 0.6262770891189575
Epoch 2, Batch 200, Loss: 0.8794751167297363
...
Epoch 99, Batch 0, Loss: 0.0063345907256007195
Epoch 99, Batch 100, Loss: 0.0020089359022676945
Epoch 99, Batch 200, Loss: 0.0005474633071571589
Epoch 99, Batch 300, Loss: 0.029195604845881462

  抽样损失总体下降说明模型在拟合训练数据,但局部波动是正常的,末轮批损失很小也不能单独证明模型已在新数据上收敛。判断训练轮数或选择检查点应查看独立验证集的整集指标。下面把训练后的模型、测试加载器、损失函数和设备传给 test预期打印整个测试集的平均损失与准确率。

1test(model, test_loader, criterion, device)

  一次运行的测试输出如下,其中 Test Loss 是按全部测试样本平均的交叉熵,Accuracy 是预测类别等于真实标签的样本比例:

Test Loss: 0.4712, Accuracy: 93.61%

  具体数值会随随机性、训练轮数和实现环境改变。完整实验应从训练集再划出验证集,用于选择调度、权重衰减和最佳检查点;测试集只用于冻结方案后的最终评估。

Shiny 交互演示:残差连接

  交互页面的“残差连接”标签页会分别显示残差分支、恒等或投影直连分支以及两条分支逐元素相加后的结果。请先核对两条分支的维度是否相同,再观察直连分支怎样保留原有表示。页面使用随机生成的小型数组说明计算结构,不代表 ResNet-18 在 CIFAR-10 测试集上的预测效果。

点击打开“多通道卷积、理论感受野与残差连接”交互演示

核心推导与实现核验#

核心关系

\[\begin{split}\begin{aligned} \bz^{[l+1]}&=\bW^{[l+1]}\cdot\ba^{[l]}+\bb^{[l+1]},\\ \ba^{[l+1]}&=\sigma^{[l+1]}\!\left(\bz^{[l+1]}\right),\\ \bz^{[l+2]}&=\bW^{[l+2]}\cdot\ba^{[l+1]}+\bb^{[l+2]},\\ \ba^{[l+2]}&=\sigma^{[l+2]}\!\left(\bz^{[l+2]}+\ba^{[l]}\right). \end{aligned}\end{split}\]

  推导路径。 残差分支通过两次卷积变换得到修正量 \(\bz^{[l+2]}\)⁠,直连分支在维度不变时把 \(\ba^{[l]}\) 原样送到相加处。若通道数或空间尺寸发生变化,则使用

\[\ba^{[l+2]} =\sigma^{[l+2]}\!\left( \bz^{[l+2]}+\bW_s^{[l+2]}\cdot\ba^{[l]} \right),\]

其中 \(\bW_s^{[l+2]}\) 在本节代码中由带有适当步幅的 \(1\times1\) 卷积实现。

关键条件

  对激活函数之前的相加结果求导时,恒等直连分支会产生 \(\bI\) 这一直接项,完整计算见 残差连接的梯度推导⁠。连续残差块因此提供较短的梯度传播路径,但总导数仍受残差分支、激活函数和 Batch Normalization 影响,不能保证梯度绝不消失或爆炸。

数据规模

  代码把一批 CIFAR 图像按“样本数、通道数、高、宽”的顺序存放,即输入维度为 \(m\times3\times32\times32\)⁠。因此,CIFAR 版 ResNet 常用 \(3\times3\) 首卷积,并省略过早的最大池化。每次下采样都会把 \(d_H^{[l]}\)\(d_W^{[l]}\) 减半,残差分支与直连分支必须同步变化,才能在末端相加。

常见误区

  调度器 step 放错批次/轮次会改变实际曲线;评估时不切换 BN 模式会污染运行统计量。

动手检查

  用一批随机生成的输入记录逐层 \(m\times d_C^{[l]}\times d_H^{[l]}\times d_W^{[l]}\) 维度、参数量以及一次后向传播得到的梯度;残差块还要检查两条分支维度相同,并验证残差分支输出为 0 时的恒等映射。

数值稳定性与规模

  训练损失直接接收输出层的线性运算结果;BatchNorm 统计量保持足够精度。混合精度下使用损失缩放,并对每层输出、梯度和学习率做有限性检查。

本节小结#

  1. 残差分支与直连分支相加前必须具有完全相同的维度。

  2. CIFAR 版 ResNet 需适配小分辨率输入。

  3. BN 模式和调度器调用频率属于模型实现的一部分。

综合练习#

  程序题应固定随机种子、写出维度断言并报告运行环境;比较题还应固定数据划分、随机种子集合和训练预算。全部参考答案见 ResNet-18 与 CIFAR-10 实现答案⁠。

  1. 层数计算。 CIFAR-10 ResNet-18 含一个初始卷积、四个阶段且每阶段有两个 BasicBlock,每个 BasicBlock 的残差分支含两个卷积,最后有一个线性层。按照从输入到输出的主要计算过程统计含参数层,说明名称中的 18 如何得到;解释直连分支中的投影卷积为什么通常不改变这个命名。

  2. 逐阶段维度。 输入为 \(m\times3\times32\times32\)⁠,初始卷积输出 64 通道且不下采样;四个阶段输出通道依次为 64、128、256、512,后三个阶段首块步幅为 2。写出每个阶段、全局平均池化和线性层的输出维度。

  3. 残差块参数量。 忽略卷积偏置但计入 BatchNorm 的可训练尺度与平移。计算 64 通道、步幅 1、无投影 BasicBlock 的参数量;再计算从 64 通道下采样到 128 通道、含 \(1\times1\) 投影与投影后 BatchNorm 的 BasicBlock 参数量。

  4. 余弦退火计算。 参见 正文中的余弦退火学习率说明⁠。学习率按 \(\eta_t=\eta_{\min}+(\eta_0-\eta_{\min})[1+\cos(\pi t/T)]/2\) 变化。分别计算 \(t=0,T/2,T\) 的学习率,并说明普通 CosineAnnealingLR 与带 warm restarts 的调度在完成一次计划退火后的行为有何不同。

  5. 残差网络实现。 完成 BasicBlock 和 ResNet-18,实现投影直连分支的自动选择、阶段构建、全局平均池化和 10 类输出;在相加前明确检查残差分支与直连分支维度一致,并返回可选的逐阶段维度记录。

  6. 性质与库对照。 将自编 BasicBlock 与参考实现复制为相同权重并比较输出和梯度;把第二个 BatchNorm 的尺度与偏置以及残差分支卷积置为使残差输出为 0,验证激活函数之前为恒等或投影映射。

  7. 测试设计。 测试 ResNet-18 的层数、四阶段维度、不同批量大小、投影分支同形、零残差性质、参数总数与框架统计一致、一次后向梯度有限,以及训练/评估模式下 BatchNorm 运行量行为正确。

  8. ResNet 与普通网络比较。 构造卷积层数和通道配置相同但没有残差连接的普通网络,与 ResNet-18 比较。固定 CIFAR-10 划分、随机种子集合、初始化规则和训练预算;报告任务性能、梯度范数、训练时间、固定批量预测时间、参数量及峰值内存。

  9. 学习率调度比较。 比较恒定学习率、阶梯衰减、普通余弦退火和带 warm restarts 的余弦调度,保存每一步真实学习率。固定数据划分、随机种子集合、模型、优化器和训练预算;报告任务性能、训练时间、固定批量预测时间、参数量及峰值内存。

  10. 深度与宽度比较。 比较窄版 ResNet-18、标准 ResNet-18 和参数量相近的更深残差网络。固定数据划分、随机种子集合、增强、训练预算和超参数选择预算;报告任务性能、训练时间、固定批量预测时间、参数量、乘加量及峰值内存,讨论深度与宽度的不同代价。