神经网络在做什么?以 MNIST 数据集为例#
学习目标与记号#
准确解释图像展平、数据加载、训练模式与评估模式;
根据公式和张量维度分析多分类损失,并识别常见实现错误;
用
Python实现或验证错误分析;在其他条件相同的情况下,只改变一个需要研究的因素进行比较,并根据实验结果分析该因素可能带来的影响。
本节沿用统一记号:普通小写字母表示标量,粗体小写字母表示向量,粗体大写字母表示矩阵或高阶张量;层编号写作上标 \([l]\),样本或时间编号写作下标;转置写作 \(\trans\)。除非另有说明,批量样本按行存放。正文与练习中的程序都应同时检查数值结果和数组维度。本节练习的参考答案见 用全连接网络识别 MNIST 答案。
模型使用 ReLU、Softmax 多分类思想、交叉熵 与 Adam;若要利用图像空间结构,可继续阅读 卷积神经网络。
前面已经介绍了神经网络的基本概念和手工实现。本节以 MNIST 手写数字分类为例,使用 PyTorch 搭建多层全连接网络,并查看各层参数和中间激活。需要注意:可视化单个样本的激活有助于形成直觉,但不能据此断言某个神经元已经学习到唯一、固定的语义。
加载程序包#
在这里加载数组计算、绘图、PyTorch 建模和 MNIST 数据读取所需的程序包,并把 NumPy 与 PyTorch 的 CPU 随机种子设为 42。该代码没有数据输入和可见输出;执行成功后,后续代码可以使用这些模块,并较容易复现参数初始化与数据打乱顺序。
import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# 设置随机数种子
np.random.seed(42)
torch.manual_seed(42)
# torch.cuda.manual_seed_all(42) # 如果使用GPU
在上面的代码中,torch 负责张量、模型和优化,torchvision 负责图像数据集及变换,DataLoader 负责生成小批量。NumPy 和 PyTorch 使用各自的随机数生成器,所以要分别设种子;若使用 CUDA,还应启用相应的 GPU 种子设置。固定种子只能控制部分随机来源,跨硬件、软件版本或非确定性算子仍可能产生差异,且运行环境必须预先安装这些程序包。
MNIST 数据集#
MNIST 包含 60,000 个训练样本和 10,000 个测试样本。每个样本是一幅 \(28\times28\) 的灰度图像,标签为 0 到 9。transforms.ToTensor() 会把图像转换成维度为 \(1\times28\times28\) 的浮点张量,并把像素值缩放到 \([0,1]\)。
下面以本地目录为输入位置,必要时下载 MNIST,输出训练集、测试集及批量大小为 64 的两个 DataLoader;随后从每个类别各取第一张训练图像,预期画出包含数字 0 至 9 的 \(2\times5\) 图像网格。
1# 加载训练集和测试集
2trainset = datasets.MNIST('~/.pytorch/MNIST_data/', download=True, train=True,
3 transform=transforms.ToTensor())
4trainloader = DataLoader(trainset, batch_size=64, shuffle=True)
5
6testset = datasets.MNIST('~/.pytorch/MNIST_data/', download=True, train=False,
7 transform=transforms.ToTensor())
8testloader = DataLoader(testset, batch_size=64, shuffle=False)
9
10# 展示每个类别下的第一张图片
11fig, axs = plt.subplots(2, 5, figsize=(10, 5))
12for i in range(10):
13 axs[i//5, i%5].imshow(np.array(trainset.data[trainset.targets==i][0]), cmap='gray')
14 axs[i//5, i%5].set_title(f'Class {i}')
15 axs[i//5, i%5].axis('off')
16plt.show()
train=True 与 train=False 分别选择官方训练、测试部分,ToTensor 在通过数据集接口读取样本时完成通道维添加和数值缩放;训练加载器打乱顺序,测试加载器保持固定顺序。绘图直接读取 trainset.data 中的原始整数像素,因此这里只用于显示。首次运行需要网络和磁盘空间;该流程没有从训练集另划验证集,后续调参时应补充验证划分。
网络搭建#
这里搭建一个简单的全连接神经网络。全连接网络会忽略图像的空间结构,通常不是图像任务的最佳选择;后续章节将介绍专门处理图像的 卷积神经网络。本节仍采用全连接网络,是为了清楚展示从输入展平到分类输出的完整流程。由于 MNIST 数据集相对简单,这一基线模型也能取得较好的效果。
和之前手动实现的全连接神经网络不同,我们在这里使用 PyTorch 提供的 nn.Module 类来搭建神经网络。该类的 forward 输入一个形如 \(m\times1\times28\times28\) 的图像批次,预期输出 \(m\times10\) 的线性运算结果,每行对应十个候选类别。
1class FNN(nn.Module):
2 def __init__(self):
3 super(FNN, self).__init__()
4 self.fc1 = nn.Linear(28*28, 128)
5 self.fc2 = nn.Linear(128, 64)
6 self.fc3 = nn.Linear(64, 10)
7
8 def forward(self, x):
9 x = x.view(-1, 28*28)
10 x = torch.relu(self.fc1(x))
11 x = torch.relu(self.fc2(x))
12 x = self.fc3(x)
13 return x
FNN 继承自 nn.Module。前向传播先把每幅图像展平为 784 维向量,再经 ReLU 依次映射到 128 维、64 维和 10 维;最后十个数尚未经过 Softmax,不是概率。CrossEntropyLoss 会在内部稳定地处理这些线性输出,因此训练时不应先手动 Softmax。模型写死了 \(28\times28\) 输入和 10 类输出,并因展平而忽略二维邻接关系;其他尺寸或类别数需要修改网络结构。
训练与测试函数#
在这里,我们定义了一个训练函数 train 和一个测试函数 test。在训练函数中,我们首先将模型设置为训练模式,然后遍历数据集,计算模型的输出和损失,最后更新模型的参数。在测试函数中,我们将模型设置为评估模式,然后遍历数据集,计算模型的输出和损失。
首先定义训练函数 train。它输入模型、训练加载器、损失函数、优化器和训练轮数;函数原地更新模型参数并定期打印当前批次损失,不返回新的对象。
1def train(model, train_loader, criterion, optimizer, num_epochs):
2 model.train()
3 for epoch in range(num_epochs):
4 for batch_idx, (data, target) in enumerate(train_loader):
5 optimizer.zero_grad()
6 output = model(data)
7 loss = criterion(output, target)
8 loss.backward()
9 optimizer.step()
10 if batch_idx % 100 == 0:
11 print(f'Epoch {epoch+1}/{num_epochs}, Batch {batch_idx}, Loss: {loss.item():.4f}')
上述代码先将模型切换到训练模式,再逐批执行梯度清零、前向计算、损失计算、后向传播和参数更新。输出中的 epoch、批次编号和损失用于监控训练,但该损失只来自当前批次,不能当作整个训练集的平均损失。函数也未处理设备迁移、验证集、早停或断点保存,较长训练任务应补充这些机制。
接着定义测试函数 test。它输入训练后的模型、测试加载器和损失函数,不更新参数;预期在完整测试集上汇总逐样本平均损失与分类准确率并打印出来。
1def test(model, test_loader, criterion):
2 model.eval()
3 test_loss = 0
4 correct = 0
5 with torch.no_grad():
6 for data, target in test_loader:
7 output = model(data)
8 # criterion 默认返回当前批次的平均损失;先还原为批次总和
9 test_loss += criterion(output, target).item() * data.size(0)
10 pred = output.argmax(dim=1, keepdim=True)
11 correct += pred.eq(target.view_as(pred)).sum().item()
12 test_loss /= len(test_loader.dataset)
13 accuracy = 100. * correct / len(test_loader.dataset)
14 print(f'Test Loss: {test_loss:.4f}, Accuracy: {accuracy:.2f}%')
model.eval() 让 Dropout、BatchNorm 等模块采用评估行为,torch.no_grad() 关闭梯度记录以节省内存和计算。代码把每个批次的平均损失还原为总和,再除以测试样本总数;argmax 给出预测类别,命中数最终转为百分比。当前函数只打印而不返回指标,也没有按类别分析错误;测试集应主要用于最终评价,不能反复用于选择超参数。
初始化模型和优化器#
下面不接收额外数据,而是实例化 FNN,建立直接接收 \(m\times10\) 线性输出和长度为 \(m\) 类别标签的交叉熵损失,并用模型的全部可训练参数创建学习率为 0.003 的 Adam 优化器。三行代码分别输出可供后续训练使用的 model、criterion 和 optimizer 对象。:
model = FNN()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.003)
优化器在创建时保存对 model.parameters() 的引用,后续 step 会原地更新这些参数。这里模型和数据默认都在 CPU;若迁移到 GPU,必须把模型与每个批次同时移到同一设备。学习率 0.003 只是本例设置,仍应在验证集上选择。
到此为止,我们已经完成了模型、数据加载器、损失函数和优化器的准备。下面把这些对象传给 train,运行 10 个 epoch;预期每隔 100 个批次打印一次当前小批量损失,并在函数结束时把训练结果保留在 model 中。
1train(model, trainloader, criterion, optimizer, 10)
训练损失通常总体下降,但小批量损失会有波动。若训练损失继续下降而验证损失上升,说明模型开始过拟合,应考虑提前停止或正则化。下面仅展示输出格式,实际数值会随软件版本、硬件和随机性略有变化。
Epoch 1/10, Batch 0, Loss: 2.2923
Epoch 1/10, Batch 100, Loss: 0.5469
Epoch 1/10, Batch 200, Loss: 0.3759
Epoch 1/10, Batch 300, Loss: 0.2229
Epoch 1/10, Batch 400, Loss: 0.1669
Epoch 1/10, Batch 500, Loss: 0.1417
Epoch 1/10, Batch 600, Loss: 0.0689
Epoch 1/10, Batch 700, Loss: 0.1060
Epoch 1/10, Batch 800, Loss: 0.1921
Epoch 1/10, Batch 900, Loss: 0.2093
...
Epoch 10/10, Batch 900, Loss: 0.0821
每行分别表示训练轮次、该轮中的批次编号和该批次交叉熵;省略号代表中间日志,而不是程序真的打印的字符。单个批次损失可能上下波动,展示数值也不是可移植的基准;判断收敛应汇总训练与验证曲线并重复不同随机种子。
接着把训练后的 model、完整 testloader 和同一损失函数传给 test;该调用没有显式返回值,预期打印测试集的平均损失和准确率。
1test(model, testloader, criterion)
下面的控制台块展示一次可能的输出:平均交叉熵约为 0.1127,准确率约为 \(97.38\%\)。
Test Loss: 0.1127, Accuracy: 97.38%
损失越小通常表示分配给真实类别的概率越高,准确率表示预测类别正确的样本比例;二者衡量角度不同。一次运行通常可以得到较高准确率,但精确数值并非固定结论,也没有展示各类别或置信度表现。调整网络结构、学习率或训练轮数时,应另设验证集,避免用测试结果反复调参。
参数和输出的提取#
我们使用 model.named_parameters() 检查训练后模型的参数结构。它输入当前 model,逐项产生“名称、参数张量”元组;下面只打印名称和维度,预期列出三层权重与三层偏置,不输出具体参数值。
1for name, param in model.named_parameters():
2 print(name, param.size())
该循环按模块注册顺序遍历可训练参数,param.size() 描述连接维度;它不复制、修改或保存参数。若要复现实验或部署模型,应使用 state_dict 保存数值,而不是依赖这份维度日志。
下面的控制台输出与 784→128→64→10 的网络结构一一对应。
fc1.weight torch.Size([128, 784])
fc1.bias torch.Size([128])
fc2.weight torch.Size([64, 128])
fc2.bias torch.Size([64])
fc3.weight torch.Size([10, 64])
fc3.bias torch.Size([10])
在上面的代码中,我们打印出了每个全连接层的权重和偏置的大小。可以看到,第一个全连接层的权重大小为 torch.Size([128, 784]),表示有 128 个神经元,每个神经元有 784 个输入。第一个全连接层的偏置大小为 torch.Size([128]),其他全连接层的权重和偏置的大小也可以类似地解释。
接下来从 testset 的原始图像和标签中,为每个数字类别选取首次出现的样本。预期得到长度为 10 的索引列表、形状 \(10\times28\times28\) 且缩放到 \([0,1]\) 的 data_selected,以及依次为 0 至 9 的标签 targets_selected。
1# 从测试集中选取样本
2targets = testset.targets
3data = testset.data
4
5# 选取每个类别的第一个样本
6list_idx = []
7for i in range(10):
8 idx = np.where(targets == i)[0][0]
9 list_idx.append(idx)
10# testset.data 保存的是 0--255 的整数;手动前向传播时要复现 ToTensor 的缩放
11data_selected = data[list_idx].float() / 255.0
12targets_selected = targets[list_idx]
13print(targets_selected)
np.where 找出每类标签的位置并取第一个索引;直接读取 testset.data 会绕过 ToTensor,所以代码显式转为浮点并除以 255,以匹配训练输入尺度。该选样法要求每个类别至少出现一次,而且只取每类一张,具有展示性而不具代表性。
因此本次标签输出应为:
tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
之后输入这 10 张缩放后的图像,手动复现模型三层前向传播。预期 output_1、output_2、output_3 的形状分别为 \(10\times128\)、\(10\times64\)、\(10\times10\),最后打印每行最大概率对应的预测类别。
1with torch.no_grad():
2 output_1 = torch.relu(model.fc1(data_selected.view(-1, 28*28)))
3 output_2 = torch.relu(model.fc2(output_1))
4 output_3 = torch.softmax(model.fc3(output_2), dim=1)
5
6print(output_3.argmax(dim=1))
no_grad 避免为分析激活建立计算图,前两层与模型一致地使用 ReLU,最后 Softmax 只为解释成概率。手动访问 fc1 等子层与当前结构绑定;若模型加入 Dropout、BatchNorm 或修改 forward,这段代码可能不再等价,并且最好在运行前显式调用 model.eval()。
本次示例的预测类别输出为:
tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
此处 Softmax 仅用于把线性运算结果转换为总和为 1 的类别概率;argmax 在 Softmax 前后得到的类别相同。示例中的十个样本恰好预测正确,并不意味着模型对每个类别的所有样本都能正确分类,整体性能仍应由完整测试集衡量。
神经网络的内部工作机制#
让我们以一张手写数字“2”的图片为例,来展示一张图片是如何转化为预测结果的。
视频展示了图像展平后在各层产生的激活以及相应权重。圆圈亮度表示该样本上的激活强弱;连线的颜色表示权重符号,亮度表示权重绝对值。某个 ReLU 单元是否输出 0,取决于完整的仿射输入 \(\bw\trans\cdot\bx+b\) 是否为负,不能只根据某几条权重为负来判断。
ReLU 的确常产生零激活,从而得到稀疏的中间表示,但“稀疏”本身不保证不过拟合,也不等同于模型参数更少。理解内部表示时应同时比较多个样本、多个类别和不同层;还可以保持其他设置不变,删除或改变一个模型组件后观察结果,或者使用特征可视化。不要根据单张图的颜色模式作过度解释。
核心推导与实现核验#
核心关系
推导路径。 每张 \(28\times28\) 灰度图展平为 784 维向量;最后线性层输出 10 个线性运算结果,交叉熵内部完成 log-softmax,因此训练时无需手动转概率。
关键条件
展平是元素的一一重排,不丢失像素数值;但普通全连接层不再显式利用二维邻接关系,所以空间归纳偏置被丢弃。
数据规模
若一个批次有 \(m\) 张 MNIST 灰度图像,原始输入大小通常为 \(m\times1\times28\times28\)。每张图像展平后有 \(28\times28=784\) 个像素,因此网络输入为 \(m\times784\);标签向量包含 \(m\) 个类别编号。
常见误区
评估时忘记 no_grad 会保留计算图;先对线性运算结果应用 Softmax 再传给 CrossEntropyLoss 会重复或削弱稳定计算。
数值稳定性与规模
多分类交叉熵直接接收输出层的线性运算结果和整数类别标签,不要先做 Softmax。训练时监控损失、梯度范数与有限性;评估时切换到 eval 模式并使用 no_grad,避免保留无用的计算图。
本节小结#
展平保留数值但去除显式空间结构。
多分类交叉熵直接接收输出层的线性运算结果与整数标签。
评估模式、无梯度环境和错误分析不可省略。
综合练习#
程序题应固定随机种子、写出维度断言并报告运行环境;比较题还应固定数据划分、随机种子集合和训练预算。全部参考答案见 用全连接网络识别 MNIST 答案。
预处理、维度与参数量。 一个 MNIST 批次维度为 \(64\times1\times28\times28\),展平后输入两层隐藏宽度为 128 和 64 的 FNN,最后输出 10 维。逐层写出权重矩阵和偏置向量的维度,说明
ToTensor对像素范围的处理,并计算三个含偏置线性层的总参数量。多分类交叉熵计算。 某样本输出层的线性运算结果为 \((2,1,0)\),真实类别为第 0 类。计算 Softmax 概率和交叉熵,并把损失改写为 \(\log\sum_k e^{z_k}-z_y\) 的稳定形式给出数值。
测试损失汇总。 测试集的两个批次分别含 64 和 36 个样本,批均值损失分别为 \(0.2\) 与 \(0.5\)。计算整个测试集的逐样本平均损失,并解释为什么直接对两个批均值再取平均会出错。
Softmax 平移不变性。 证明对任意常数 \(c\),\(\operatorname{softmax}(\bz+c\boldsymbol1)=\operatorname{softmax}(\bz)\);由此说明计算时减去 \(\max_k z_k\) 为什么能降低指数溢出风险而不改变概率或交叉熵。
可复现训练实现。 用 PyTorch 或 NumPy 完成训练/验证/测试划分、数据加载、FNN 训练、最佳验证检查点保存和最终测试;显式设置 NumPy、框架与数据加载器随机种子,并记录每轮平均损失、准确率和运行时间。
测试设计。 至少测试:像素范围和展平维度正确、标签位于 0--9、相同种子产生相同首批数据与初始损失、评估模式预测可复现、批量加权测试损失符合第 3 题、保存再加载检查点预测不变,以及所有参数梯度同形且有限。
隐藏宽度与学习率比较。 比较至少三组隐藏宽度,并为每组使用相同的学习率候选集合。固定数据划分、随机种子集合、以训练轮数表示的训练预算和选择规则;报告测试精度、训练时间、固定批量预测时间、单样本预测时间、参数量及峰值内存,分析宽度与学习率的交互。