机器学习数学基础专题【左扬精讲】—— 基于卷积的 MNIST 手写体分类实战
机器学习数学基础专题【左扬精讲】—— 基于卷积的 MNIST 手写体分类实战
在上一篇文章中,我们系统学习了卷积神经网络的基础知识,包括卷积运算的数学原理、PyTorch 中 Conv2d 的使用方法、池化操作以及 Softmax 激活函数。本篇文章将这些理论知识付诸实践,从零开始构建一个完整的 MNIST 手写数字识别系统。
MNIST 数据集是深度学习入门的"Hello World",它包含 0-9 的手写数字灰度图像。通过这个实战项目,你将掌握数据加载、模型构建、训练循环、模型评估的完整流程,为更复杂的视觉任务打下坚实基础。
torchvision.datasets.MNIST ← MNIST 数据集加载
torch.utils.data.DataLoader ← 数据迭代器
torch.nn.Conv2d ← 卷积层
torch.nn.MaxPool2d ← 最大池化层
torch.nn.Linear ← 全连接层
torch.optim.SGD ← 随机梯度下降优化器
torch.nn.CrossEntropyLoss ← 交叉熵损失函数
matplotlib.pyplot ← 可视化工具
PyTorchMNISTCNN图像分类数据加载模型训练
学习重点
- 必须掌握
- MNIST 数据集的下载、加载与预处理流程
- 基于 torch.nn.Conv2d 构建 CNN 分类模型
- 训练循环的完整实现:前向传播、损失计算、反向传播、参数更新
- 模型评估:计算准确率、绘制混淆矩阵
- 理解即可
- 数据增强的基本方法
- 学习率调度策略
目录
一、数据的准备
What — MNIST 数据集是什么?
MNIST(Modified National Institute of Standards and Technology)数据集是深度学习领域最经典的数据集之一,被广泛应用于机器学习和计算机视觉的入门学习。它包含 70,000 张手写数字图像,其中 60,000 张用于训练,10,000 张用于测试。
MNIST 数据集的特点:
- 图像尺寸:28x28 像素的灰度图像
- 类别数量:10 个类别(0-9 的手写数字)
- 像素值范围:0-255(灰度值),通常归一化到 [0, 1]
- 数据集划分:训练集 60,000 张,测试集 10,000 张
Why — 为什么 MNIST 是入门首选?
问题一:任务简单但有意义。手写数字识别虽然简单,但涵盖了图像分类的核心要素:特征提取、模式识别、分类决策。
问题二:计算资源需求低。28x28 的灰度图像数据量小,即使没有 GPU 的电脑也能快速训练。
问题三:Benchmark 完备。MNIST 有大量经典方法的基准结果可以对比,便于评估模型性能。
没有标准化预处理会发生什么?
- 不同图像的像素值范围差异大,导致训练不稳定
- 梯度更新方向不一致,收敛速度慢
- 模型可能对某些像素值范围过拟合
PyTorch 提供了便捷的 MNIST 数据加载接口,配合 DataLoader 实现批量数据迭代:
import torch
from torchvision import datasets, transforms
# 定义数据预处理管道
# transforms.Compose 将多个预处理操作串联起来
transform = transforms.Compose([
# 1. 将 PIL Image 或 ndarray 转换为 torch.Tensor
# 原始像素值范围 [0, 255] -> [0.0, 1.0]
transforms.ToTensor(),
# 2. 标准化处理:将数据调整到均值为0.1307、标准差为0.3081的分布
# 这是 MNIST 官方统计的全局均值和标准差
transforms.Normalize((0.1307,), (0.3081,))
])
# 下载并加载训练集
train_dataset = datasets.MNIST(
root='./data', # 数据存储目录,不存在则自动创建
train=True, # True=训练集,False=测试集
download=True, # 数据集不存在时自动下载
transform=transform # 数据预处理管道
)
# 下载并加载测试集
test_dataset = datasets.MNIST(
root='./data',
train=False,
download=True,
transform=transform
)
print(f"训练集样本数: {len(train_dataset)}") # 60000
print(f"测试集样本数: {len(test_dataset)}") # 10000
# 查看单个样本的结构
sample_image, sample_label = train_dataset[0]
print(f"单张图像形状: {sample_image.shape}") # torch.Size([1, 28, 28])
print(f"标签类型: {type(sample_label)}, 值: {sample_label}") # 0-9
DataLoader 负责批量加载数据,支持打乱顺序、多进程加载等高级功能:
from torch.utils.data import DataLoader
# 创建训练数据迭代器
train_loader = DataLoader(
dataset=train_dataset, # 数据集
batch_size=64, # 每批样本数
shuffle=True, # 每个 epoch 开始时打乱数据
num_workers=2, # 使用2个子进程加载数据(加速)
pin_memory=True # True=将数据加载到锁页内存,GPU训练时加速
)
# 创建测试数据迭代器
test_loader = DataLoader(
dataset=test_dataset,
batch_size=1000, # 测试时可以用更大的 batch
shuffle=False, # 测试集不需要打乱
num_workers=2
)
# 遍历一个 batch 的数据
for batch_images, batch_labels in train_loader:
print(f"Batch 图像形状: {batch_images.shape}") # [64, 1, 28, 28]
print(f"Batch 标签形状: {batch_labels.shape}") # [64]
print(f"Batch 标签内容: {batch_labels[:10]}") # 前10个标签
break # 只看第一个 batch
在训练前,先可视化一些样本,了解数据的真实样貌:
import matplotlib.pyplot as plt
# 创建画布:2行5列展示10个样本
fig, axes = plt.subplots(2, 5, figsize=(12, 5))
axes = axes.flatten()
# 从训练集随机选取10张图像展示
for i in range(10):
# 获取第 i 个样本(注意:这里 i 只是索引,不是标签)
img, label = train_dataset[i]
# 去掉通道维度,变成 28x28
img = img.squeeze()
# 反标准化:还原到 [0, 1] 范围以便显示
# 公式:x_normalized = (x - mean) / std
# 反过来:x = x_normalized * std + mean
img_display = img * 0.3081 + 0.1307
axes[i].imshow(img_display, cmap='gray')
axes[i].set_title(f'Label: {label}')
axes[i].axis('off')
plt.suptitle('MNIST 手写数字样本展示', fontsize=14)
plt.tight_layout()
plt.savefig('mnist_samples.png', dpi=150)
plt.show()
print("样本图像已保存为 mnist_samples.png")
本章小结
- MNIST 规格:28x28 灰度图,60,000 训练 + 10,000 测试样本
- 数据加载:torchvision.datasets.MNIST 一行代码完成下载和加载
- 预处理:ToTensor 转为张量 + Normalize 标准化
- 批量迭代:DataLoader 提供批量加载、打乱、多进程等功能
二、模型的设计
What — 基于卷积的 MNIST 分类模型是什么样的?
一个典型的用于 MNIST 分类的 CNN 模型包含以下组件:输入层接收 28x28 灰度图像,几个卷积-池化块提取层次化特征,最后通过全连接层输出 10 个类别的概率分布。
模型架构设计要点:
- 浅层网络:MNIST 任务相对简单,2-3 层卷积足够
- 逐步降维:通过池化层逐步减小空间尺寸,增加通道数
- 全局池化或展平:将特征图转换为向量输入全连接层
- Dropout 正则化:防止过拟合
Why — 为什么这样设计模型架构?
问题一:为什么需要多层卷积? 单层卷积只能提取低级特征(如边缘、纹理),多层堆叠可以提取越来越抽象的特征。第一层可能检测斜线、弧线,第二层组合这些形成数字部件(如圆圈、直线),第三层形成完整的数字模式。
问题二:为什么要逐步增加通道数? 空间尺寸减小时(通过池化),我们增加通道数来补偿信息密度的降低。每一次池化后,特征图尺寸减半,但通道数翻倍,这样总的信息量大致保持不变。
问题三:为什么用 Dropout? Dropout 在训练时随机"关闭"一些神经元,迫使网络学习更鲁棒的特征,减少对特定神经元的依赖,从而提高泛化能力。
没有这种层次化设计会发生什么?
- 浅层网络无法学习足够抽象的特征表示
- 无法有效捕捉手写数字的多尺度模式
- 模型容易过拟合训练数据,泛化能力差
让我们一步步构建一个用于 MNIST 的卷积神经网络:
import torch
import torch.nn as nn
import torch.nn.functional as F
class CNNForMNIST(nn.Module):
"""基于卷积的 MNIST 手写数字分类网络"""
def __init__(self, num_classes=10):
super(CNNForMNIST, self).__init__()
# 第一个卷积块:Conv -> BN -> ReLU -> Pool
# 输入: [B, 1, 28, 28]
# Conv: 1 -> 32 channels, 3x3 kernel, same padding
# 输出: [B, 32, 28, 28](尺寸不变)
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
self.bn1 = nn.BatchNorm2d(32) # 批归一化,稳定训练
# 第二个卷积块
# 输入: [B, 32, 14, 14](池化后)
# Conv: 32 -> 64 channels, 3x3 kernel
# 输出: [B, 64, 14, 14]
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.bn2 = nn.BatchNorm2d(64)
# 第三个卷积块
# 输入: [B, 64, 7, 7]
# Conv: 64 -> 128 channels, 3x3 kernel
# 输出: [B, 128, 7, 7]
self.conv3 = nn.Conv2d(64, 128, kernel_size=3, padding=1)
self.bn3 = nn.BatchNorm2d(128)
# 全连接层
# 输入展平: 128 * 7 * 7 = 6272
# 输出: 256 -> 10
self.fc1 = nn.Linear(128 * 7 * 7, 256)
self.fc2 = nn.Linear(256, num_classes)
# Dropout: 防止过拟合
self.dropout = nn.Dropout(p=0.5)
def forward(self, x):
# 第一个卷积块:Conv -> BN -> ReLU -> MaxPool
x = self.conv1(x) # [B, 1, 28, 28] -> [B, 32, 28, 28]
x = self.bn1(x)
x = F.relu(x) # ReLU 激活
x = F.max_pool2d(x, 2) # 2x2 池化:[B, 32, 14, 14]
# 第二个卷积块
x = self.conv2(x) # [B, 32, 14, 14] -> [B, 64, 14, 14]
x = self.bn2(x)
x = F.relu(x)
x = F.max_pool2d(x, 2) # [B, 64, 7, 7]
# 第三个卷积块
x = self.conv3(x) # [B, 64, 7, 7] -> [B, 128, 7, 7]
x = self.bn3(x)
x = F.relu(x)
# 展平:从 [B, 128, 7, 7] -> [B, 128*7*7]
x = x.view(x.size(0), -1)
# 全连接层
x = self.fc1(x) # [B, 6272] -> [B, 256]
x = F.relu(x)
x = self.dropout(x) # 训练时随机丢弃 50%
x = self.fc2(x) # [B, 256] -> [B, 10]
return x
# 创建模型实例
model = CNNForMNIST(num_classes=10)
print(model)
print(f"\n模型参数量: {sum(p.numel() for p in model.parameters()):,}")
通过追踪数据在模型中的流动,直观理解每一层的输出尺寸变化:
# 创建模型和测试输入
model = CNNForMNIST()
test_input = torch.randn(1, 1, 28, 28) # 单张 MNIST 图像
print("=" * 60)
print("CNNForMNIST 数据流动追踪")
print("=" * 60)
# 手动追踪每一层
x = test_input
print(f"输入: {x.shape}")
# Conv Block 1
x = model.conv1(x)
print(f"Conv1: {x.shape}")
x = model.bn1(x)
x = torch.relu(x)
print(f"ReLU1: {x.shape}")
x = torch.max_pool2d(x, 2)
print(f"Pool1: {x.shape}")
# Conv Block 2
x = model.conv2(x)
print(f"Conv2: {x.shape}")
x = model.bn2(x)
x = torch.relu(x)
print(f"ReLU2: {x.shape}")
x = torch.max_pool2d(x, 2)
print(f"Pool2: {x.shape}")
# Conv Block 3
x = model.conv3(x)
print(f"Conv3: {x.shape}")
x = model.bn3(x)
x = torch.relu(x)
print(f"ReLU3: {x.shape}")
# Flatten
print(f"展平前: {x.shape}")
x = x.view(x.size(0), -1)
print(f"展平后: {x.shape}")
# FC Layers
x = model.fc1(x)
print(f"FC1: {x.shape}")
x = torch.relu(x)
x = model.dropout(x)
print(f"Dropout: {x.shape}")
x = model.fc2(x)
print(f"FC2 (输出 logits): {x.shape}")
# 最终输出
print("\n最终输出(logits):", x)
print("预测类别:", torch.argmax(x, dim=1).item())
BatchNorm 在 CNN 中的作用
批归一化(BatchNorm)在卷积层之后使用,对每个通道在整个 batch 上计算均值和方差,然后进行标准化。BatchNorm 可以:1) 加速收敛;2) 提高模型泛化能力;3) 对权重初始化不那么敏感。注意在推理时,BatchNorm 使用训练阶段统计的移动平均均值和方差。
本章小结
- 模型架构:3 层卷积 + BatchNorm + ReLU + MaxPool + 2 层全连接
- 尺寸变化:[1,28,28] -> [32,14,14] -> [64,7,7] -> [128,7,7] -> [10]
- 正则化:BatchNorm 稳定训练 + Dropout 防止过拟合
- 输出:10 维 logits,向后接 Softmax 得到类别概率
三、训练循环的实现
What — 训练循环包含哪些核心步骤?
神经网络的训练过程是一个迭代优化的过程,每个 epoch(轮次)包含一次完整的训练集遍历。在每个 iteration(迭代)中,我们完成:数据加载、前向传播、损失计算、反向传播、参数更新这五个核心步骤。
训练循环的核心组件:
- 优化器(Optimizer):根据梯度更新模型参数,如 SGD、Adam
- 损失函数(Loss Function):衡量预测值与真实值的差距,如 CrossEntropyLoss
- 学习率(Learning Rate):控制参数更新的步长大小
- Epoch 和 Batch:epoch 是完整遍历一次数据集,batch 是每次更新的样本数
Why — 为什么需要完整的训练循环?
问题一:前向传播的目的是什么? 前向传播将输入数据通过层层计算得到预测输出,这是计算损失和反向传播的基础。没有前向传播,就无法知道模型当前的预测效果。
问题二:为什么需要反向传播? 反向传播通过链式法则计算损失函数对每个参数的梯度,告诉我们"如何调整参数能减少损失"。没有梯度,参数更新就没有方向。
问题三:optimizer.zero_grad() 为什么必须在 backward() 之前? PyTorch 默认会累积梯度,如果不清零,每次 backward() 会把新梯度加到之前的梯度上,导致参数更新错误。正确的做法是每个 batch 开始前清零梯度。
没有正确的训练循环会发生什么?
- 梯度累积导致参数更新错误
- 模型无法收敛或收敛到错误解
- 训练不稳定,甚至发散
以下是 PyTorch 中实现训练循环的标准代码模板:
import torch
import torch.optim as optim
import torch.nn as nn
import time
# 设置设备:优先使用 CUDA(GPU),否则使用 CPU
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"使用设备: {device}")
# 创建模型并将模型移到设备上
model = CNNForMNIST().to(device)
# 定义损失函数:交叉熵损失(内置 Softmax)
criterion = nn.CrossEntropyLoss()
# 定义优化器:随机梯度下降 + 动量
optimizer = optim.SGD(
model.parameters(), # 要优化的参数
lr=0.01, # 学习率:控制参数更新步长
momentum=0.9, # 动量:加速收敛,减少震荡
weight_decay=1e-4 # L2 正则化:防止过拟合
)
# 学习率调度器:每隔 10 个 epoch 将学习率降低为原来的 1/10
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)
def train_epoch(model, train_loader, criterion, optimizer, device):
"""训练一个 epoch"""
model.train() # 设置为训练模式(启用 Dropout 和 BatchNorm 训练行为)
running_loss = 0.0
correct = 0
total = 0
for batch_idx, (images, labels) in enumerate(train_loader):
# 1. 将数据移到设备上
images, labels = images.to(device), labels.to(device)
# 2. 前向传播:计算模型预测
outputs = model(images) # [B, 10] 的 logits
loss = criterion(outputs, labels) # 计算交叉熵损失
# 3. 反向传播:计算梯度
optimizer.zero_grad() # 清零之前的梯度(必须!)
loss.backward() # 反向传播计算梯度
# 4. 参数更新:根据梯度更新参数
optimizer.step()
# 5. 统计信息
running_loss += loss.item()
_, predicted = torch.max(outputs.data, dim=1) # 取最大 logit 对应的索引
total += labels.size(0)
correct += (predicted == labels).sum().item()
# 每 100 个 batch 打印一次进度
if (batch_idx + 1) % 100 == 0:
print(f" Batch {batch_idx+1}/{len(train_loader)}, "
f"Loss: {loss.item():.4f}")
epoch_loss = running_loss / len(train_loader)
epoch_acc = 100 * correct / total
return epoch_loss, epoch_acc
# 训练多个 epoch
num_epochs = 20
print("\n开始训练...")
print("=" * 60)
for epoch in range(num_epochs):
start_time = time.time()
# 训练一个 epoch
train_loss, train_acc = train_epoch(
model, train_loader, criterion, optimizer, device
)
# 更新学习率
scheduler.step()
current_lr = optimizer.param_groups[0]['lr']
epoch_time = time.time() - start_time
print(f"Epoch [{epoch+1}/{num_epochs}] "
f"Loss: {train_loss:.4f} "
f"Acc: {train_acc:.2f}% "
f"LR: {current_lr:.6f} "
f"Time: {epoch_time:.1f}s")
print("-" * 60)
记录训练过程中的损失和准确率变化,便于分析训练效果:
# 完整的训练脚本(含可视化)
import matplotlib.pyplot as plt
# 存储训练历史
train_losses = []
train_accs = []
test_losses = []
test_accs = []
def evaluate(model, test_loader, criterion, device):
"""在测试集上评估模型性能"""
model.eval() # 设置为评估模式(禁用 Dropout,使用 BatchNorm 统计值)
test_loss = 0.0
correct = 0
total = 0
with torch.no_grad(): # 评估时不需要计算梯度
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
test_loss += loss.item()
_, predicted = torch.max(outputs.data, dim=1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
avg_loss = test_loss / len(test_loader)
accuracy = 100 * correct / total
return avg_loss, accuracy
# 训练并记录历史
num_epochs = 20
for epoch in range(num_epochs):
# 训练
model.train()
running_loss = 0.0
correct = 0
total = 0
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
running_loss += loss.item()
_, predicted = torch.max(outputs.data, dim=1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
# 记录训练指标
train_loss = running_loss / len(train_loader)
train_acc = 100 * correct / total
train_losses.append(train_loss)
train_accs.append(train_acc)
# 在测试集上评估
test_loss, test_acc = evaluate(model, test_loader, criterion, device)
test_losses.append(test_loss)
test_accs.append(test_acc)
print(f"Epoch {epoch+1:2d}: Train Loss={train_loss:.4f}, "
f"Train Acc={train_acc:.2f}%, "
f"Test Loss={test_loss:.4f}, Test Acc={test_acc:.2f}%")
scheduler.step()
# 绘制训练曲线
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
# 损失曲线
ax1.plot(range(1, num_epochs+1), train_losses, 'b-', label='训练损失')
ax1.plot(range(1, num_epochs+1), test_losses, 'r-', label='测试损失')
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Loss')
ax1.set_title('训练与测试损失曲线')
ax1.legend()
ax1.grid(True)
# 准确率曲线
ax2.plot(range(1, num_epochs+1), train_accs, 'b-', label='训练准确率')
ax2.plot(range(1, num_epochs+1), test_accs, 'r-', label='测试准确率')
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Accuracy (%)')
ax2.set_title('训练与测试准确率曲线')
ax2.legend()
ax2.grid(True)
plt.tight_layout()
plt.savefig('training_curves.png', dpi=150)
plt.show()
print("\n训练曲线已保存为 training_curves.png")
本章小结
- 训练五步曲:数据加载 -> 前向传播 -> 损失计算 -> 反向传播 -> 参数更新
- 关键 API:optimizer.zero_grad() 必须先调用,loss.backward() 计算梯度
- 模型模式:model.train() 训练模式,model.eval() 评估模式
- 学习率调度:scheduler.step() 按策略调整学习率
四、模型评估与预测
What — 如何全面评估模型的性能?
模型评估是检验模型泛化能力的关键步骤。我们不仅要看整体准确率,还要分析模型在不同类别上的表现,识别可能的错误模式。
常用的评估指标:
- 准确率(Accuracy):正确分类的样本占总样本的比例
- 混淆矩阵(Confusion Matrix):展示各类别之间的预测分布
- 每个类别的精确率和召回率:更细致地分析每个类别的表现
- 错误分析:查看模型预测错误的样本,理解错误原因
Why — 为什么需要全面的模型评估?
问题一:准确率高就代表模型好吗? 不一定。如果数据集类别不平衡,高准确率可能来自对多数类的偏向。精确率和召回率能揭示这一点。
问题二:混淆矩阵有什么用? 混淆矩阵直观展示模型在各类别上的预测情况,帮助发现模型对哪些类别容易混淆(如 3 和 8,7 和 1)。
问题三:为什么需要错误分析? 通过查看预测错误的样本,可以发现数据质量问题、模型架构缺陷或需要增加训练数据的方向。
没有详细评估会发生什么?
- 无法发现模型对特定类别的性能问题
- 可能在实际应用中遇到意外的低性能场景
- 无法指导后续的模型改进方向
下面是完整的模型评估代码,计算准确率、精确率、召回率等指标:
from sklearn.metrics import confusion_matrix, classification_report
import seaborn as sns
import numpy as np
def evaluate_detailed(model, test_loader, device):
"""详细评估模型性能"""
model.eval()
all_predictions = []
all_labels = []
all_probs = []
with torch.no_grad():
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
probs = torch.softmax(outputs, dim=1) # 转为概率
_, predicted = torch.max(outputs, dim=1)
all_predictions.extend(predicted.cpu().numpy())
all_labels.extend(labels.cpu().numpy())
all_probs.extend(probs.cpu().numpy())
all_predictions = np.array(all_predictions)
all_labels = np.array(all_labels)
# 计算整体准确率
accuracy = (all_predictions == all_labels).mean() * 100
print(f"测试集准确率: {accuracy:.2f}%")
# 生成分类报告
print("\n分类报告:")
print(classification_report(
all_labels, all_predictions,
target_names=[f'{i}' for i in range(10)]
))
return all_predictions, all_labels, np.array(all_probs)
# 执行评估
predictions, labels, probs = evaluate_detailed(model, test_loader, device)
# 绘制混淆矩阵
cm = confusion_matrix(labels, predictions)
plt.figure(figsize=(10, 8))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
xticklabels=range(10), yticklabels=range(10))
plt.xlabel('预测标签')
plt.ylabel('真实标签')
plt.title('MNIST 分类混淆矩阵')
plt.savefig('confusion_matrix.png', dpi=150)
plt.show()
print("\n混淆矩阵已保存为 confusion_matrix.png")
通过分析预测错误的样本,发现模型的薄弱环节:
def visualize_predictions(model, dataset, device, num_samples=15):
"""可视化模型的预测结果,包括正确和错误的案例"""
model.eval()
# 随机选择样本
indices = np.random.choice(len(dataset), num_samples, replace=False)
fig, axes = plt.subplots(3, 5, figsize=(14, 9))
axes = axes.flatten()
for idx, ax in zip(indices, axes):
img, true_label = dataset[idx]
# 模型预测
with torch.no_grad():
img_batch = img.unsqueeze(0).to(device)
output = model(img_batch)
prob = torch.softmax(output, dim=1)[0]
pred_label = torch.argmax(prob).item()
confidence = prob[pred_label].item()
# 反标准化用于显示
img_display = img.squeeze().cpu().numpy()
img_display = img_display * 0.3081 + 0.1307
# 绘制图像
ax.imshow(img_display, cmap='gray')
# 根据预测是否正确设置颜色
if pred_label == true_label:
color = 'green'
title = f'真实:{true_label} 预测:{pred_label}\n置信度:{confidence:.2%}'
else:
color = 'red'
title = f'真实:{true_label} 预测:{pred_label}\n[错误] 置信度:{confidence:.2%}'
ax.set_title(title, color=color, fontsize=10)
ax.axis('off')
plt.suptitle('MNIST 预测结果可视化(绿色=正确,红色=错误)', fontsize=14)
plt.tight_layout()
plt.savefig('predictions_visualization.png', dpi=150)
plt.show()
print("预测可视化已保存为 predictions_visualization.png")
# 执行可视化
visualize_predictions(model, test_dataset, device)
本章小结
- 评估指标:准确率、精确率、召回率、F1-score
- 混淆矩阵:直观展示各类别预测分布,发现混淆模式
- 错误分析:分析错误样本,发现模型薄弱环节
- 可视化:绘制训练曲线、混淆矩阵、预测结果
五、完整代码整合
整合要点
将前面的所有代码片段整合成一个完整的、可直接运行的 MNIST 分类脚本。这个完整脚本应该包含:数据加载、模型定义、训练循环、评估和可视化等所有必要组件。
以下是经过整理的完整训练脚本,可以直接复制运行:
"""
MNIST 手写数字分类 - 完整训练脚本
基于卷积神经网络 (CNN) 的图像分类实战
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import confusion_matrix
import seaborn as sns
# ============================================
# 1. 数据准备
# ============================================
print("=" * 60)
print("1. 数据准备")
print("=" * 60)
transform = transforms.Compose([
transforms.ToTensor(), # 转为张量,归一化到 [0, 1]
transforms.Normalize((0.1307,), (0.3081,)) # MNIST 标准化参数
])
train_dataset = datasets.MNIST(
root='./data', train=True, download=True, transform=transform
)
test_dataset = datasets.MNIST(
root='./data', train=False, download=True, transform=transform
)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)
print(f"训练集: {len(train_dataset)} 样本")
print(f"测试集: {len(test_dataset)} 样本")
# ============================================
# 2. 模型定义
# ============================================
print("\n" + "=" * 60)
print("2. 模型定义")
print("=" * 60)
class CNNForMNIST(nn.Module):
def __init__(self, num_classes=10):
super(CNNForMNIST, self).__init__()
self.conv1 = nn.Conv2d(1, 32, 3, padding=1)
self.bn1 = nn.BatchNorm2d(32)
self.conv2 = nn.Conv2d(32, 64, 3, padding=1)
self.bn2 = nn.BatchNorm2d(64)
self.conv3 = nn.Conv2d(64, 128, 3, padding=1)
self.bn3 = nn.BatchNorm2d(128)
self.fc1 = nn.Linear(128 * 7 * 7, 256)
self.fc2 = nn.Linear(256, num_classes)
self.dropout = nn.Dropout(0.5)
def forward(self, x):
x = F.max_pool2d(F.relu(self.bn1(self.conv1(x))), 2)
x = F.max_pool2d(F.relu(self.bn2(self.conv2(x))), 2)
x = F.relu(self.bn3(self.conv3(x)))
x = x.view(x.size(0), -1)
x = self.dropout(F.relu(self.fc1(x)))
x = self.fc2(x)
return x
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = CNNForMNIST().to(device)
print(f"模型使用设备: {device}")
print(f"模型参数量: {sum(p.numel() for p in model.parameters()):,}")
# ============================================
# 3. 训练配置
# ============================================
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)
# ============================================
# 4. 训练循环
# ============================================
print("\n" + "=" * 60)
print("3. 开始训练")
print("=" * 60)
num_epochs = 15
train_losses, train_accs, test_accs = [], [], []
for epoch in range(num_epochs):
model.train()
running_loss, correct, total = 0.0, 0, 0
for images, labels in train_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()
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
scheduler.step()
train_loss = running_loss / len(train_loader)
train_acc = 100 * correct / total
# 测试集评估
model.eval()
correct, total = 0, 0
with torch.no_grad():
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
test_acc = 100 * correct / total
train_losses.append(train_loss)
train_accs.append(train_acc)
test_accs.append(test_acc)
print(f"Epoch {epoch+1:2d}/{num_epochs}: "
f"Loss={train_loss:.4f}, "
f"Train Acc={train_acc:.2f}%, "
f"Test Acc={test_acc:.2f}%")
# ============================================
# 5. 结果可视化
# ============================================
print("\n" + "=" * 60)
print("4. 结果可视化")
print("=" * 60)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.plot(train_losses, 'b-')
ax1.set_title('Training Loss')
ax1.set_xlabel('Epoch')
ax1.grid(True)
ax2.plot(train_accs, 'b-', label='Train')
ax2.plot(test_accs, 'r-', label='Test')
ax2.set_title('Accuracy')
ax2.set_xlabel('Epoch')
ax2.legend()
ax2.grid(True)
plt.savefig('training_results.png')
plt.show()
print("\n训练完成!最终测试准确率: {:.2f}%".format(test_accs[-1]))
本章小结
- 完整流程:数据加载 -> 模型定义 -> 训练配置 -> 训练循环 -> 结果评估
- 核心技巧:合理使用 BatchNorm、Dropout、学习率调度
- 实战要点:GPU 加速、模型保存与加载、结果可视化
六、FAQ(20 组)
FAQ — 精选 20 问,深入理解 MNIST 分类实战的关键问题
Q1. 为什么 MNIST 图像需要归一化?
归一化使数据分布稳定,加快模型收敛,防止梯度爆炸或消失。MNIST 原始像素值在 [0, 255],归一化到 [0, 1] 或标准化到均值 0.1307、标准差 0.3081 后,数据分布更均匀。深度网络中,未归一化的数据可能导致某些层激活值过大或过小,影响梯度传播。
Q2. BatchNorm 在训练和推理时行为有何不同?
训练时 BatchNorm 使用当前 batch 的均值方差;推理时使用移动平均的统计值。训练阶段,每个 batch 计算均值和方差,然后通过 running_mean/running_var 累积移动平均。推理阶段,直接使用这些预计算的统计值,不再计算新的均值方差。
Q3. Dropout 为什么能防止过拟合?
Dropout 随机丢弃神经元,迫使网络学习冗余的特征表示。训练时每个 batch 随机"关闭"部分神经元(如 50%),强迫网络不依赖特定神经元。推理时所有神经元参与,但输出要乘以保留概率。效果上类似于训练了多个网络的集成。
Q4. 为什么学习率不能太大也不能太小?
学习率太大导致震荡不收敛,太小导致收敛缓慢。学习率控制参数更新的步长。太大时,参数在最优解附近来回跳动无法收敛;太小时,虽然稳定但需要更多迭代才能收敛。实践中常用学习率调度(先大学习率快速接近最优,后小学习率精调)。
Q5. momentum=0.9 是什么意思?
动量将历史梯度方向考虑进来,平滑参数更新。不带动量的 SGD 只看当前梯度。动量方法累积历史梯度:v_t = momentum * v_{t-1} + gradient。如果历史梯度和当前梯度方向一致,更新步长会更大;方向不一致时,更新会被平滑。类似物理中的动量惯性。
Q6. 为什么测试集不需要 shuffle?
测试集打乱与否不影响最终指标,但 shuffle=False 保持结果可复现。测试时我们只需要计算整体准确率等指标,样本顺序无关紧要。但固定顺序便于复现和调试。注意训练集必须 shuffle,否则模型可能学到样本顺序的偏见。
Q7. model.train() 和 model.eval() 的区别?
train 模式启用 Dropout 和 BatchNorm 的训练行为;eval 模式禁用/使用统计值。训练时 Dropout 随机丢弃神经元;BatchNorm 使用当前 batch 统计值。推理时 Dropout 不丢弃(输出要乘保留概率或直接不乘);BatchNorm 使用移动平均统计值。
Q8. CrossEntropyLoss 内部包含 Softmax 吗?
是的,CrossEntropyLoss 内部会自动应用 Softmax。PyTorch 的 nn.CrossEntropyLoss 输入是原始 logits(未经 Softmax),内部会自动计算 Softmax 再计算交叉熵。因此不需要在模型输出后手动 Softmax,直接传 logits 给 CrossEntropyLoss 即可。
Q9. 为什么使用 64、128 这样成倍的通道数?
成倍变化的通道数便于硬件(GPU)并行优化,且符合信息量保持的原则。池化将空间尺寸减半后,通常将通道数翻倍,使每个位置的总信息量(通道数×面积)大致保持。2 的幂次方通道数在 GPU 上有更好的内存对齐和计算效率。
Q10. torch.max(outputs, dim=1) 返回什么?
返回沿着指定维度(通道维度)的最大值和对应的索引。dim=1 表示在通道维度上取最大值。返回两个张量:values(最大值)和 indices(最大值的位置/索引)。对于分类任务,indices 就是预测的类别标签。
Q11. 为什么要用 3x3 卷积核而不是 5x5?
两个 3x3 卷积的感受野等于一个 5x5,但参数量更少、非线性更多。3x3×2 的参数量是 2×9=18,而 5x5 是 25,参数量减少 28%。两个 3x3 卷积中间有两次 ReLU,引入更多非线性,表达能力更强。
Q12. weight_decay=1e-4 是什么?
weight_decay 是 L2 正则化,在损失函数中加入参数平方和项。它惩罚大的权重参数,使网络更平滑。SGD 优化器中等价于在梯度更新时减去 lr * weight_decay * params。过大的 weight_decay 会导致欠拟合,过小则正则化效果弱。
Q13. 训练时 loss 下降但测试准确率不变是什么原因?
可能是过拟合或学习率太小,也可能是评估代码有 bug。过拟合时模型在训练集上越来越好但泛化能力不提升。学习率太小时,模型可能卡在次优解附近。如果排除了以上原因,仔细检查测试集的评估代码(如没有设置 model.eval() 或 torch.no_grad())。
Q14. 为什么 MNIST 只需要 3 层卷积?
MNIST 任务相对简单,28x28 图像已经很小,三层卷积足以提取足够抽象的特征。相比之下 ImageNet 使用 152 层网络是因为 224x224 的彩色图像需要更深更强的特征提取能力。对于 MNIST,三层卷积就能实现 99%+ 的准确率,更深的网络反而可能过拟合。
Q15. num_workers=2 是什么意思?
使用 2 个子进程并行加载数据,加快数据读取速度。数据加载(从磁盘读、预处理)是 CPU 操作,可能成为 GPU 训练的瓶颈。多进程加载可以重叠数据读取和 GPU 计算。但进程数不宜过多,否则进程切换开销反而降低效率。
Q16. pin_memory=True 有什么用?
将数据加载到锁页内存,减少 CPU 到 GPU 的数据传输时间。锁页内存是操作系统保留的内存,不会被交换到磁盘,传输到 GPU 时更快。在使用 GPU 训练时开启可以提升数据加载效率,但会占用更多系统内存。
Q17. 为什么要除以 len(train_loader) 计算平均 loss?
因为每个 batch 的 loss 是该 batch 的总损失,除以 batch 数得到平均损失。如果直接累加 loss,最后一个不完整 batch 的样本数更少,会导致平均不准确。用 len(train_loader)(总 batch 数)除以得到每个样本的平均损失,与 batch 大小无关。
Q18. 模型的保存和加载如何实现?
使用 torch.save(model.state_dict(), path) 保存,使用 torch.load(path) + model.load_state_dict() 加载。推荐只保存参数(state_dict)而非整个模型,这样更灵活。保存时自动保存到 GPU,加载时需要确认设备一致性(用 map_location 参数处理 CPU 加载 GPU 模型的情况)。
Q19. 学习率调度器 StepLR 的 step_size=10, gamma=0.1 是什么意思?
每 10 个 epoch 将学习率乘以 0.1。这是阶梯式衰减策略。初始学习率 0.01,第 10 个 epoch 后变为 0.001,第 20 个 epoch 后变为 0.0001。阶梯衰减是最常用的学习率调度策略,符合"先粗调后精调"的原则。
Q20. 如何继续训练一个已保存的模型?
加载模型参数后继续训练,需要确保优化器状态也保存或在加载后重新创建。如果只保存了模型参数,加载后需要重新创建优化器(学习率等参数要匹配)。如果想从保存的检查点恢复训练状态,需要同时保存模型的 state_dict 和优化器的 state_dict。
FAQ 总结
- 数据预处理:ToTensor 归一化 + Normalize 标准化
- 训练技巧:BatchNorm + Dropout + 学习率调度 + 动量
- 评估要点:model.eval() + torch.no_grad() + 多指标评估
- 常见问题:过拟合、学习率选择、梯度问题
七、Roadmap 预告
下篇预告:《PyTorch 的深度可分离膨胀卷积详解》
在下一篇进阶文章中,我们将深入探讨现代高效 CNN 架构的核心组件:
- 深度可分离卷积(Depthwise Separable Convolution)的定义与原理
- 深度可分离与标准卷积的参数量对比分析
- 膨胀卷积(Dilated Convolution)详解:感受野扩展的秘密
- 实战:基于深度可分离膨胀卷积的 MNIST 手写体识别
敬请期待!

浙公网安备 33010602011771号