LeNet 风格网络代码实现:参考答案#
说明#
以下答案与正文 10 道题逐题对应。推导题给出主要步骤;编程和实验题给出参考程序和检查方法,并说明结果适用于哪些条件。
当一个批量中有 \(m\) 个样本时,各层的数据维度依次为 \(m\times1\times32\times32\rightarrow m\times6\times28\times28\rightarrow m\times6\times14\times14\rightarrow m\times16\times10\times10\rightarrow m\times16\times5\times5\rightarrow m\times400\rightarrow m\times120\rightarrow m\times84\rightarrow m\times10\)。参数量分别为 \(6(25+1)=156\)、\(16[6(25)+1]=2416\)、\(120(400+1)=48120\)、\(84(120+1)=10164\)、\(10(84+1)=850\),合计 61706;池化层没有可训练参数。经典 LeNet-5 使用平均型下采样和 tanh 函数,并用 120 个 \(5\times5\) 核得到 \(m\times120\times1\times1\);本节教学实现改用最大池化、ReLU 函数和
Linear(400, 120),所以应称为 LeNet 风格网络。指标 \(c,u,v\) 在两个输入中重复,表示对输入通道与卷积核的两个空间轴求和;\(m,q\) 保留为输出指标,所以结果维度为 \(m\times d_C^{[l]}\)。题中区域和卷积核的四个元素乘积之和为 4,再加偏置 2,唯一输出为 6。对每个空间位置执行同一运算即可组成卷积特征图。
填充 0 后边界窗口包含 \([-2,0]\),最大值变为 0,人工填充值错误地压过真实负响应。填充 \(-\infty\) 后最大值为 \(\max(-2,-\infty)=-2\),保持了有效输入的最大值。因此最大池化若允许填充,应使用不会成为最大值的 \(-\infty\);整数类型则需选择相应最小值或显式掩码。
从 \(32\) 出发,两次无填充 \(5\times5\) 卷积和两次 \(2\times2\) 池化得到 \(32\rightarrow28\rightarrow14\rightarrow10\rightarrow5\),故展平为 \(16(5)(5)=400\)。若输入为 28,则得到 \(28\rightarrow24\rightarrow12\rightarrow8\rightarrow4\),展平只有 \(16(4)(4)=256\);原线性层期望 400 个输入,矩阵内维不相等而报错。虚拟前向可避免手工写死该数值。
完整模型应让手写层像标准模块一样注册参数,并由虚拟输入计算展平维度:
from copy import deepcopy class LeNetLike(nn.Module): def __init__(self, input_shape=(1, 32, 32)): super().__init__() self.features = nn.Sequential(MyConv2D(1, 6, 5), nn.ReLU(), MyMaxPool2D(2), MyConv2D(6, 16, 5), nn.ReLU(), MyMaxPool2D(2)) with torch.no_grad(): flat = self.features(torch.zeros(1, *input_shape)).flatten(1).shape[1] self.classifier = nn.Sequential(nn.Linear(flat, 120), nn.ReLU(), nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, 10)) def forward(self, x): parameter = next(self.parameters()) if x.device != parameter.device or x.dtype != parameter.dtype: raise ValueError("输入与模型必须使用相同的设备和数据类型") return self.classifier(self.features(x).flatten(1)) def evaluate(model, loader, criterion, device): model.eval() loss_sum = 0.0 correct = 0 sample_count = 0 with torch.no_grad(): for data, target in loader: data, target = data.to(device), target.to(device) output = model(data) loss_sum += criterion(output, target).item() * data.size(0) correct += (output.argmax(1) == target).sum().item() sample_count += data.size(0) return loss_sum / sample_count, correct / sample_count def fit(model, train_loader, val_loader, criterion, optimizer, epochs, device): model.to(device) best_state = deepcopy(model.state_dict()) best_val_loss = float("inf") for _ in range(epochs): model.train() for data, target in train_loader: data, target = data.to(device), target.to(device) optimizer.zero_grad() loss = criterion(model(data), target) loss.backward() optimizer.step() val_loss, _ = evaluate(model, val_loader, criterion, device) if val_loss < best_val_loss: best_val_loss = val_loss best_state = deepcopy(model.state_dict()) model.load_state_dict(best_state) return model
fit只根据验证损失保存最佳参数,训练结束后再加载该参数;测试集应在模型和训练方案确定以后使用。虚拟输入自动得到flat,因而输入尺寸改变时不必手工猜测第一个线性层的输入数。按照 手算结果与可信实现对照 的原则,复制相同的 \(\bW^{[l]}\)、\(\bb^{[l]}\)、\(\bA^{[l-1]}\) 和池化参数,手写层与框架层输出应在允许误差内一致。卷积梯度对照使用标量 \(\sum(\bZ^{[l]}\odot\bG)\) 作为损失,其中 \(\bG\) 是固定的 \(\mathrm d\bZ^{[l]}\);分别比较 \(\mathrm d\bA^{[l-1]}\)、\(\mathrm d\bW^{[l]}\) 和 \(\mathrm d\bb^{[l]}\)。
dict(model.named_parameters())应包含手写层权重与偏置,优化器中的参数元素总数应等于模型可训练参数总数,否则即使能够自动求导,优化器也不会更新遗漏的参数。用钩子检查正文维度链和总参数 61706;批量 \(1\) 与 \(8\) 只改变第 0 轴;全负窗口复现第 3 题;模型与输入移动到同一设备后输出设备一致;一次交叉熵后向传播后,所有参数梯度都应与相应参数维度相同且为有限数;保存
state_dict、扰动参数并重新加载后,固定输入的预测应恢复为保存前结果。没有 CUDA 时应跳过 GPU 测试,而不是将其判为失败。三种实现先复制相同的权重,并检查前向结果与梯度是否一致,再使用相同的数据划分、随机种子、参数更新次数和硬件训练。循环版通常最慢,
unfold会增加保存滑动窗口所需的内存,框架卷积通常会调用经过优化的计算程序。结果表包括测试指标及其波动、训练时间、程序预先运行后测得的固定批量预测时间、61706 个参数和峰值内存。如果任务指标不同,应先检查三种实现是否执行了相同的计算,不能直接认为差异是运行速度造成的。三个模型使用相同的归一化方法、数据划分、随机种子和参数更新次数;在验证集上尝试超参数的次数相同,并让每组超参数使用相同的训练量。逻辑回归的模型容量最小,MLP 不直接利用图像的局部空间关系,LeNet 风格网络则利用卷积的局部连接与权重共享;通常后者更适合图像任务,但仍应根据本题的实际结果判断。报告测试准确率或损失、训练时间、固定批量预测时间、实际参数量和峰值内存;参数量接近并不表示乘加次数或预测时间也接近。
应比较所有“网络宽度×学习率”组合,并检查学习率的影响是否会随网络宽度而改变。每个组合使用相同的数据划分、随机种子和批次顺序。加宽通常会增加参数量、训练时间和内存,也可能需要不同的学习率;过大的学习率可能使某些宽度下的训练不稳定。报告任务指标的均值与标准差、训练秒数、固定批量预测时间、参数量及峰值内存;各宽度应尝试相同数量的学习率,不能在看到结果后给某个宽度增加更多尝试。