PyTorch入门实战:从MNIST手写数字识别掌握深度学习全流程

📅 2026/8/4 3:40:23 👁️ 阅读次数 📝 编程学习
PyTorch入门实战:从MNIST手写数字识别掌握深度学习全流程

1. 项目缘起:为什么从MNIST开始你的PyTorch之旅?

如果你刚接触深度学习,面对PyTorch、TensorFlow这些框架,感觉像面对一个装满精密零件的工具箱,不知从何下手。我的建议是,别急着去造火箭,先从拧螺丝开始。而MNIST手写数字识别,就是那颗最经典、最标准的“螺丝”。这个项目几乎成了深度学习界的“Hello World”,不是因为它简单到无聊,恰恰相反,它麻雀虽小,五脏俱全。你能在这里面接触到数据加载、模型定义、训练循环、评估测试这一整套标准流程,而且计算资源要求极低,用CPU也能在几分钟内跑出不错的结果。我见过太多新手一上来就想搞图像生成、大语言模型,结果在环境配置、复杂概念和漫长的训练等待中耗尽热情,最后不了了之。从MNIST开始,你能快速获得正反馈,看到模型从乱猜(准确率10%)到基本能认(准确率98%以上)的整个过程,这种成就感是持续学习最好的燃料。

2. 环境搭建:避开那些“看起来简单”的坑

在真正写代码之前,把环境搭对、搭稳,能避免后面80%的莫名错误。很多人觉得安装就是pip install torch一行命令的事,但实际踩的坑可能比写代码还多。

2.1 PyTorch安装:选对版本,一步到位

去PyTorch官网,你会看到一个用来自动生成安装命令的配置器。这里的选择至关重要,直接关系到你能不能调用GPU加速。核心就三样:PyTorch版本、你的操作系统、CUDA版本。

首先,版本选择:对于入门,我强烈建议选择当前官网推荐的、最新的稳定版(Stable)。新版本通常修复了更多bug,有更好的文档和社区支持。不要为了“稳定”而去选择一个很老的版本,那可能会遇到依赖库不兼容的问题。

其次,CUDA版本:这是决定能否使用GPU的关键。CUDA是NVIDIA的并行计算平台。打开你的命令行,输入nvidia-smi,在输出信息里找到“CUDA Version”这一项。这个版本号是你的显卡驱动最高能支持的CUDA版本。比如显示“12.4”,意味着你可以安装≤12.4的CUDA。然后,你需要在系统里实际安装一个≤此版本的CUDA Toolkit(比如11.8或12.1)。最后,在PyTorch安装器上选择与你安装的CUDA Toolkit版本匹配的选项。如果没GPU或不想用,就选“CPU”版本。

注意:经常有人问“我显卡是Intel Arc,怎么装PyTorch?”对于Intel独立显卡,目前PyTorch的GPU加速主要依赖CUDA(NVIDIA)和ROCm(AMD)。Intel显卡的加速需要通过Intel的扩展库(如Intel Extension for PyTorch, IPEX)来实现,配置更为复杂。入门阶段,如果你的电脑是Intel Arc显卡,我建议先使用CPU版本的PyTorch完成学习,等熟悉基础后再研究GPU加速的配置。

最后,复制命令安装:在官网选好配置后,它会给你一行pipconda命令。我推荐使用conda,因为它能更好地处理环境隔离和依赖冲突。打开Anaconda Prompt(如果你装了Miniconda或Anaconda),先创建一个独立环境是个好习惯:

conda create -n pytorch_mnist python=3.9 conda activate pytorch_mnist

然后粘贴官网生成的conda install命令进行安装。安装完成后,在Python里跑一下import torch; print(torch.__version__); print(torch.cuda.is_available()),确保导入成功,并且如果装了GPU版,这里能返回True

2.2 配套工具:选一个顺手的“编辑器”

代码写在哪?Jupyter Notebook非常适合这种探索性、教程类的学习。它能分段运行代码,即时看到结果和图表,对于理解数据、调试模型特别友好。你可以通过conda install jupyter安装,然后jupyter notebook启动。当然,如果你习惯用PyCharm、VSCode这类集成开发环境(IDE),也完全没问题,它们对代码提示、调试的支持更强大。选择你用得最顺手的一个即可。

3. 数据准备:理解你的“原料”MNIST

模型像厨师,数据就是食材。不了解食材,做不出好菜。MNIST数据集包含6万张训练图和1万张测试图,每张都是28x28像素的灰度手写数字(0-9)。

3.1 下载与加载:利用torchvision省时省力

手动下载数据集然后读取?那是十年前的做法了。PyTorch的torchvision库提供了现成的接口。但这里有个常见坑点:网络下载失败。因为数据集源站在国外,直接下载可能会非常慢甚至超时。

import torch from torchvision import datasets, transforms # 定义一个数据转换管道 transform = transforms.Compose([ transforms.ToTensor(), # 将PIL图像或NumPy数组转换为PyTorch张量 (Tensor) transforms.Normalize((0.1307,), (0.3081,)) # 标准化,这里的均值0.1307和标准差0.3081是MNIST数据集的全局统计值 ]) # 尝试下载并加载训练集和测试集 try: train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform) test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform) except Exception as e: print(f"下载失败: {e}") print("请尝试:") print("1. 检查网络连接,特别是能否访问外网。") print("2. 手动下载:搜索‘MNIST数据集下载’,通常可以找到四个.gz文件(train-images-idx3-ubyte.gz等)。") print("3. 下载后,放入 ‘./data/MNIST/raw/’ 目录下,再次运行代码,程序会检测到本地文件而跳过下载。")

transforms.ToTensor()做了两件重要的事:第一,把图像数据从[0, 255]的整数范围,转换到[0.0, 1.0]的浮点数范围,这对神经网络训练更友好;第二,改变了数据的维度,从原始的(高度,宽度)变成了(通道数,高度,宽度)。MNIST是灰度图,所以通道数是1。transforms.Normalize则进行标准化,减去均值再除以标准差,可以让数据分布更集中,加速模型收敛。这里的(0.1307,)和(0.3081,)是预先计算好的MNIST全局像素均值和标准差。

3.2 数据可视化:看一眼你的数据

在投入训练前,花几分钟看看数据长什么样,这是很好的习惯,能帮你发现数据加载是否出错。

import matplotlib.pyplot as plt # 从训练集中取一个批次的数据样本 figure = plt.figure(figsize=(8, 8)) cols, rows = 5, 5 for i in range(1, cols * rows + 1): sample_idx = torch.randint(len(train_dataset), size=(1,)).item() img, label = train_dataset[sample_idx] # img的形状现在是[1, 28, 28],需要去掉通道维度才能用matplotlib显示 img = img.squeeze() # 变成[28, 28] ax = figure.add_subplot(rows, cols, i) ax.set_title(label) ax.axis("off") ax.imshow(img, cmap="gray") plt.show()

运行这段代码,你会看到一个5x5的网格,显示25个随机的手写数字及其标签。这能直观确认数据加载正确,同时也能感受到手写数字的多样性(有的工整,有的潦草),理解我们任务的挑战性。

3.3 制作数据加载器:DataLoader的作用

数据集(Dataset)存储了所有样本和标签,但我们训练时通常不是一次把所有数据扔进模型,而是分成一小批一小批(batch)地喂进去。这样做有两个好处:一是对内存更友好;二是批数据计算出的梯度带有一定的噪声,这种噪声有时反而能帮助模型跳出局部最优解,找到更好的解。DataLoader就是干这个的。

from torch.utils.data import DataLoader batch_size = 64 # 一个常见的起始值,不大不小 train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)

这里的关键参数是shuffle。训练时一定要shuffle=True(打乱顺序),防止模型学到数据顺序带来的偏见。测试时用shuffle=False,保证每次评估的顺序一致,结果可复现。batch_size可以调整,太大会占用更多内存,太小可能导致梯度更新不稳定。64是一个不错的起点。

4. 模型构建:设计你的第一个神经网络

现在来到核心部分:构建模型。对于MNIST这种28x28的较小图像,一个简单的卷积神经网络(CNN)就足够了。CNN能自动提取图像的局部特征(如边缘、角点),比全连接网络(Dense Network)效果更好、参数更少。

4.1 网络结构设计:从输入到输出的旅程

我们来搭建一个经典的LeNet-5简化版。理解每一层的输入输出形状变化是掌握CNN的关键。

import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super(Net, self).__init__() # 第一个卷积层:输入通道1(灰度图),输出通道32,卷积核3x3 self.conv1 = nn.Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1) # 第二个卷积层:输入通道32,输出通道64,卷积核3x3 self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) # 最大池化层:窗口2x2,步长2 self.pool = nn.MaxPool2d(kernel_size=2, stride=2) # 第一个全连接层:输入维度是多少?我们需要计算一下 # 经过两次[卷积->池化]后,特征图大小变化:28x28 -> (池化)14x14 -> (池化)7x7 # 此时特征图的通道数是64,所以展平后的向量长度是 64 * 7 * 7 = 3136 self.fc1 = nn.Linear(64 * 7 * 7, 128) # 全连接层,输出128维 self.fc2 = nn.Linear(128, 10) # 输出层,10个类别(数字0-9) # Dropout层,用于防止过拟合,训练时随机“丢弃”一部分神经元 self.dropout = nn.Dropout(p=0.5) def forward(self, x): # x 的形状: [batch_size, 1, 28, 28] x = self.pool(F.relu(self.conv1(x))) # -> [batch_size, 32, 14, 14] x = self.pool(F.relu(self.conv2(x))) # -> [batch_size, 64, 7, 7] # 将特征图展平成一维向量,为全连接层做准备 x = x.view(-1, 64 * 7 * 7) # -> [batch_size, 3136] x = self.dropout(F.relu(self.fc1(x))) # -> [batch_size, 128] # 输出层不需要ReLU激活函数,因为后面会接CrossEntropyLoss,它内部包含了LogSoftmax x = self.fc2(x) # -> [batch_size, 10] return x # 实例化模型 model = Net() print(model)

逐行解释:

  • nn.Conv2d: 卷积层,核心是学习一组滤波器(卷积核),在图像上滑动进行特征提取。padding=1是为了保持卷积后特征图的空间尺寸不变(当kernel_size=3时)。
  • nn.MaxPool2d: 池化层,进行下采样,减少数据量,同时保留主要特征,增强模型对微小位移的鲁棒性。
  • nn.Linear: 全连接层,将学习到的特征映射到最终的分类空间。
  • F.relu: 激活函数,引入非线性,让网络能够拟合复杂函数。这里使用函数式调用方式。
  • nn.Dropout: 正则化技术,随机让一部分神经元失活,强迫网络学习更鲁棒的特征,减轻过拟合。注意,它只在训练时起作用,在模型评估(model.eval())时会自动关闭。
  • x.view(-1, 64*7*7): 这是改变张量形状的操作。-1表示让PyTorch自动计算这个维度的大小(通常是batch_size)。这一步将四维的[batch, channel, height, width]张量,拉平成二维的[batch, features],以便输入全连接层。

为什么是两次池化后得到7x7?初始输入是28x28。第一次卷积(padding=1)后还是28x28,池化(窗口2,步长2)后变成14x14。第二次卷积后14x14,再次池化后变成7x7。这是一个经典的空间尺寸减半过程。

4.2 模型参数量与设备转移

我们可以快速看一下这个模型有多少参数:

total_params = sum(p.numel() for p in model.parameters()) print(f"模型总参数量: {total_params:,}")

这个数字大概在几十万量级,对于MNIST任务来说完全足够,也保证了训练速度。

接下来,如果有GPU,我们需要把模型和数据都放到GPU上,以利用其并行计算能力加速训练。

device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"使用设备: {device}") model = model.to(device)

这一步很重要。模型定义在CPU上,通过.to(device)将其参数和缓冲区转移到指定设备。之后,每一个输入模型的张量也需要通过.to(device)转移到同一个设备上,否则会报错。

5. 训练循环:让模型从“猜”到“认”

这是整个流程中最像“炼丹”的部分。我们需要定义损失函数(告诉模型它错得有多离谱)、优化器(告诉模型如何改正错误),然后在一个循环中反复迭代。

5.1 定义损失函数与优化器

对于十分类任务,交叉熵损失(Cross-Entropy Loss)是标准选择。在PyTorch中,nn.CrossEntropyLoss已经将LogSoftmax和NLLLoss结合好了,所以我们的模型输出层不需要再加Softmax。

优化器负责根据损失函数的梯度来更新模型的参数。Adam优化器是当前最流行、默认效果往往不错的选择,它自适应地调整每个参数的学习率。

import torch.optim as optim criterion = nn.CrossEntropyLoss() # 损失函数 optimizer = optim.Adam(model.parameters(), lr=0.001) # 优化器,学习率设为0.001

学习率(lr)是一个超参数,控制每次参数更新的步长。太大可能导致训练震荡甚至发散,太小则训练缓慢。0.001或0.0001是常见的起始值。

5.2 编写训练与评估函数

为了让代码更清晰,我们把训练一个epoch和评估测试集的过程写成函数。

def train_one_epoch(model, device, train_loader, optimizer, criterion, epoch): model.train() # 将模型设置为训练模式(启用Dropout等) running_loss = 0.0 correct = 0 total = 0 for batch_idx, (data, target) in enumerate(train_loader): data, target = data.to(device), target.to(device) # 数据转移到设备 optimizer.zero_grad() # **关键步骤**:清空上一轮计算的梯度 output = model(data) # 前向传播,得到预测输出 loss = criterion(output, target) # 计算损失 loss.backward() # 反向传播,计算梯度 optimizer.step() # 优化器根据梯度更新参数 # 统计信息 running_loss += loss.item() _, predicted = output.max(1) # 获取预测类别(最大值的索引) total += target.size(0) correct += predicted.eq(target).sum().item() # 每处理一定批次的数据,打印一次进度 if batch_idx % 100 == 0: print(f'Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} ' f'({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f}') avg_loss = running_loss / len(train_loader) accuracy = 100. * correct / total print(f'\n训练集平均损失: {avg_loss:.4f}, 准确率: {accuracy:.2f}%') return avg_loss, accuracy def evaluate(model, device, test_loader, criterion): model.eval() # 将模型设置为评估模式(关闭Dropout等) test_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): # **关键步骤**:关闭梯度计算,节省内存和计算资源 for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) test_loss += criterion(output, target).item() # 累加损失 _, predicted = output.max(1) total += target.size(0) correct += predicted.eq(target).sum().item() avg_loss = test_loss / len(test_loader) accuracy = 100. * correct / total print(f'测试集平均损失: {avg_loss:.4f}, 准确率: {accuracy:.2f}%\n') return avg_loss, accuracy

几个关键点解释:

  • model.train()model.eval():这会影响像DropoutBatchNorm这样的层在训练和推理时的不同行为。训练时必须用.train(),评估时必须用.eval()
  • optimizer.zero_grad():PyTorch的梯度是累加的。如果不清零,下一次loss.backward()时梯度会与上一次的梯度相加,这绝对不是我们想要的。所以每次计算新梯度前必须清零
  • with torch.no_grad():在评估模型时,我们不需要计算梯度(因为不更新参数)。这个上下文管理器能显著减少内存消耗并加速计算。
  • output.max(1)output的形状是[batch_size, 10]max(1)表示在第1个维度(类别维度)上取最大值,返回最大值和其索引。我们只需要索引(predicted),它代表了模型预测的数字。

5.3 启动训练与保存模型

现在,把一切串起来,开始真正的训练循环。

num_epochs = 10 # 训练轮数,可以调整 train_losses, train_accs = [], [] test_losses, test_accs = [], [] for epoch in range(1, num_epochs + 1): print(f'\n--- Epoch {epoch}/{num_epochs} ---') train_loss, train_acc = train_one_epoch(model, device, train_loader, optimizer, criterion, epoch) test_loss, test_acc = evaluate(model, device, test_loader, criterion) train_losses.append(train_loss) train_accs.append(train_acc) test_losses.append(test_loss) test_accs.append(test_acc) # 训练完成后,保存模型的状态字典 torch.save(model.state_dict(), 'mnist_cnn_model.pth') print("模型已保存为 'mnist_cnn_model.pth'")

运行这段代码,你会看到控制台开始滚动输出,显示每个epoch的训练损失、准确率以及测试集上的表现。理想情况下,训练损失和测试损失都应该逐渐下降,训练准确率和测试准确率逐渐上升,并且两者差距不大(如果测试准确率远低于训练准确率,可能是过拟合)。

保存模型使用torch.save(model.state_dict(), ...)state_dict()是一个Python字典,包含了模型的所有可学习参数(权重和偏置)。保存这个比保存整个模型对象更轻量、更灵活。

6. 结果分析与模型调优:从“能用”到“好用”

训练完成后,我们得到了一个准确率可能超过98%的模型。但这还不够,我们需要分析它,并知道如何让它变得更好。

6.1 可视化训练过程

绘制损失和准确率曲线,能直观看出模型的学习情况。

import matplotlib.pyplot as plt epochs_range = range(1, num_epochs + 1) plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(epochs_range, train_losses, 'b-', label='Training Loss') plt.plot(epochs_range, test_losses, 'r-', label='Test Loss') plt.xlabel('Epochs') plt.ylabel('Loss') plt.title('Training and Test Loss') plt.legend() plt.grid(True) plt.subplot(1, 2, 2) plt.plot(epochs_range, train_accs, 'b-', label='Training Accuracy') plt.plot(epochs_range, test_accs, 'r-', label='Test Accuracy') plt.xlabel('Epochs') plt.ylabel('Accuracy (%)') plt.title('Training and Test Accuracy') plt.legend() plt.grid(True) plt.tight_layout() plt.show()

如何解读曲线?

  • 理想情况:两条损失曲线都平稳下降,最后趋于平缓;两条准确率曲线都平稳上升,最后趋于平缓,且训练和测试的最终值很接近。
  • 过拟合:训练损失持续下降,训练准确率持续上升,但测试损失在某个点后开始上升,测试准确率停滞甚至下降。这意味着模型把训练数据的噪声也学进去了,泛化能力变差。解决办法:增加Dropout比率、增加数据增强、简化模型结构、使用更早的停止训练(Early Stopping)。
  • 欠拟合:训练和测试的损失/准确率都很早就停滞在一个不理想的水平。这意味着模型能力不足,无法捕捉数据中的模式。解决办法:增加模型复杂度(更多层、更多通道)、训练更长时间、减少正则化强度。

6.2 查看模型在哪些数字上容易出错

光看总体准确率不够,我们还需要一个混淆矩阵(Confusion Matrix)来查看模型在各个类别上的具体表现。

from sklearn.metrics import confusion_matrix import seaborn as sns import numpy as np model.eval() all_preds = [] all_targets = [] with torch.no_grad(): for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) _, preds = output.max(1) all_preds.extend(preds.cpu().numpy()) all_targets.extend(target.cpu().numpy()) cm = confusion_matrix(all_targets, all_preds) plt.figure(figsize=(10, 8)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=range(10), yticklabels=range(10)) plt.xlabel('Predicted Label') plt.ylabel('True Label') plt.title('Confusion Matrix on Test Set') plt.show()

混淆矩阵的对角线数字越大越好,表示预测正确。非对角线的数字表示预测错误。比如,你可能会发现模型容易把“9”预测成“4”,或者把“5”预测成“6”。这能给你提供调优的线索:是不是某些数字的写法本身就很像?是不是训练数据中某些类别的样本太少?

6.3 加载模型进行单张图片预测

保存的模型如何用?我们来演示加载模型,并对一张新的图片(从测试集中取一张)进行预测。

# 加载保存的模型参数 loaded_model = Net() # 必须先实例化一个结构相同的模型 loaded_model.load_state_dict(torch.load('mnist_cnn_model.pth', map_location=device)) loaded_model = loaded_model.to(device) loaded_model.eval() # 别忘了切换到评估模式 # 从测试集中取一张图片 sample_data, sample_label = test_dataset[0] # 给数据增加一个batch维度,因为模型输入要求是 [batch, channel, height, width] sample_data = sample_data.unsqueeze(0).to(device) with torch.no_grad(): output = loaded_model(sample_data) prediction = output.argmax(dim=1).item() print(f'真实标签: {sample_label}') print(f'模型预测: {prediction}') # 可视化这张图片 plt.imshow(sample_data.cpu().squeeze(), cmap='gray') plt.title(f'True: {sample_label}, Pred: {prediction}') plt.axis('off') plt.show()

unsqueeze(0)是在最前面增加一个维度,将[1,28,28]变成[1,1,28,28],表示batch_size为1。argmax(dim=1)在类别维度上取最大值索引,即得到预测的数字。

7. 进阶思考与优化方向

当你跑通整个流程,并达到一个不错的准确率后,可以尝试以下方向来深化理解或提升效果:

7.1 超参数调优

我们之前用的学习率(0.001)、批大小(64)、Dropout率(0.5)都是经验值。你可以系统地调整它们,观察对结果的影响。

  • 学习率:尝试0.01, 0.001, 0.0001。通常可以使用学习率调度器(如torch.optim.lr_scheduler.StepLR),在训练过程中动态降低学习率。
  • 批大小:尝试32, 64, 128。更小的batch可能带来更好的泛化性能,但训练更慢、更震荡;更大的batch训练更稳定、更快,但可能泛化稍差。
  • 优化器:除了Adam,可以试试SGD(随机梯度下降),特别是配合动量(momentum)和学习率衰减,有时在更精细的调参下能取得比Adam更好的最终效果。
  • 网络结构:增加卷积层深度或通道数,或者加入BatchNorm层(nn.BatchNorm2d)来加速训练并提升稳定性。

7.2 数据增强

这是提升模型泛化能力、防止过拟合的利器。对于图像任务,可以在训练时对图像进行随机变换,生成“新”的训练样本。torchvision.transforms提供了很多工具。

from torchvision import transforms # 增强版的训练数据转换 train_transform = transforms.Compose([ transforms.RandomRotation(10), # 随机旋转 ±10度 transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)), # 随机平移 transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) # 测试集不要做数据增强,只用最基本的转换 test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])

然后用train_transform重新创建训练集的DataLoader。这样,模型在每个epoch看到的同一张图片都可能略有不同,强迫它学习更本质的特征,而不是记住像素点的精确位置。

7.3 尝试不同的网络架构

LeNet只是一个开始。你可以尝试更现代的架构,比如在torchvision.models里有一些经典的CNN模型(虽然它们是为ImageNet设计的,但你可以修改最后的全连接层来适配10分类)。更重要的是,理解这些架构的设计思想,比如VGG的堆叠小卷积核、ResNet的残差连接。

7.4 编写更工程化的代码

把模型定义、训练循环、工具函数分别放到不同的.py文件里。使用argparse库来管理命令行参数(如epoch数、学习率、批大小)。使用TensorBoard或Weights & Biases等工具来更美观、更实时地记录和可视化训练过程。这些实践在真正的项目开发中至关重要。

跑完这个完整的MNIST项目,你收获的不仅仅是一个能识别数字的程序,而是一套完整的深度学习项目工作流。下次当你面对一个新的图像分类任务时,你会发现流程是相通的:准备数据、构建模型、训练评估、调优分析。变化的只是数据本身、模型复杂度和一些超参数。这才是入门教程真正想带给你的东西——一把能打开深度学习大门的钥匙。