\[ \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} \]

LeNet 风格网络的简单实现#

学习目标与记号#

  1. 准确解释模块接口、卷积实现与池化实现;

  2. 根据公式和张量维度分析经典 LeNet-5 与本节的 LeNet 风格网络,并识别常见实现错误;

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

  本节沿用统一记号:普通小写字母表示标量,粗体小写字母表示向量,粗体大写字母表示矩阵或高阶张量;层编号写作上标 \([l]\)⁠,样本编号写作下标。批量大小记为 \(m\)⁠,程序中的图像张量采用 NCHW 排列,即四个轴依次表示批量、通道、高度和宽度。正文与练习中的程序都应同时检查数值结果和数组维度。本节练习的参考答案见 LeNet 风格网络代码实现答案⁠。

  本节代码以 经典 LeNet-5 结构 为基础,手写层分别实现 多通道卷积池化⁠;梯度来源见 卷积层后向传播⁠。由于激活函数、池化方式和部分模块的写法与经典模型不同,下面始终将代码称为“LeNet 风格网络”。

  本节借助 PyTorch 的自动微分,手动实现二维互相关与最大池化,再搭建一个 LeNet 风格网络完成 MNIST 分类。循环实现便于理解,但速度远低于框架内置算子,不适合实际训练。

  首先导入张量计算、神经网络、优化、绘图和 MNIST 数据加载所需的库,并把 PyTorch 及可用 CUDA 设备的随机种子设为 42。该代码是后续各段的运行准备,本身没有数据输入,也不会输出训练结果;固定种子只能减少本例的随机差异,不能保证不同硬件和软件版本逐位一致。

 1# 导入相关的库
 2import torch
 3import torch.nn as nn
 4import torch.optim as optim
 5import torch.nn.functional as F
 6import matplotlib.pyplot as plt
 7from torch.utils.data import DataLoader
 8from torchvision import datasets, transforms
 9
10# 固定随机种子,使本节的训练记录便于复现
11SEED = 42
12torch.manual_seed(SEED)
13if torch.cuda.is_available():
14    torch.cuda.manual_seed_all(SEED)

  nnF 分别提供可注册的网络模块与函数式运算,optim 创建优化器,DataLoader 按小批量提供数据,datasetstransforms 负责下载及预处理 MNIST。后面的代码依赖这些名称和 SEED因此单独运行某一代码块前也要先执行本段。

卷积层的实现#

  先实现一个二维互相关层。对第 \(l\) 层,输入、卷积核、偏置和输出线性运算结果的维度分别为

\[\begin{split}\begin{aligned} \bA^{[l-1]} &\in\mathbb{R}^{m\times d_C^{[l-1]}\times d_H^{[l-1]}\times d_W^{[l-1]}},\\ \bW^{[l]} &\in\mathbb{R}^{d_C^{[l]}\times d_C^{[l-1]}\times f_1^{[l]}\times f_2^{[l]}},\\ \bb^{[l]} &\in\mathbb{R}^{d_C^{[l]}\times1\times1},\\ \bZ^{[l]} &\in\mathbb{R}^{m\times d_C^{[l]}\times d_H^{[l]}\times d_W^{[l]}}. \end{aligned}\end{split}\]

  下面的教学代码只接收一个整数 kernel_sizestridepadding因此两个空间方向使用相同的设置,并且没有实现空洞卷积。此时 \(f_1^{[l]}=f_2^{[l]}=f^{[l]}\)⁠。需要两个方向采用不同设置或使用空洞率时,应把相应参数扩展为二元组,并使用通用输出尺寸公式。

 1class MyConv2D(nn.Module):
 2    def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0):
 3        super(MyConv2D, self).__init__()
 4        self.in_channels = in_channels
 5        self.out_channels = out_channels
 6        self.kernel_size = kernel_size
 7        self.stride = stride
 8        self.padding = padding
 9
10        # nn.Parameter 才会被注册、随 model.to(device) 移动并交给优化器
11        self.weight = nn.Parameter(torch.empty(
12            out_channels, in_channels, kernel_size, kernel_size
13        ))
14        self.bias = nn.Parameter(torch.zeros(out_channels))
15        nn.init.kaiming_uniform_(self.weight, nonlinearity='relu')
16
17    def forward(self, x):
18        # 手动实现卷积操作
19        batch_size, _, height, width = x.shape
20
21        # 计算输出的高度和宽度
22        out_height = (height + 2 * self.padding - self.kernel_size) // self.stride + 1
23        out_width = (width + 2 * self.padding - self.kernel_size) // self.stride + 1
24
25        # 初始化输出张量
26        out = x.new_zeros((batch_size, self.out_channels, out_height, out_width))
27
28        # 对输入张量进行填充
29        if self.padding > 0:
30            x = torch.nn.functional.pad(x, (self.padding, self.padding, self.padding, self.padding))
31
32        # 手动实现卷积运算
33        for i in range(out_height):
34            for j in range(out_width):
35                region = x[:, :, i*self.stride:i*self.stride+self.kernel_size, j*self.stride:j*self.stride+self.kernel_size]
36                out[:, :, i, j] = torch.einsum(
37                    'mcuv,qcuv->mq', region, self.weight
38                ) + self.bias
39
40        return out

  代码中的 in_channelsout_channelskernel_size 分别对应 \(d_C^{[l-1]}\)⁠、\(d_C^{[l]}\)\(f^{[l]}\)⁠。数学上把偏置写成 \(d_C^{[l]}\times1\times1\)⁠,以强调每个输出通道的同一个偏置会用于所有空间位置;PyTorch 则将其存成维度为 (out_channels,) 的向量,并在批量和两个空间方向上自动广播。必须使用 nn.Parameter 包装可训练张量;仅设置 requires_grad=True 虽然能计算梯度,却不会自动把张量注册到 model.parameters()优化器也就不会更新它。

  接下来,forward 函数是 MyConv2D 类的前向传播函数。在这个函数中,我们手动实现了卷积操作。首先,我们获取输入张量的维度信息,然后计算输出张量的高度和宽度。接着,我们初始化输出张量 out维度为 (batch_size, out_channels, out_height, out_width)如果填充参数 padding 大于 0,我们对输入张量 x 进行填充。然后,我们手动实现卷积运算。我们遍历输出张量的每一个像素,获取对应的感受野区域,然后通过 torch.einsum 函数计算卷积运算的结果。最后,我们将计算得到的结果加上偏置,得到最终的输出张量 out

einsum:爱因斯坦求和约定

  爱因斯坦求和(Einstein summation convention)是物理学家阿尔伯特·爱因斯坦引入的一种简洁的符号表示法,常用于描述张量运算。它的基本规则是:当在一个数学表达式中某个指标,即上标或下标,在同一个项中出现两次时,默认该项对这个指标进行求和,不再需要显式地写出求和符号。这种方法在相对论和张量分析中非常有用,因为它大大简化了复杂方程的表示。

  例如,对矩阵 \(\bA\)\(\bB\)⁠,矩阵乘法 \(\bC=\bA\cdot\bB\) 的分量写作

\[C_{ik}=\sum_j A_{ij}B_{jk}.\]

这里 \(j\) 在乘积中重复出现,因此对 \(j\) 求和;\(i,k\) 是结果保留的自由指标。

  在 torch 中,可以通过 torch.einsum 实现这种求和。上述代码使用 torch.einsum('mcuv,qcuv->mq', region, self.weight)\(m\) 表示样本,\(c\) 表示输入通道,\(u,v\) 表示卷积核位置,\(q\) 表示输出通道。\(c,u,v\) 在两个输入中重复但没有出现在输出中,因此程序会沿这三个方向求和,只保留 \(m,q\)⁠。这个表达式等价于

\[\operatorname{out}_{mq} =\sum_{c,u,v} \operatorname{region}_{m,c,u,v}W_{q,c,u,v}^{[l]}.\]

这种写法同时计算一个输出位置处所有样本和输出通道的结果。想进一步了解 torch.einsum 的用法,可以参考 torch 官方文档:torch.einsum⁠。

  注意到,我们在这里只写了前向传播的代码,没有写后向传播的代码。这是因为 torch 提供了自动求导的功能,我们只需要定义前向传播的计算过程,然后通过调用 backward 函数,torch 就会自动计算梯度并进行后向传播。

池化层的实现#

  接下来手动实现最大池化层。构造函数接收窗口大小、步幅和填充量;若省略步幅,就令步幅等于窗口大小。前向输入 x 是 NCHW 张量,输出保持批量数和通道数不变,只按窗口、步幅与填充设置改变高和宽,并在每个窗口保留最大值。

 1class MyMaxPool2D(nn.Module):
 2    def __init__(self, kernel_size, stride=None, padding=0):
 3        super().__init__()
 4        self.kernel_size = kernel_size
 5        self.stride = stride if stride is not None else kernel_size
 6        self.padding = padding
 7
 8    def forward(self, x):
 9        batch_size, channels, height, width = x.shape
10
11        # 计算输出的高度和宽度
12        out_height = (height + 2 * self.padding - self.kernel_size) // self.stride + 1
13        out_width = (width + 2 * self.padding - self.kernel_size) // self.stride + 1
14
15        # 初始化输出张量
16        out = x.new_zeros((batch_size, channels, out_height, out_width))
17
18        # 对输入张量进行填充
19        if self.padding > 0:
20            # 最大池化的零填充会改变全负窗口,因此使用负无穷
21            x = F.pad(
22                x,
23                (self.padding, self.padding, self.padding, self.padding),
24                value=float('-inf'),
25            )
26
27        # 手动实现最大池化操作
28        for i in range(out_height):
29            for j in range(out_width):
30                region = x[:, :, i*self.stride:i*self.stride+self.kernel_size, j*self.stride:j*self.stride+self.kernel_size]
31                out[:, :, i, j] = region.max(dim=-1)[0].max(dim=-1)[0]
32
33        return out

  MyMaxPool2D 不含可训练参数。填充时使用 \(-\infty\) 而不是 0,是因为全负窗口若补 0,最大值会被错误地改成 0。

  forward 逐个输出位置截取池化窗口,再沿窗口的高、宽两个轴取最大值。和卷积示例一样,输出通过 x.new_zeros 创建,以继承输入的设备和数据类型。

LeNet 风格网络的搭建#

  接下来使用手动实现的卷积层和池化层,搭建一个包含两个卷积层、两个池化层和三个线性层的 LeNet 风格网络。

经典 LeNet-5 与本节教学实现

  课件中的经典 LeNet-5 使用 \(32\times32\) 灰度图像作为输入,采用平均型下采样和 tanh 函数,并用 120 个 \(5\times5\) 卷积核把 \(16\times5\times5\) 特征变为 \(120\times1\times1\)⁠。本节教学实现改用最大池化和 ReLU 函数,并用 Linear(16*5*5, 120) 完成相应映射。对于固定的 \(5\times5\) 输入,后者与 120 个覆盖整个输入的 \(5\times5\) 卷积核具有相同的连接范围和参数量,但模块的写法不同。因此,下面的代码是“LeNet 风格网络”,不是对历史模型每个细节的原样复现。

  下面的 MyLeNetLike 把前面定义的两个自编层组合成分类器。它的 forward 接收 \(m\times1\times32\times32\) 的灰度图像批量,预期返回 \(m\times10\) 的线性类别得分;类定义本身只创建网络结构,不会立即训练或打印预测。

 1class MyLeNetLike(nn.Module):
 2    def __init__(self):
 3        super().__init__()
 4        self.conv1 = MyConv2D(in_channels=1, out_channels=6, kernel_size=5, stride=1, padding=0)
 5        self.conv2 = MyConv2D(in_channels=6, out_channels=16, kernel_size=5, stride=1, padding=0)
 6        self.pool = MyMaxPool2D(kernel_size=2, stride=2)
 7        self.fc1 = nn.Linear(16*5*5, 120)
 8        self.fc2 = nn.Linear(120, 84)
 9        self.fc3 = nn.Linear(84, 10)
10
11    def forward(self, x):
12        x = self.pool(F.relu(self.conv1(x)))
13        x = self.pool(F.relu(self.conv2(x)))
14        x = x.view(-1, 16*5*5)
15        x = F.relu(self.fc1(x))
16        x = F.relu(self.fc2(x))
17        x = self.fc3(x)
18        return x

  上述 MyLeNetLike 类继承自 nn.Module它的组织方式与全连接网络相同,但特征提取部分换成了自行实现的卷积层和池化层,其调用接口与 PyTorch 内置层一致。对批量大小为 \(m\) 的输入,完整维度变化为

\[\begin{split}\begin{aligned} m\times1\times32\times32 &\longrightarrow m\times6\times28\times28 \longrightarrow m\times6\times14\times14\\ &\longrightarrow m\times16\times10\times10 \longrightarrow m\times16\times5\times5 \longrightarrow m\times400\\ &\longrightarrow m\times120 \longrightarrow m\times84 \longrightarrow m\times10. \end{aligned}\end{split}\]

  经典结构会把其中的 \(m\times16\times5\times5\rightarrow m\times120\) 写成 \(m\times16\times5\times5\rightarrow m\times120\times1\times1\rightarrow m\times120\)⁠。下面依次定义训练与测试函数、加载数据、实例化模型,并设置损失函数和优化器。train 接收模型、训练数据加载器、损失函数、优化器、轮数和设备,返回每 100 个小批量记录一次的损失列表;test 接收对应的测试对象,输出按全部测试样本平均的损失与分类准确率。数据部分把 MNIST 的 \(28\times28\) 灰度图填充为 \(32\times32\)⁠,每批返回图像张量和类别标签。

 1# 训练函数
 2def train(model, train_loader, criterion, optimizer, num_epochs, device):
 3    model.train()
 4    loss_list = []
 5    for epoch in range(num_epochs):
 6        loss_list.append([])
 7        for batch_idx, (data, target) in enumerate(train_loader):
 8            data, target = data.to(device), target.to(device)
 9            optimizer.zero_grad()
10            output = model(data)
11            loss = criterion(output, target)
12            loss.backward()
13            optimizer.step()
14            if batch_idx % 100 == 0:
15                print(f'Epoch {epoch}, Batch {batch_idx}, Loss: {loss.item()}')
16                loss_list[-1].append(loss.item())
17    return loss_list
18
19
20
21# 测试函数
22def test(model, test_loader, criterion, device):
23    model.eval()
24    test_loss = 0
25    correct = 0
26    with torch.no_grad():
27        for data, target in test_loader:
28            data, target = data.to(device), target.to(device)
29            output = model(data)
30            test_loss += criterion(output, target).item() * data.size(0)
31            pred = output.argmax(dim=1, keepdim=True)
32            correct += pred.eq(target.view_as(pred)).sum().item()
33    test_loss /= len(test_loader.dataset)
34    accuracy = 100. * correct / len(test_loader.dataset)
35    print(f'Test Loss: {test_loss:.4f}, Accuracy: {accuracy:.2f}%')
36
37
38# 加载训练集和测试集
39transform = transforms.Compose([
40    transforms.Pad(2),  # 28x28 -> 32x32,与下面的 16*5*5 维度匹配
41    transforms.ToTensor(),
42])
43trainset = datasets.MNIST('~/.pytorch/MNIST_data/', download=True, train=True,
44                transform=transform)
45generator = torch.Generator().manual_seed(SEED)
46trainloader = DataLoader(
47    trainset,
48    batch_size=64,
49    shuffle=True,
50    generator=generator,
51)
52
53testset = datasets.MNIST('~/.pytorch/MNIST_data/', download=True, train=False,
54                transform=transform)
55testloader = DataLoader(testset, batch_size=64, shuffle=False)
56
57# 实例化模型
58device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
59model = MyLeNetLike().to(device)
60# 定义损失函数
61criterion = nn.CrossEntropyLoss()
62# 定义优化器
63optimizer = optim.Adam(model.parameters(), lr=0.001)

  训练函数对每个小批量依次清空旧梯度、前向计算 10 维线性输出、计算交叉熵、反向传播并更新参数。测试函数切换到评估模式并关闭梯度记录,只累计损失和预测正确数;它只打印指标,不返回指标。数据加载器在训练时打乱样本,在测试时保持固定顺序;这里直接使用官方测试集作最终展示,没有另划验证集,因此不应利用测试结果反复选择学习率、轮数或网络结构。

为什么模型中没有单独加入 Softmax

  课件的结构图在 10 维输出之后画出了 Softmax。这里的 nn.CrossEntropyLoss 会在内部对输出层的线性运算结果进行数值稳定的 log-softmax 计算,再计算负对数似然,因此训练时不应在模型末尾重复加入 Softmax。需要展示各类别的预测概率时,可以在模型预测阶段对 output 使用 torch.softmax(output, dim=1)

  下面把已创建的模型、训练加载器、损失函数、优化器和设备传给 train训练 5 轮。返回的 loss_list 按轮保存每隔 100 个小批量抽样得到的训练损失;运行期间预期同时在终端打印这些记录。由于手写卷积使用逐位置 Python 循环,这一步会明显慢于使用 nn.Conv2d 的等价模型。

1loss_list = train(model, trainloader, criterion, optimizer, 5, device)

  训练的部分输出如下;每行给出轮次、小批量编号和该批样本的交叉熵,而不是整轮平均损失:

Epoch 0, Batch 0, Loss: 2.313023090362549
Epoch 0, Batch 100, Loss: 0.2724916934967041
Epoch 0, Batch 200, Loss: 0.24752724170684814
Epoch 0, Batch 300, Loss: 0.2016330510377884
Epoch 0, Batch 400, Loss: 0.0875156968832016
Epoch 0, Batch 500, Loss: 0.07786551862955093
Epoch 0, Batch 600, Loss: 0.12608958780765533
Epoch 0, Batch 700, Loss: 0.2344299852848053
Epoch 0, Batch 800, Loss: 0.041058387607336044
Epoch 0, Batch 900, Loss: 0.22866475582122803
Epoch 1, Batch 0, Loss: 0.12164872884750366
Epoch 1, Batch 100, Loss: 0.03153081238269806
Epoch 1, Batch 200, Loss: 0.30480441451072693
Epoch 1, Batch 300, Loss: 0.11223193258047104
Epoch 1, Batch 400, Loss: 0.031937647610902786
Epoch 1, Batch 500, Loss: 0.15128779411315918
Epoch 1, Batch 600, Loss: 0.0511942021548748
Epoch 1, Batch 700, Loss: 0.09246962517499924
Epoch 1, Batch 800, Loss: 0.019182205200195312
Epoch 1, Batch 900, Loss: 0.09765469282865524
Epoch 2, Batch 0, Loss: 0.06264112144708633
Epoch 2, Batch 100, Loss: 0.0318015031516552
Epoch 2, Batch 200, Loss: 0.0829918310046196
Epoch 2, Batch 300, Loss: 0.015590152703225613
Epoch 2, Batch 400, Loss: 0.026429124176502228
Epoch 2, Batch 500, Loss: 0.028633370995521545
Epoch 2, Batch 600, Loss: 0.014136232435703278
Epoch 2, Batch 700, Loss: 0.0798005536198616
Epoch 2, Batch 800, Loss: 0.2847082018852234
Epoch 2, Batch 900, Loss: 0.011133795604109764
Epoch 3, Batch 0, Loss: 0.012092544697225094
Epoch 3, Batch 100, Loss: 0.012453068047761917
Epoch 3, Batch 200, Loss: 0.02332950197160244
Epoch 3, Batch 300, Loss: 0.09619424492120743
Epoch 3, Batch 400, Loss: 0.03305321931838989
Epoch 3, Batch 500, Loss: 0.04451408237218857
Epoch 3, Batch 600, Loss: 0.04110445827245712
Epoch 3, Batch 700, Loss: 0.10520290583372116
Epoch 3, Batch 800, Loss: 0.029406791552901268
Epoch 3, Batch 900, Loss: 0.004607217386364937
Epoch 4, Batch 0, Loss: 0.16948693990707397
Epoch 4, Batch 100, Loss: 0.03311607614159584
Epoch 4, Batch 200, Loss: 0.0033724356908351183
Epoch 4, Batch 300, Loss: 0.06435728073120117
Epoch 4, Batch 400, Loss: 0.1018964871764183
Epoch 4, Batch 500, Loss: 0.11684078723192215
Epoch 4, Batch 600, Loss: 0.018910348415374756
Epoch 4, Batch 700, Loss: 0.04910891875624657
Epoch 4, Batch 800, Loss: 0.008243650197982788
Epoch 4, Batch 900, Loss: 0.022557081654667854

  总体下降说明模型正在拟合训练数据,但相邻记录会因小批量组成不同而波动,个别较大的数值并不表示训练一定失败。这里只展示一个随机种子下的训练批次损失,不能据此估计新数据表现或比较两个模型的优劣。

  下面把 loss_list 中每一轮抽样记录的损失画成一条曲线。横轴只是该轮内“已记录点”的顺序,并非原始小批量编号;输出是一幅用于观察大致训练趋势的图,不会改变模型参数。

1plt.figure(figsize=(10, 5))
2for epoch, loss in enumerate(loss_list):
3    plt.plot(loss, lw=2, marker='o', label=f'Epoch {epoch}')
4plt.xlabel('Batch', fontsize=14)
5plt.ylabel('Training Loss', fontsize=14)
6plt.legend(fontsize=14)
7plt.show()

  输出图中每条折线对应一轮训练时抽样记录的若干批损失;曲线整体降低可作为模型正在拟合的迹象,但各折线横坐标会从 0 重新开始,也没有显示未记录的小批量或验证损失。

LeNet5_loss

图 22 训练损失#

  小批量损失会明显波动,不能根据单个批次断言模型已经收敛。应同时观察逐轮平均训练损失与验证损失。下面把训练后的 modeltestloader 传给 test该函数遍历全部测试样本,预期打印平均交叉熵与准确率,但不会返回预测数组或混淆矩阵。

1test(model, testloader, criterion, device)

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

Test Loss: 0.0436, Accuracy: 98.78%

  上述结果是在 CPU 上使用随机种子 42 得到的。即使固定随机种子,结果仍可能随软件版本和硬件略有变化。比较模型时还应设置验证集并重复多个随机种子。自定义层的价值在于展示张量运算;实际应用应优先使用 nn.Conv2dnn.MaxPool2d它们具有经过优化的 CPU/GPU 实现并覆盖更多边界情况。

核心推导与实现核验#

核心关系

\[\begin{split}\begin{aligned} m\times1\times32\times32 &\longrightarrow m\times6\times28\times28 \longrightarrow m\times6\times14\times14\\ &\longrightarrow m\times16\times10\times10 \longrightarrow m\times16\times5\times5 \longrightarrow m\times400\\ &\longrightarrow m\times120 \longrightarrow m\times84 \longrightarrow m\times10. \end{aligned}\end{split}\]

  推导路径。 无填充 \(5\times5\) 卷积使边长减 4,\(2\times2\)⁠、步幅 2 的池化使边长减半。按此顺序逐层计算数据维度,并同时保留每个批量中的样本数、通道数以及特征图的高度和宽度,即可得到各层的数据维度和 400 维的展平结果。

关键条件

  每一步都由通用输出尺寸公式得到;把各层的 \(f^{[l]}\)⁠、\(s^{[l]}\)\(p^{[l]}\) 代入,即可证明最终空间尺寸为 \(5\times5\)⁠。经典结构接着用 120 个 \(5\times5\) 核得到 \(120\times1\times1\)⁠,本节教学实现则展平后使用线性层。

数据规模

  若一次输入 \(m\)\(32\times32\) 的灰度图像,本节教学实现的输入维度为 \(m\times1\times32\times32\)⁠。第二次池化后的特征维度为 \(m\times16\times5\times5\)⁠,因此每张图像展平后得到 \(16\times5\times5=400\) 个数。

常见误区

  爱因斯坦求和下标若在输出中遗漏或重复会静默得到错误归约;展平维度也不能脱离前层维度硬猜。

动手检查

  用 \(m\times1\times32\times32\) 的虚拟批次记录逐层 NCHW 维度,核对最终展平维度为 400;将手写卷积与池化分别同框架算子比较,并完成一次损失值有限的后向传播。

数值稳定性与规模

  CrossEntropyLoss 直接接收输出层的线性运算结果,模型末尾不应重复加入 Softmax。若使用混合精度训练,应采用合适的损失缩放,并检查每层输出、梯度和损失是否都是有限数。

本节小结#

  1. LeNet 是练习维度传播和模块化实现的经典小网络;修改其激活、池化等细节后,应称为 LeNet 风格网络。

  2. 手写运算必须通过 可信框架结果对照数值梯度检验 进行验证。

  3. 网络最后的全连接层需要接收前面卷积层产生的全部特征,因此其输入维度应根据卷积和池化后的实际输出计算。

综合练习#

  程序题应固定随机种子、写出维度断言并报告运行环境;比较题还应固定数据划分、随机种子集合和训练预算。全部参考答案见 LeNet 风格网络代码实现答案⁠。

  1. LeNet 风格网络的维度与参数量。\(32\times32\) 单通道输入,依次使用“6 个 \(5\times5\) 卷积核”“\(2\times2\)⁠、步幅 2 的池化”“16 个 \(5\times5\) 卷积核”“\(2\times2\)⁠、步幅 2 的池化”和宽度为 120、84、10 的线性层。卷积均无填充、步幅 1。逐层写出数据维度的变化,并同时保留每个批量中的样本数、通道数以及特征图的高度和宽度;计算本节教学实现包含偏置时的总参数量,并说明其池化方式和激活函数与经典 LeNet-5 有何不同。

  2. 手写卷积计算。 MyConv2D 的输入区域维度为 \(m\times d_C^{[l-1]}\times f^{[l]}\times f^{[l]}\)⁠,权重维度为 \(d_C^{[l]}\times d_C^{[l-1]}\times f^{[l]}\times f^{[l]}\)⁠。说明 einsum('mcuv,qcuv->mq', region, weight) 的求和指标和输出维度;取 \(m=1\)⁠、单输入通道、单输出通道、区域与核均为 \(2\times2\) 全 1,偏置为 2,计算输出。

  3. 最大池化填充。 证明最大池化对全负输入使用 0 填充会改变边界窗口的结果。以单元素输入 \([-2]\)⁠、核大小 2、填充 1 为例,分别计算 0 填充与 \(-\infty\) 填充的边界最大值,并解释正确实现应选择哪一个。

  4. 分类输出层计算。 证明第二次池化后的空间尺寸为 \(5\times5\)⁠,因而展平维度为 400。若误把输入图像改为 \(28\times28\) 而不调整结构,逐层计算空间尺寸并指出原来的第一全连接层为什么发生维度错误。

  5. 完整实现。 使用本节 MyConv2DMyMaxPool2D 组装 LeNet 风格网络,补充设备和数据类型一致性检查、通过虚拟输入自动计算展平维度的程序,以及训练、验证和加载最佳模型参数的函数。

  6. 前向与梯度对照。 把手写卷积/池化和框架 Conv2d/MaxPool2d 设置为相同参数,比较前向输出;对卷积再使用同一上游梯度比较输入、权重和偏置梯度,并核对所有参数均已注册到优化器。

  7. 测试设计。 测试本节 LeNet 风格网络的逐层维度、参数总数 61706、不同批量大小、全负池化窗口、CPU 与可用 GPU 上的前向传播、一次后向传播得到有限梯度,以及保存后重新加载模型参数得到相同预测。

  8. 卷积实现效率比较。 在同一 LeNet 风格网络中比较逐输出位置循环、unfold/einsum 和框架卷积。固定 MNIST 数据划分、随机种子集合、训练预算、初始化与硬件;报告任务性能、训练时间、固定批量预测时间、参数量及峰值内存。

  9. LeNet 风格网络与基础模型比较。 在同一 MNIST 划分上比较逻辑回归、参数量尽量接近的 MLP 和本节 LeNet 风格网络。固定随机种子集合、训练预算和超参数选择预算;报告任务性能、训练时间、固定批量预测时间、参数量及峰值内存。

  10. 通道数与学习率比较。 对 LeNet 风格网络的卷积通道使用“原通道数的一半、原配置、原通道数的两倍”,并为每种宽度比较相同的三个学习率。固定数据划分、随机种子集合和以参数更新次数表示的训练预算;报告任务性能、训练时间、固定批量预测时间、参数量及峰值内存,分析容量与学习率的共同影响。