摘要
MNIST数据集是深度学习领域的"Hello World",它包含70,000张28x28的灰度手写数字图像,是入门图像分类任务的绝佳选择。本文将手把手带你使用PyTorch构建一个简洁而高效的卷积神经网络(CNN),完成对MNIST数据集的训练与评估。文章会逐步解读每一段代码的含义,从数据加载、模型构建、训练循环到测试评估,并结合完整的训练日志分析模型的收敛过程。最终我们的模型在测试集上达到了99.03%的准确率,损失值从最初的2.3逐步下降至接近0,直观展示了CNN在图像识别任务上的强大能力。无论你是深度学习新手还是希望巩固PyTorch基础,相信本文都能带给你满满的收获。
一、引言
手写数字识别是计算机视觉的经典问题之一,广泛应用于邮政编码识别、银行支票处理等场景。传统方法依赖手工设计的特征提取器(如HOG、SIFT)结合支持向量机等分类器,但卷积神经网络的出现彻底改变了这一格局。CNN通过局部感受野和权值共享,能够自动从像素级数据中学习到层次化的特征表示,极大地提升了识别精度。
二、环境配置与数据集
2.1 MNIST数据集简介
MNIST由美国国家标准与技术研究所(NIST)整理,共包含70,000张手写数字图像:60,000张用于训练,10,000张用于测试。每张图像是28x28像素的灰度图,纯黑背景,白色前景,且数字已经居中并归一化尺寸。这种预处理简化了我们后续的建模工作。
数据集共分为10个类别,对应数字0~9。各类别在训练和测试集中分布较均衡,可直接用于训练分类模型。
2.2 加载数据集
PyTorch提供了torchvision.datasets模块,内置了MNIST数据集的下载与加载接口,非常方便。
python
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets
from torchvision.transforms import ToTensor
# 下载训练集
training_data = datasets.MNIST(
root="data",
train=True,
download=True,
transform=ToTensor(),
)
# 下载测试集
test_data = datasets.MNIST(
root="data",
train=False,
download=True,
transform=ToTensor(),
)
参数解释:
root:数据集存放的根目录,若本地不存在则会自动下载。train=True:加载训练集,train=False则加载测试集。download=True:若本地未下载,则从互联网自动下载。transform=ToTensor():将PIL图像或numpy数组转化为PyTorch的张量(tensor),并将像素值从0,255归一化到0.0,1.0。
2.3 DataLoader的使用
直接使用datasets对象会逐张读取图片,训练时我们希望以小批量(mini-batch)的方式输入,以提高计算效率并加速收敛。DataLoader正是为此设计的。
python
train_dataloader = DataLoader(training_data, batch_size=64)
test_dataloader = DataLoader(test_data, batch_size=64)
这里设置batch_size=64,即每次向模型喂入64张图像。打印一个batch的形状:
python
for X, y in test_dataloader:
print(f"Shape of X [N, C, H, W]: {X.shape}")
print(f"Shape of y: {y.shape} {y.dtype}")
break
输出:
Shape of X [N, C, H, W]: torch.Size([64, 1, 28, 28])
Shape of y: torch.Size([64]) torch.int64
X的形状为[batch_size, channels, height, width],这里channels=1表示灰度图。y是长度为64的一维张量,存储每张图片对应的标签。
三、定义卷积神经网络模型
3.1 网络结构设计
我们设计一个较为简洁的CNN网络,包含三个卷积块和一个全连接输出层:
- Conv1:输入1通道,输出16通道,卷积核5x5,padding=2保持尺寸,后接ReLU和2x2最大池化 → 输出16×14×14
- Conv2:输入16通道,先卷积至32通道,再卷积保持32通道,后接ReLU和2x2池化 → 输出32×7×7
- Conv3:输入32通道,卷积至64通道,后接ReLU(无池化) → 输出64×7×7
- 全连接层:将64×7×7的特征图展平为3136维向量,送入线性层,输出10维(对应10个数字类别)
这种设计并非最优,但兼顾了模型容量与训练速度,非常适合教学演示。
3.2 模型代码
python
class CNN(nn.Module):
def __init__(self):
super(CNN, self).__init__()
self.conv1 = nn.Sequential(
nn.Conv2d(1, 16, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2),
)
self.conv2 = nn.Sequential(
nn.Conv2d(16, 32, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.Conv2d(32, 32, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2),
)
self.conv3 = nn.Sequential(
nn.Conv2d(32, 64, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
)
self.out = nn.Linear(64 * 7 * 7, 10)
def forward(self, x):
x = self.conv1(x) # -> [16,14,14]
x = self.conv2(x) # -> [32,7,7]
x = self.conv3(x) # -> [64,7,7]
x = x.view(x.size(0), -1) # flatten -> [batch_size, 3136]
output = self.out(x)
return output
model = CNN().to(device)
print(model)
关键点解读:
nn.Sequential:将多个层按顺序组合,简化forward定义。Conv2d参数:in_channels, out_channels, kernel_size, stride, padding。此处padding=2保证卷积后尺寸不变(当kernel=5, stride=1时)。MaxPool2d(kernel_size=2):将特征图长宽各缩一半。view(x.size(0), -1):展平操作,保留batch维度。.to(device):将模型参数迁移到GPU或CPU。
打印模型结构:
CNN(
(conv1): Sequential(...)
(conv2): Sequential(...)
(conv3): Sequential(...)
(out): Linear(in_features=3136, out_features=10, bias=True)
)
四、训练与测试流程
4.1 选择设备
允许无缝切换CPU和GPU。我们增加对Apple MPS(M系列芯片GPU)的支持:
python
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
print(f"Using {device} device")
示例运行结果显示Using cuda device,即使用NVIDIA GPU训练。
4.2 定义损失函数和优化器
- 损失函数 :多分类任务使用
CrossEntropyLoss,它内部集成了Softmax和负对数似然损失。 - 优化器 :选择
Adam,设置学习率lr=0.001。
python
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
4.3 训练函数
训练循环的典型步骤:前向传播 → 计算损失 → 梯度清零 → 反向传播 → 参数更新。
python
def train(dataloader, model, loss_fn, optimizer):
model.train() # 开启训练模式(启用dropout、batchnorm等)
batch_size_num = 1
for X, y in dataloader:
X, y = X.to(device), y.to(device)
pred = model(X)
loss = loss_fn(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss = loss.item()
print(f"loss: {loss:>7f} [number:{batch_size_num}]")
batch_size_num += 1
详解:
model.train():设置模型为训练模式,影响某些层(如Dropout)的行为。本文未使用这些层,但保留此习惯。optimizer.zero_grad():清除上一轮梯度,避免累加。loss.backward():计算梯度。optimizer.step():根据梯度更新权重。- 打印每个batch的损失,方便观察下降趋势。
4.4 测试函数
测试时不更新参数,需要关闭梯度计算以节省内存。
python
def test(dataloader, model, loss_fn):
size = len(dataloader.dataset)
num_batches = len(dataloader)
model.eval() # 进入评估模式
test_loss, correct = 0, 0
with torch.no_grad(): # 不计算梯度
for X, y in dataloader:
X, y = X.to(device), y.to(device)
pred = model(X)
test_loss += loss_fn(pred, y).item()
correct += (pred.argmax(1) == y).type(torch.float).sum().item()
test_loss /= num_batches
correct /= size
print(f"Test result: \n Accuracy: {(100*correct):>0.1f}%, Avg loss: {test_loss:>8f} \n")
model.eval():固定BN、Dropout等层。torch.no_grad():不构建计算图,减少资源消耗。pred.argmax(1):输出每一行最大值的索引,即预测类别。
五、训练过程与日志分析
5.1 运行配置
我们设置10个epoch(遍历整个训练集10次)。
python
epochs = 10
for t in range(epochs):
print(f"Epoch {t+1}\n-------------------------------")
train(train_dataloader, model, loss_fn, optimizer)
print("Done!")
test(test_dataloader, model, loss_fn)
5.2 训练日志节选与解读
训练集包含60,000张图片,batch_size=64,因此每个epoch有938个batch(60,000/64 ≈ 938)。日志中每个batch打印一次损失值。以下为关键节点分析:
Epoch 1
- 初始loss = 2.3039,这是随机预测下的交叉熵(-ln(0.1) ≈ 2.3026),说明模型起初完全随机判断。
- 随着训练进行,loss快速下降,到第100个batch已降至0.1447,第200个batch为0.2292,第500个batch为0.0176,第900个batch降至0.0001。说明网络学习速度非常快,第一个epoch结束时,模型已经对训练集有较高的拟合。
Epoch 2~5
- 损失继续降低,第2轮开始loss约0.02,到第5轮时很多batch损失已经达到1e-4甚至1e-5量级。
- 说明Adam优化器配合CNN结构能高效收敛。
Epoch 6~10
- 损失保持极低水平,部分batch损耗已到1e-6,模型几乎完全记住了训练样本。
- 但仍有个别batch损失异常增高(如Epoch 6的某些batch达到0.1以上),这可能是由于mini-batch中存在较难样本或标签噪声,但整体已非常稳定。
最终测试
Test result:
Accuracy: 99.03%, Avg loss: 0.04704595426646583
测试集准确率达到99.03%,平均损失仅0.047,表明模型泛化能力良好,未发生过拟合(因为训练损失极低,测试损失稍高但仍较小,且准确率高)。
六、完整代码整合
为了方便读者复现,这里将核心代码整理为完整的可执行脚本(已省略matplotlib部分):
python
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets
from torchvision.transforms import ToTensor
# 1. 加载数据
training_data = datasets.MNIST(
root="data",
train=True,
download=True,
transform=ToTensor(),
)
test_data = datasets.MNIST(
root="data",
train=False,
download=True,
transform=ToTensor(),
)
train_dataloader = DataLoader(training_data, batch_size=64)
test_dataloader = DataLoader(test_data, batch_size=64)
# 2. 设备选择
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
print(f"Using {device} device")
# 3. 定义模型
class CNN(nn.Module):
def __init__(self):
super(CNN, self).__init__()
self.conv1 = nn.Sequential(
nn.Conv2d(1, 16, 5, 1, 2),
nn.ReLU(),
nn.MaxPool2d(2),
)
self.conv2 = nn.Sequential(
nn.Conv2d(16, 32, 5, 1, 2),
nn.ReLU(),
nn.Conv2d(32, 32, 5, 1, 2),
nn.ReLU(),
nn.MaxPool2d(2),
)
self.conv3 = nn.Sequential(
nn.Conv2d(32, 64, 5, 1, 2),
nn.ReLU(),
)
self.out = nn.Linear(64 * 7 * 7, 10)
def forward(self, x):
x = self.conv1(x)
x = self.conv2(x)
x = self.conv3(x)
x = x.view(x.size(0), -1)
return self.out(x)
model = CNN().to(device)
# 4. 损失函数与优化器
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
# 5. 训练和测试函数
def train(dataloader, model, loss_fn, optimizer):
model.train()
for X, y in dataloader:
X, y = X.to(device), y.to(device)
pred = model(X)
loss = loss_fn(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
def test(dataloader, model, loss_fn):
model.eval()
test_loss, correct = 0, 0
with torch.no_grad():
for X, y in dataloader:
X, y = X.to(device), y.to(device)
pred = model(X)
test_loss += loss_fn(pred, y).item()
correct += (pred.argmax(1) == y).type(torch.float).sum().item()
size = len(dataloader.dataset)
test_loss /= len(dataloader)
correct /= size
print(f"Test Accuracy: {100*correct:.2f}%, Avg loss: {test_loss:.6f}")
# 6. 开始训练
epochs = 10
for t in range(epochs):
print(f"Epoch {t+1}\n-------------------------------")
train(train_dataloader, model, loss_fn, optimizer)
test(test_dataloader, model, loss_fn)