1. 为什么选择线性分类器作为MNIST的入门项目
很多人第一次接触深度学习框架时,都会有一种"不知道从哪里下手"的迷茫感。打开官方文档,看到卷积层、池化层、批归一化、注意力机制这些名词,很容易就被劝退了。我的建议是:先别碰那些花哨的东西,从最简单的线性分类器开始,把一条完整的训练链路跑通,比什么都重要。
MNIST手写数字数据集在这个领域里的地位,大概相当于学编程时的"Hello World"。它足够小,小到用普通笔记本电脑的CPU几分钟就能跑完一轮训练;它又足够经典,经典到几乎所有的框架教程都会拿它当第一个例子。而线性分类器,则是所有神经网络里最基础的一种结构——没有隐藏层,没有非线性激活,输入直接映射到输出。听起来简单得过分,但正是这种简单,让你能把注意力集中在真正重要的东西上:数据怎么加载、模型怎么定义、损失怎么计算、梯度怎么更新。
我见过不少初学者,一上来就照着网上的代码搭了一个多层卷积网络,跑出来准确率确实高,但问他"优化器里的学习率是干什么的""为什么要调用zero_grad()",就答不上来了。这就是跳过了基础步骤的后果。线性分类器虽然准确率只有90%出头,但它能让你把每一个环节都看得清清楚楚。
这篇文章适合谁?如果你已经装好了PyTorch,知道张量是什么,但还没完整地训练过一个模型,那这篇内容就是为你准备的。如果你已经做过一些项目,但想回头把基础打扎实,也值得一看。我会把每一步的意图、常见的坑、以及我自己的经验都写出来,尽量让你少走弯路。
2. 数据准备:MNIST加载与预处理的细节
2.1 用torchvision加载MNIST的正确姿势
PyTorch生态里,torchvision.datasets已经内置了MNIST的下载和加载逻辑,你不需要自己去网上找数据集文件。核心代码就几行:
from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST( root='./data', train=True, download=True, transform=transform ) test_dataset = datasets.MNIST( root='./data', train=False, download=True, transform=transform )这段代码看起来简单,但里面有几个细节值得展开说。
transforms.ToTensor()做的事情不只是"把图片变成张量"这么简单。它实际上完成了三件事:把PIL图片对象转成PyTorch张量、把像素值从0-255缩放到0-1之间、把维度从(H, W, C)调整成(C, H, W)。最后这一点特别重要,因为PyTorch的卷积层和全连接层都默认输入的第一个维度是通道数。如果你跳过这一步,直接把numpy数组喂给模型,维度对不上,报错信息还特别难看懂。
transforms.Normalize((0.1307,), (0.3081,))这两个数字不是随便写的。0.1307是MNIST训练集所有像素的均值,0.3081是标准差。做归一化的目的是让输入数据的分布更接近标准正态分布,这样梯度下降的时候收敛更快。你可以不做归一化,模型也能训练,但收敛速度会慢一些,而且对学习率更敏感。我实测过,同样的学习率下,做了归一化的模型在前几个epoch的loss下降明显更顺畅。
注意:如果你用的是自己准备的MNIST数据文件,而不是通过torchvision自动下载,一定要确认图片的格式和标签的对应关系。我遇到过一次,数据文件里的标签是从1开始编号的,而PyTorch默认是从0开始,结果训练出来的模型把所有数字都预测错了一位。
2.2 DataLoader的批大小与打乱策略
数据集加载好之后,需要用一个DataLoader把它包装起来,这样才能按批次取数据:
from torch.utils.data import DataLoader train_loader = DataLoader( train_dataset, batch_size=64, shuffle=True ) test_loader = DataLoader( test_dataset, batch_size=1000, shuffle=False )batch_size的选择是一个经验活。设得太小,比如1或者8,每个批次计算的梯度噪声很大,训练过程会震荡得厉害,而且GPU利用率低;设得太大,比如整个数据集作为一个批次,内存吃不消,而且梯度更新次数太少,收敛慢。对于MNIST这种小数据集,64是一个比较稳妥的选择。训练集用64,测试集可以用1000,因为测试的时候不需要反向传播,显存占用小,一次多跑一些数据能加快评估速度。
shuffle=True在训练集上是必须的。如果每个epoch都按同样的顺序喂数据,模型可能会学到一些和顺序相关的虚假模式。比如如果标签恰好是按类别排序的,模型可能会"偷懒"地根据批次位置来猜测类别。打乱之后,每个批次的样本分布更接近真实分布,梯度估计更准确。
测试集的shuffle设成False,主要是为了方便复现结果。如果你需要保存预测结果和标签做对比分析,不打乱的话索引是对应的,处理起来方便很多。
2.3 验证集要不要单独划分
很多教程在MNIST上只分训练集和测试集,不单独划验证集。这样做在入门阶段没问题,但我想提醒一句:测试集是用来做最终评估的,不应该在训练过程中反复用来调参。如果你根据测试集的表现来调整学习率或者网络结构,那测试集实际上就变成了验证集,最终报告的性能会有乐观偏差。
我的习惯是从训练集里再切出10%作为验证集,每个epoch结束后在验证集上看一下loss和准确率,用来判断是否过拟合或者欠拟合。具体做法可以用torch.utils.data.random_split:
from torch.utils.data import random_split train_size = int(0.9 * len(train_dataset)) val_size = len(train_dataset) - train_size train_subset, val_subset = random_split(train_dataset, [train_size, val_size])然后在训练循环里多跑一个验证阶段。虽然多写几行代码,但对理解模型的泛化表现很有帮助。
3. 线性分类器的结构设计与前向传播
3.1 从像素到类别:全连接层的本质
线性分类器的核心就是一个全连接层。MNIST的每张图片是28x28像素,展平之后是784维的向量。我们有10个类别(数字0到9),所以输出是一个10维的向量。全连接层的权重矩阵形状是(10, 784),偏置是(10,)。
用数学公式表示就是:y = Wx + b,其中x是784维输入,W是10x784的权重矩阵,b是10维偏置,y是10维输出。这个输出通常被称为logits,还不是概率。要得到概率,需要再经过一个softmax函数。
为什么叫"线性"分类器?因为决策边界是线性的。在784维空间里,每一类对应一个超平面,模型学到的是用这些超平面把不同类别的数据点分开。对于MNIST这种相对简单的数据集,线性边界已经能区分大部分样本了,准确率大概在92%左右。剩下的8%主要是那些写得模棱两可的数字,比如4和9、3和8、7和1,人类有时候也会看错。
3.2 用nn.Linear搭建模型
PyTorch里定义模型的标准做法是继承nn.Module:
import torch.nn as nn class LinearClassifier(nn.Module): def __init__(self): super(LinearClassifier, self).__init__() self.flatten = nn.Flatten() self.fc = nn.Linear(784, 10) def forward(self, x): x = self.flatten(x) x = self.fc(x) return xnn.Flatten()的作用是把输入从(batch_size, 1, 28, 28)变成(batch_size, 784)。你也可以用x.view(x.size(0), -1)手动展平,效果一样,但用nn.Flatten()作为模块的一部分,代码更整洁,而且能自动处理批次维度。
nn.Linear(784, 10)内部会自动初始化权重和偏置。默认的初始化方式是Kaiming均匀分布,对于这种简单网络来说够用了。如果你想手动控制,可以用nn.init里的函数,但在MNIST这个场景下,初始化方式对最终结果的影响不大。
3.3 前向传播中的维度检查
新手最容易犯的错误就是维度对不上。我建议在写forward函数的时候,养成打印维度的习惯:
def forward(self, x): print(x.shape) # 调试用,正式训练时删掉 x = self.flatten(x) print(x.shape) x = self.fc(x) print(x.shape) return x跑一个批次的数据,看看维度变化是否符合预期。输入应该是[64, 1, 28, 28],展平后是[64, 784],输出是[64, 10]。如果中间某一步对不上,报错信息会告诉你哪两个维度不匹配,顺着查就能找到问题。
还有一个常见的坑:如果你在定义模型的时候把nn.Flatten()写成了torch.flatten(),那它不会作为模型的一部分被注册,保存和加载模型的时候可能会出问题。所以尽量用nn模块里的层,而不是函数式的操作。
4. 损失函数与优化器的选择逻辑
4.1 CrossEntropyLoss的内部机制
分类任务最常用的损失函数是交叉熵损失。PyTorch里的nn.CrossEntropyLoss实际上把LogSoftmax和NLLLoss合在了一起,所以你在模型里不需要再加softmax层:
criterion = nn.CrossEntropyLoss()这一点很多人会搞混。如果你在模型的forward里加了nn.Softmax(),然后又用CrossEntropyLoss,相当于做了两次softmax,梯度会变得很小,训练几乎不动。我刚开始学的时候就这么干过,loss一直卡在2.3左右不下降,排查了半天才发现是重复softmax的问题。
交叉熵损失的计算过程是这样的:先对logits做softmax得到概率分布,然后取真实标签对应的概率的负对数。对于一批数据,损失是所有样本的平均值。它的物理意义是衡量模型预测分布和真实分布之间的差异,差异越大,loss越大,梯度也越大,参数更新幅度就越大。
4.2 SGD还是Adam:入门阶段的选择
优化器的选择上,入门阶段我推荐用SGD加动量:
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)SGD的更新规则很直观:参数沿着梯度的反方向走一步,步长由学习率决定。加上动量之后,更新方向会考虑之前的梯度方向,相当于给优化过程加了一个"惯性",能加速收敛并且减少震荡。
Adam是另一种常用的优化器,它自适应地调整每个参数的学习率,通常收敛更快,对学习率的初始值不那么敏感。但在入门阶段,我建议先用SGD,因为它的行为更容易理解。你可以观察到学习率太大时loss会震荡甚至发散,学习率太小时收敛很慢,这种直观的感受对建立直觉很有帮助。
学习率设成0.01是一个经验值。对于MNIST加线性分类器这个组合,0.01配合动量0.9,大概5到10个epoch就能收敛到92%左右的准确率。如果你用Adam,学习率可以设成0.001,收敛会更快一些,但最终准确率差不多。
4.3 学习率调度器的初步尝试
如果你想更进一步,可以加一个简单的学习率调度器,比如每3个epoch把学习率乘以0.1:
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.1)然后在每个epoch结束后调用scheduler.step()。这样做的目的是在训练初期用较大的学习率快速下降,后期用较小的学习率精细调整。对于MNIST这种简单任务,调度器带来的提升有限,但养成这个习惯对以后训练更复杂的模型有好处。
5. 训练循环的完整实现与逐行解读
5.1 一个epoch里到底发生了什么
训练循环的代码看起来不长,但每一行都有它的作用。先看完整版本:
def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) epoch_loss = running_loss / total epoch_acc = correct / total return epoch_loss, epoch_accmodel.train()的作用是把模型设置为训练模式。对于线性分类器来说,它不影响任何层的行为,因为没有Dropout和BatchNorm。但养成这个习惯很重要,以后用到这些层的时候,忘了切换模式会导致结果完全错误。
optimizer.zero_grad()必须放在反向传播之前。PyTorch默认会累积梯度,如果你不清零,这次计算的梯度会加到上次的梯度上,相当于用了错误的学习率。我见过有人忘了写这一行,训练loss一直不下降,查了好久才发现。
loss.backward()计算所有参数的梯度,optimizer.step()根据梯度更新参数。这两步的顺序不能反,先算梯度再更新。
loss.item()把单元素张量转成Python浮点数。直接用loss累加的话,会保留计算图,导致内存泄漏。乘以images.size(0)是因为CrossEntropyLoss默认对一个批次取平均,乘以批次大小才能还原成总损失。
5.2 评估阶段与训练阶段的区别
评估阶段的代码和训练阶段很像,但有几个关键区别:
def evaluate(model, loader, criterion, device): model.eval() running_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) return running_loss / total, correct / totalmodel.eval()把模型切换到评估模式,影响Dropout和BatchNorm的行为。torch.no_grad()关闭梯度计算,减少显存占用并加速计算。评估阶段不需要反向传播,所以不需要zero_grad()、backward()和step()。
5.3 设备管理:CPU还是GPU
如果你的机器有可用的GPU,把模型和数据都搬到GPU上能显著加速:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LinearClassifier().to(device)注意,模型和数据必须在同一个设备上。如果模型在GPU上,数据在CPU上,会报错。常见的做法是在训练循环里把每个批次的数据都.to(device)。
对于MNIST加线性分类器这个规模,CPU和GPU的差距其实不大,因为模型太小,数据传输的开销可能比计算本身还大。但养成设备管理的习惯,以后训练大模型的时候就不会手忙脚乱。
6. 实测结果与常见问题排查
6.1 训练过程的典型曲线
我用SGD加动量0.9、学习率0.01、批次大小64,跑了10个epoch。训练集上的准确率从第一个epoch的89%左右稳步上升到第10个epoch的93%以上,测试集准确率最终在92%附近。loss从最初的0.4左右下降到0.2以下。
如果你跑出来的曲线和这个差距很大,比如loss一直不下降,或者准确率卡在10%左右(相当于随机猜测),那说明某个环节出了问题。下面是我整理的一些常见症状和对应的排查方向。
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| loss不下降,准确率约10% | 标签和输出维度不匹配 | 检查类别数和输出维度是否都是10 |
| loss震荡严重 | 学习率太大 | 把学习率降到0.001试试 |
| loss下降但准确率不涨 | 数据预处理有问题 | 检查归一化参数和标签对应关系 |
| 训练准确率高但测试低 | 过拟合 | 对于线性分类器,这种情况较少见 |
| 报错维度不匹配 | 展平或全连接层输入维度错误 | 打印每一步的shape |
6.2 我踩过的几个坑
第一个坑是忘记把数据搬到GPU上。模型用了.to(device),但数据没有,结果报错说张量在不同设备上。这个错误信息其实挺明确的,但新手可能不知道"device"指的是什么。
第二个坑是DataLoader的num_workers参数。在Windows上设成大于0的值可能会报错,因为多进程的实现方式和Linux不同。如果你在Windows上跑代码,建议把num_workers设成0,或者把训练代码放在if __name__ == '__main__':下面。
第三个坑是学习率设得太大。我一开始用0.1的学习率,loss直接飞到nan。后来降到0.01才稳定下来。对于SGD来说,MNIST上的学习率一般在0.001到0.1之间,0.01是一个比较安全的起点。
6.3 如何判断模型是否真的学到了东西
准确率是一个指标,但不够直观。我建议在测试集上随机抽几张图片,把模型的预测结果和真实标签打印出来看看:
model.eval() with torch.no_grad(): images, labels = next(iter(test_loader)) images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) for i in range(5): print(f"真实标签: {labels[i].item()}, 预测: {predicted[i].item()}")如果预测结果大部分正确,说明模型确实学到了数字的特征。如果预测结果看起来很随机,那就要回头检查训练过程了。
7. 从线性分类器继续往前走的方向
线性分类器跑通之后,你可以尝试几个方向来加深理解。第一个方向是增加隐藏层,把模型从线性变成非线性。加一个128维的隐藏层和ReLU激活,准确率能提升到97%左右。第二个方向是换成卷积网络,利用图像的局部相关性,准确率能到99%以上。第三个方向是调整超参数,比如批次大小、学习率、优化器类型,观察它们对训练过程和最终结果的影响。
但不管往哪个方向走,核心的训练循环是不变的:取数据、前向传播、算损失、反向传播、更新参数。把这套流程理解透了,换任何模型都只是替换中间的网络结构而已。
我个人在实际操作中的体会是,入门阶段最值得花时间的不是调参,而是把每一个API的作用搞清楚。比如optimizer.zero_grad()为什么要放在那个位置,loss.item()和loss有什么区别,model.train()和model.eval()分别影响什么。这些问题看起来琐碎,但它们是构建完整知识体系的基础。等你能不查文档就写出一个完整的训练循环,并且能解释每一行的作用,那这个入门项目的目的就达到了。