机器学习数学基础专题【左扬精讲】—— 基于卷积的 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 有大量经典方法的基准结果可以对比,便于评估模型性能。

没有标准化预处理会发生什么?

  • 不同图像的像素值范围差异大,导致训练不稳定
  • 梯度更新方向不一致,收敛速度慢
  • 模型可能对某些像素值范围过拟合
标准写法: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 批量数据迭代

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
数据可视化:查看 MNIST 样本

在训练前,先可视化一些样本,了解数据的真实样貌:

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 在训练时随机"关闭"一些神经元,迫使网络学习更鲁棒的特征,减少对特定神经元的依赖,从而提高泛化能力。

没有这种层次化设计会发生什么?

  • 浅层网络无法学习足够抽象的特征表示
  • 无法有效捕捉手写数字的多尺度模式
  • 模型容易过拟合训练数据,泛化能力差
标准写法:CNN 模型完整实现

让我们一步步构建一个用于 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")

本章小结

  • 训练五步曲:数据加载 -> 前向传播 -> 损失计算 -> 反向传播 -> 参数更新
  • 关键 APIoptimizer.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 手写体识别

敬请期待!


posted @ 2026-07-15 15:55  左扬  阅读(9)  评论(0)    收藏  举报