引言

手写数字识别是计算机视觉领域的“Hello World”,而MNIST数据集和卷积神经网络CNN)的结合,则是这一领域的经典组合。无论是入门深度学习,还是验证模型性能,CNN+MNIST的组合都值得深入探索。本文将从数据集解析、CNN原理、PyTorch实战到模型优化,带你全面掌握这一经典任务。

1. MNIST数据集:计算机视觉的基石

什么是MNIST?

MNIST数据集(Modified National Institute of Standards and Technology database)是一个广泛使用的机器学习基准数据集,主要用于手写数字识别任务。它由美国国家标准与技术研究所(NIST)收集并修改而来,是计算机视觉和深度学习领域的经典入门数据集。

  • 图像内容:包含0到9的手写数字图片,均为灰度图像。

  • 数据规模:60,000张训练图像 + 10,000张测试图像。

  • 图像格式:28x28像素的灰度图,像素值范围0-255(黑色为0,白色为255)。

  • 标签内容:每张图像对应一个0-9的标签,表示手写数字的真实值。

文件结构与内容示例

MNIST数据以二进制文件存储,文件命名遵循特定规则:

  • 训练图像:train-images-idx3-ubyte

  • 训练标签:train-labels-idx1-ubyte

  • 测试图像:t10k-images-idx3-ubyte

  • 测试标签:t10k-labels-idx1-ubyte

图像内容示例

图片来源:Wikipedia,MNIST示例图像

获取方式

可通过主流的深度学习框架(如TensorFlow、PyTorch、Keras)直接加载:

batch_size = 64
transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))])

train_dataset = datasets.MNIST(root='../dataset/mnist/', train=True, download=True, transform=transform)
train_loader = DataLoader(train_dataset, shuffle=True, batch_size=batch_size)
test_dataset = datasets.MNIST(root='../dataset/mnist/', train=False, download=True, transform=transform)
test_loader = DataLoader(test_dataset, shuffle=False, batch_size=batch_size)

也可以在官网下载:mnist数据集官网

2. 为什么选择CNN?——从LeNet到现代网络的进化

CNN的发展简史

  • Neocognitron(1980):福岛邦彦提出“Neocognitron”,奠定卷积结构基础。

  • LeNet-5(1998):Yann LeCun 提出LeNet-5,首次成功应用于手写数字识别。包括卷积层、池化层和全连接层。

  • AlexNet(2012):AlexNet在ImageNet竞赛中夺冠,CNN成为深度学习的主流架构。包括多个卷积层、池化层、ReLU激活函数和全连接层。关键改进:ReLU激活函数、Dropout正则化、GPU并行训练。

  • ZFNet(2013): Matthew D. Zeiler、Rob Fergus 提出了一种新的可视化方法,是AlexNet的一个改进版本,即Deconvolutional Networks。与AlexNet相似,但有细微的结构差异和优化。

  • VGGNet(2014): Karen Simonyan、Andrew Zisserman 通过使用更小的卷积核(3x3)和更深的网络结构,在ImageNet比赛中取得了优异的成绩。结构主要由3x3的卷积层和2x2的池化层组成,深度可选为VGG16或VGG19。通过堆叠小卷积核(3x3)构建深层网络,证明深度对性能的重要性。

  • GoogLeNet(2014):Christian Szegedy等(Google Research)引入了“Inception模块”来提取多尺度的特征,以及全局平均池化来减少参数数量。结构包括多个Inception模块,与传统的卷积神经网络有很大的不同。

  • ResNet(2015):Kaiming He等(Microsoft Research)通过引入残差连接(Residual Connection)解决了深度卷积神经网络训练过程中的梯度消失和梯度爆炸问题。结构包括多个残差块(Residual Block),可以构建非常深的网络。

  • DenseNet(2017): Gao Huang等进一步提出了密集连接(Dense Connection)来增强特征重用和梯度流动。结构上每个层与所有前面的层直接连接。

  • EfficientNet(2019): Mingxing Tan、Quoc V. Le通过网络缩放方法在网络深度、宽度和分辨率上进行均衡,提高了模型的性能和计算效率。

  • Vision Transformers(ViT)(2020): Alexey Dosovitskiy等(Google Research)首次将Transformer架构应用于计算机视觉任务,取得了与卷积神经网络相当的性能。

  • 自适应卷积网络(2021):Xin Li等通过自适应地调整卷积核形状和大小,实现了更高效的特征提取。

CNN的核心优势

  1. 高效的特征提取
    自动学习图像的多层次特征,无需手动设计特征(如传统SIFT、HOG)。

  2. 参数共享与稀疏连接
    参数量远小于全连接网络(例如:一个3x3卷积核仅9个参数,可扫描整张图像)。

  3. 平移、旋转和缩放鲁棒性
    通过卷积和池化对输入的小变化不敏感。

  4. 端到端学习
    从原始像素到分类结果全程优化,无需分步处理。

  5. 在MNIST任务中的表现
    LeNet-5在MNIST上可达99%+准确率,远超传统方法(如SVM约95%)。

3. PyTorch:灵活与效率的平衡

为什么选择PyTorch?

  • 优点

    • 动态计算图:调试灵活,适合科研与快速迭代。

    • Python语法:代码简洁易读,学习成本低。

    • 强大的生态系统:TorchVision、TorchText等工具库完善。

    • GPU加速:一行代码切换CPU/GPU(model.to(device))。

  • 缺点:

    • 动态图可能导致初学者调试困难(需熟悉张量形状变化)。

4. PyTorch实战:手写数字识别代码详解

代码来源与运行环境

import torch
import torch.nn as nn
import torchvision
from torchvision import transforms

代码分步解析

Step 1:导入必要的库

  • torch:PyTorch深度学习框架核心库

  • transforms:数据预处理工具(如归一化、格式转换)

  • datasets:内置数据集加载工具

  • DataLoader:数据批量加载器(支持多线程加载)

  • F:PyTorch的函数式API(包含激活函数等)

  • optim:优化算法模块(如SGD、Adam)

  • matplotlib:可视化库(用于绘制准确率曲线)

import torch
from torchvision import transforms
from torchvision import datasets
from torch.utils.data import DataLoader
import torch.nn.functional as F
import torch.optim as optim
import matplotlib.pyplot as plt
import time
import os
os.environ["KMP_DUPLICATE_LIB_OK"]="TRUE"  # 解决某些系统下的OpenMP库冲突问题

Step 2: 数据准备与预处理

  • ToTensor:将PIL图像或NumPy数组转换为[通道, 高度, 宽度]格式的Tensor,并自动将像素值从[0,255]缩放到[0.0,1.0]。
  • Normalize:使用MNIST数据集的标准均值和标准差进行归一化:(像素值 - 0.1307) / 0.3081 → 数据分布更接近标准正态分布,加速训练收敛。
batch_size = 128
transform = transforms.Compose([
    transforms.ToTensor(),                 # 将PIL图像转换为Tensor格式([C, H, W])
    transforms.Normalize((0.1307,), (0.3081,))  # MNIST专用归一化参数
])

Step 3: 加载MNIST数据集

  • root:数据集存储路径(自动下载到指定目录)

  • train:True表示加载训练集(60k样本),False表示测试集(10k样本)

  • download:若本地无数据集,则自动下载

  • DataLoader参数:

  • shuffle=True:训练集打乱顺序,避免模型记忆样本顺序

  • batch_size=128:每个批次加载128张图像

  • 测试集无需打乱(shuffle=False)

train_dataset = datasets.MNIST(
    root='../dataset/mnist/', 
    train=True, 
    download=True, 
    transform=transform
)
test_dataset = datasets.MNIST(
    root='../dataset/mnist/',
    train=False,
    download=True,
    transform=transform
)

train_loader = DataLoader(train_dataset, shuffle=True, batch_size=batch_size)
test_loader = DataLoader(test_dataset, shuffle=False, batch_size=batch_size)

Step 4: 定义CNN模型

  • 输入:[128, 1, 28, 28](128张1通道28x28图像)

  • conv1:

    • 使用10个5x5卷积核 → 输出[128, 10, 24, 24]

    • 公式:输出尺寸 = (输入尺寸 - 核大小 + 1) = 28-5+1=24

  • pooling:

    • 2x2最大池化,步长2 → 输出[128, 10, 12, 12]
  • conv2:

    • 20个5x5卷积核 → 输出[128, 20, 8, 8]

    • 尺寸计算:12-5+1=8

  • pooling:

    • 输出[128, 20, 4, 4]
  • view:

    • 展平为[128, 2044=320]
  • fc:

    • 全连接层将320维特征映射到10维(对应0-9数字)
class Net(torch.nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = torch.nn.Conv2d(1, 10, kernel_size=5)      # [1,28,28] -> [10,24,24] ; pooling to [10,12,12]
        self.conv2 = torch.nn.Conv2d(10, 20, kernel_size=5)     # [10,12,12] -> [20,8,8]; pooling to [20,4,4]
        # self.conv3 = torch.nn.Conv2d(20,320,kernel_size=3)       # [20,4,4] -> [30,2,2]; pooling to [30,1,1]
        self.pooling = torch.nn.MaxPool2d(2)
        self.fc = torch.nn.Linear(320, 10)

    def forward(self, x):
        # flatten data from (n,1,28,28) to (n, 784)

        batch_size = x.size(0)
        x = F.relu(self.pooling(self.conv1(x)))
        x = F.relu(self.pooling(self.conv2(x)))
        # x = F.relu(self.pooling(self.conv3(x)))
        x = x.view(batch_size, -1)  # -1 此处自动算出的是320
        # print("x.shape",x.shape)
        x = self.fc(x)

        return x

Step 5: 设备配置与模型部署

  • torch.cuda.is_available():检测系统是否支持CUDA(NVIDIA GPU)

  • model.to(device):将模型参数和缓冲区转移到指定设备(GPU/CPU)

  • 注意:输入数据也需要通过.to(device)转移到相同设备

start_time = time.time()
model = Net()
if torch.cuda.is_available() == 1:
    device = torch.device("cuda")
    print("CNN is running on GPU")
else:
    device = torch.device("cpu")
    print("CNN is running on CPU")
model.to(device)

Step 6:定义损失函数与优化器

  • criterion = torch.nn.CrossEntropyLoss() # 交叉熵损失(内置Softmax)
  • optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.5)
criterion = torch.nn.CrossEntropyLoss()  # 交叉熵损失(内置Softmax)
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.5)
补充说明:

1.交叉熵损失包括softmax和NLLloss
b站:刘二大人
与Softmax函数结合使用,将模型输出转换为概率分布。
2.优化器
(1)model.parameters()
获取模型中所有需要训练的参数(torch.nn.Parameter 对象)。在CNN中,model.parameters() 包括:卷积层的权重(conv1.weight, conv2.weight)卷积层的偏置(conv1.bias, conv2.bias)
全连接层的权重和偏置(fc1.weight, fc1.bias)
(2)lr=0.01(学习率)
控制每次参数更新的步长。
学习率越大,参数更新幅度越大,收敛速度越快,但可能错过最优解。
学习率越小,参数更新幅度越小,收敛速度越慢,但更可能找到精确解。
初始学习率通常设置为 0.01 或 0.001。
可通过学习率调度器(如 StepLR 或 ReduceLROnPlateau)动态调整。
(3)momentum=0.5(动量)
加速梯度下降并减少震荡。
动量引入“惯性”概念,使参数更新方向不仅依赖当前梯度,还依赖历史梯度。
动量系数通常设置为 0.9 或 0.5。值越大,历史梯度影响越大,更新方向更平滑。

Step 7: 训练函数实现

  • enumerate(train_loader, 0):遍历数据加载器,返回批次索引和数据(从0开始计数)
  • loss.item():将单元素张量转换为Python数值(避免内存累积)
  • 梯度清零:必须调用optimizer.zero_grad(),否则梯度会累加导致训练异常
def train(epoch):
    running_loss = 0.0
    for batch_idx, data in enumerate(train_loader, 0):
        inputs, target = data
        inputs, target = inputs.to(device), target.to(device)
        optimizer.zero_grad()       # 清空历史梯度
        outputs = model(inputs)     # 前向传播
        loss = criterion(outputs, target)
        loss.backward()            # 反向传播计算梯度
        optimizer.step()           # 更新权重参数
        running_loss += loss.item()
        if batch_idx % 300 == 299: # 每300个batch打印一次损失
            print('[%d, %5d] loss: %.3f' % (epoch+1, batch_idx+1, running_loss/300))
            running_loss = 0.0

Step 8:测试函数实现

  • torch.no_grad():上下文管理器,关闭自动求导,减少内存消耗

  • torch.max(outputs.data, dim=1):沿维度1(类别维度)取最大值,返回(最大值,对应索引)

  • predicted == labels:生成布尔张量,sum().item()统计True的数量(正确预测数)

  • torch.max(outputs.data, dim=1):在 outputs 的第1维度(类别维度)上取最大值,返回最大值及其索引。返回值:第一个返回值是最大值(未使用,用 _ 忽略)。第二个返回值是最大值的索引(即预测的类别)。predicted:模型预测的类别索引,形状为 [batch_size]。例如,MNIST数据集的 predicted 形状为 [128],每个元素是一个0到9的整数。

  • labels.size(0):获取 labels 在第0维度上的大小,即当前批次的样本数量(batch_size)。

def test():
    correct = 0
    total = 0
    with torch.no_grad():  # 禁用梯度计算(节省内存)
        for data in test_loader:
            images, labels = data
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            _, predicted = torch.max(outputs.data, dim=1)  # 取最大概率的类别
            total += labels.size(0)
            correct += (predicted == labels).sum().item()
    acc = 100 * correct / total
    print(f'Accuracy: {acc}%')
    return acc

Step 9 :主训练循环与可视化

  • 训练10个epoch:每个epoch遍历全部训练集一次,并在测试集评估

  • 可视化:使用Matplotlib绘制准确率随epoch变化的曲线,直观反映模型学习进度

if __name__ == '__main__':
    epoch_list = []
    acc_list = []
    
    for epoch in range(10):
        train(epoch)
        acc = test()
        epoch_list.append(epoch)
        acc_list.append(acc)
    
    end_time = time.time()
    print(f"Total time: {end_time - start_time:.2f}s")
    
    plt.plot(epoch_list, acc_list)
    plt.xlabel('Epoch')
    plt.ylabel('Accuracy (%)')
    plt.title('Training Progress')
    plt.show()

完整代码

import torch
from torchvision import transforms
from torchvision import datasets
from torch.utils.data import DataLoader
import torch.nn.functional as F
import torch.optim as optim
import matplotlib.pyplot as plt
import time
import os
os.environ["KMP_DUPLICATE_LIB_OK"]="TRUE"
# prepare dataset

batch_size = 128
transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))])
# # 定义数据预处理操作
# data_transforms = transforms.Compose([
#     transforms.Resize((224, 224)),  # 调整图像大小
#     transforms.ToTensor(),  # 转化为张量
#     # 归一化至 [0, 1] 范围内(假设图像为 RGB)
#     transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
# ])

train_dataset = datasets.MNIST(root='../dataset/mnist/', train=True, download=True, transform=transform)
train_loader = DataLoader(train_dataset, shuffle=True, batch_size=batch_size)
test_dataset = datasets.MNIST(root='../dataset/mnist/', train=False, download=True, transform=transform)
test_loader = DataLoader(test_dataset, shuffle=False, batch_size=batch_size)


# design model using class


class Net(torch.nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = torch.nn.Conv2d(1, 10, kernel_size=5)      # [1,28,28] -> [10,24,24] ; pooling to [10,12,12]
        self.conv2 = torch.nn.Conv2d(10, 20, kernel_size=5)     # [10,12,12] -> [20,8,8]; pooling to [20,4,4]
        # self.conv3 = torch.nn.Conv2d(20,320,kernel_size=3)       # [20,4,4] -> [30,2,2]; pooling to [30,1,1]
        self.pooling = torch.nn.MaxPool2d(2)
        self.fc = torch.nn.Linear(320, 10)

    def forward(self, x):
        # flatten data from (n,1,28,28) to (n, 784)

        batch_size = x.size(0)
        x = F.relu(self.pooling(self.conv1(x)))
        x = F.relu(self.pooling(self.conv2(x)))
        # x = F.relu(self.pooling(self.conv3(x)))
        x = x.view(batch_size, -1)  # -1 此处自动算出的是320
        # print("x.shape",x.shape)
        x = self.fc(x)

        return x

start_time = time.time()

model = Net()
#device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
if torch.cuda.is_available() == 1:      # '1' -> GPU ; '0' -> CPU;
    device = torch.device("cuda")
    print("CNN is running on GPU")
else:
    device = torch.device("cpu")
    print("CNN is running on CPU")
model.to(device)

#定义一个损失函数,来计算我们模型输出的值和标准值的差距
criterion = torch.nn.CrossEntropyLoss()
#定义一个优化器,训练模型咋训练的,就靠这个,他会反向的更改相应层的权重
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.5)#lr为学习率


# training cycle forward, backward, update


def train(epoch):
    running_loss = 0.0
    for batch_idx, data in enumerate(train_loader, 0):#每次取一个样本
        inputs, target = data
        inputs, target = inputs.to(device), target.to(device)
        optimizer.zero_grad()# 优化器清零
        outputs = model(inputs)# 正向计算一下
        loss = criterion(outputs, target) # 计算损失
        loss.backward()# 反向求梯度
        optimizer.step()#更新权重
        running_loss += loss.item()#把损失加起来
        if batch_idx % 300 == 299:#每300次输出一下数据
            print('[%d, %5d] loss: %.3f' % (epoch + 1, batch_idx + 1, running_loss / 300))
            running_loss = 0.0


def test():
    correct = 0
    total = 0
    with torch.no_grad():#不用算梯度
        for data in test_loader:
            images, labels = data
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            _, predicted = torch.max(outputs.data, dim=1)#我们取概率最大的那个数作为输出
            total += labels.size(0)
            correct += (predicted == labels).sum().item()#计算正确率
    print('accuracy on test set: %d %% ' % (100 * correct / total))
    return correct / total


if __name__ == '__main__':
    epoch_list = []
    acc_list = []

    for epoch in range(10):
        train(epoch)
        acc = test()
        epoch_list.append(epoch)
        acc_list.append(acc)

    end_time = time.time()
    execution_time = end_time - start_time
    print(f"神经网络生成与运行时间为:{execution_time}秒")

    plt.plot(epoch_list, acc_list)
    plt.ylabel('accuracy')
    plt.xlabel('epoch')
    plt.show()
    

运行结果

在这里插入图片描述
在这里插入图片描述

5. 模型优化:平衡速度与精度

  • 模型复杂度统计
total_params = sum(p.numel() for p in model.parameters())
print(f"总参数量:{total_params}")  # 示例模型约1.2M参数
  • FLOPs估算:使用thop库:
from thop import profile
flops, _ = profile(model, inputs=(torch.randn(1,1,28,28),))
print(f"FLOPs: {flops / 1e6:.2f}M")  # 示例模型约50M FLOPs

Pytorch 计算模型复杂度 (Params 和 FLOPs)

结语

通过MNIST数据集和PyTorch实战,我们不仅掌握了CNN的基础原理,还体验了从数据加载到模型优化的完整流程。尽管MNIST看似简单,但它为理解更复杂的视觉任务(如目标检测、图像分割)奠定了重要基础。读者可尝试修改网络结构、调整超参数,或挑战更高难度的数据集,进一步深化对CNN的理解。

扩展阅读与参考:

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐