用 ResNet-18 分类 CIFAR-10#
学习目标与记号#
准确解释基本残差块、投影直连分支与 Batch Normalization;
根据公式和张量维度分析数据增强,并识别常见实现错误;
用
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 Normalization 和 ReLU。
本节实现适配 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 创建优化器,DataLoader、datasets 和 transforms 负责按批加载及预处理图像。后续代码块依赖这些导入。固定种子可以减少随机初始化等因素造成的差异,但不能保证不同硬件、并行加载方式和软件版本逐位复现。
接下来定义 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_loader 和 test_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)
上面的代码中,我们在训练数据集上应用了两种数据增强方法:
RandomCrop:随机裁剪,将图像随机裁剪为指定大小。RandomHorizontalFlip:随机水平翻转,将图像以 0.5 的概率水平翻转。
shuffle=True 只用于训练集,测试集保持固定次序。归一化常数必须来自训练数据或既定数据规范,不能用测试集重新估计。本例直接使用官方训练集和测试集,没有从训练集划出验证集;若要选择增强强度、学习率或训练轮数,应先建立独立验证划分。download=True 还意味着首次运行需要网络和可写的数据目录。
下面选择 CUDA 或 CPU,实例化未训练的 ResNet-18 并移动到该设备,再创建交叉熵损失、Adam 优化器和按轮更新的余弦退火调度器。输入是前面定义的模型结构与可训练参数,输出是随后传给 train 的 model、criterion、optimizer 和 scheduler;这段配置本身不会更新模型或打印指标。
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\) 是第 \(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 测试集上的预测效果。
核心推导与实现核验#
核心关系
推导路径。 残差分支通过两次卷积变换得到修正量 \(\bz^{[l+2]}\),直连分支在维度不变时把 \(\ba^{[l]}\) 原样送到相加处。若通道数或空间尺寸发生变化,则使用
其中 \(\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 统计量保持足够精度。混合精度下使用损失缩放,并对每层输出、梯度和学习率做有限性检查。
本节小结#
残差分支与直连分支相加前必须具有完全相同的维度。
CIFAR 版 ResNet 需适配小分辨率输入。
BN 模式和调度器调用频率属于模型实现的一部分。
综合练习#
程序题应固定随机种子、写出维度断言并报告运行环境;比较题还应固定数据划分、随机种子集合和训练预算。全部参考答案见 ResNet-18 与 CIFAR-10 实现答案。
层数计算。 CIFAR-10 ResNet-18 含一个初始卷积、四个阶段且每阶段有两个 BasicBlock,每个 BasicBlock 的残差分支含两个卷积,最后有一个线性层。按照从输入到输出的主要计算过程统计含参数层,说明名称中的 18 如何得到;解释直连分支中的投影卷积为什么通常不改变这个命名。
逐阶段维度。 输入为 \(m\times3\times32\times32\),初始卷积输出 64 通道且不下采样;四个阶段输出通道依次为 64、128、256、512,后三个阶段首块步幅为 2。写出每个阶段、全局平均池化和线性层的输出维度。
残差块参数量。 忽略卷积偏置但计入 BatchNorm 的可训练尺度与平移。计算 64 通道、步幅 1、无投影 BasicBlock 的参数量;再计算从 64 通道下采样到 128 通道、含 \(1\times1\) 投影与投影后 BatchNorm 的 BasicBlock 参数量。
余弦退火计算。 参见 正文中的余弦退火学习率说明。学习率按 \(\eta_t=\eta_{\min}+(\eta_0-\eta_{\min})[1+\cos(\pi t/T)]/2\) 变化。分别计算 \(t=0,T/2,T\) 的学习率,并说明普通
CosineAnnealingLR与带 warm restarts 的调度在完成一次计划退火后的行为有何不同。残差网络实现。 完成 BasicBlock 和 ResNet-18,实现投影直连分支的自动选择、阶段构建、全局平均池化和 10 类输出;在相加前明确检查残差分支与直连分支维度一致,并返回可选的逐阶段维度记录。
性质与库对照。 将自编 BasicBlock 与参考实现复制为相同权重并比较输出和梯度;把第二个 BatchNorm 的尺度与偏置以及残差分支卷积置为使残差输出为 0,验证激活函数之前为恒等或投影映射。
测试设计。 测试 ResNet-18 的层数、四阶段维度、不同批量大小、投影分支同形、零残差性质、参数总数与框架统计一致、一次后向梯度有限,以及训练/评估模式下 BatchNorm 运行量行为正确。
ResNet 与普通网络比较。 构造卷积层数和通道配置相同但没有残差连接的普通网络,与 ResNet-18 比较。固定 CIFAR-10 划分、随机种子集合、初始化规则和训练预算;报告任务性能、梯度范数、训练时间、固定批量预测时间、参数量及峰值内存。
学习率调度比较。 比较恒定学习率、阶梯衰减、普通余弦退火和带 warm restarts 的余弦调度,保存每一步真实学习率。固定数据划分、随机种子集合、模型、优化器和训练预算;报告任务性能、训练时间、固定批量预测时间、参数量及峰值内存。
深度与宽度比较。 比较窄版 ResNet-18、标准 ResNet-18 和参数量相近的更深残差网络。固定数据划分、随机种子集合、增强、训练预算和超参数选择预算;报告任务性能、训练时间、固定批量预测时间、参数量、乘加量及峰值内存,讨论深度与宽度的不同代价。