从零搭建卷积神经网络:基于PyTorch实现MNIST手写数字分类

摘要

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网络,包含三个卷积块和一个全连接输出层:

  1. Conv1:输入1通道,输出16通道,卷积核5x5,padding=2保持尺寸,后接ReLU和2x2最大池化 → 输出16×14×14
  2. Conv2:输入16通道,先卷积至32通道,再卷积保持32通道,后接ReLU和2x2池化 → 输出32×7×7
  3. Conv3:输入32通道,卷积至64通道,后接ReLU(无池化) → 输出64×7×7
  4. 全连接层:将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)

相关推荐
bIo7lyA8v1 小时前
算法优化的多层缓存映射与访问调度模型的技术8
算法
直接冲冲冲1 小时前
pytorch-深度学习-引言
人工智能·pytorch·深度学习
SilentSamsara1 小时前
MLflow 实验追踪与模型注册:从实验到生产的可复现工作流
开发语言·人工智能·pytorch·python·青少年编程
曲幽1 小时前
写爬虫时用了代理还被封?Python 代理的那些隐藏坑,我替你踩明白了
python·http·https·proxy·socks·requests·socks5·proxies
装不满的克莱因瓶1 小时前
掌握多头自注意力机制(Multi-Head Self-Attention)——Transformer 强大表达能力的核心来源
人工智能·python·深度学习·数学·ai·transformer
大模型最新论文速读1 小时前
06-10 · LLM 最新论文速览
论文阅读·人工智能·深度学习·机器学习·自然语言处理
dongf20191 小时前
R语言朴素贝叶斯算法---iris数据集
开发语言·算法·数据分析·r语言
小O的算法实验室1 小时前
2025年KBS,基于强化学习离散状态转移算法+复杂约束下多无人机任务分配
算法
下班走回家1 小时前
RAG 技术的进化:从朴素检索到 Agentic RAG
开发语言·人工智能·python