从零实现手写数字识别:基于PyTorch与LeNet-5的深度学习入门实战

从零实现手写数字识别:基于PyTorch与LeNet-5的深度学习入门实战
1. 项目概述从零构建一个手写数字识别系统手写数字识别这个看似简单的任务却是无数人踏入深度学习世界的第一块敲门砖。它就像学编程时的“Hello World”但内涵要丰富得多。我至今还记得第一次用代码成功识别出自己潦草写下的“7”时的那种兴奋感它不仅仅是屏幕上跳出一个数字更是一种“机器能看懂我”的奇妙交互的开始。这个项目之所以经典是因为它完美地浓缩了深度学习项目从数据准备、模型设计、训练调优到最终部署的全流程而且数据集MNIST干净、问题定义清晰让你能专注于理解模型本身而不是在数据清洗的泥潭里挣扎。简单来说我们要做的就是教电脑认识0到9这十个手写数字。你可能会想这有什么难的规则写清楚不就行了。但手写体的麻烦就在于它的无限多样性——每个人的笔迹粗细、倾斜角度、数字形状都千差万别用传统的“如果-那么”规则去描述会陷入无穷无尽的特例判断。而深度学习特别是卷积神经网络CNN提供了一种截然不同的思路我们不写规则而是给机器看海量的例子让它自己从像素中总结出数字的“特征”比如“0”通常是一个闭合的圆圈“1”是一根竖线等等。最终我们得到一个模型文件它就像是一个被训练过的“数字专家”你丢给它一张新的手写数字图片它就能告诉你它认为这是几。这个项目非常适合以下几类朋友首先是刚学完Python基础想找个有意思的实战项目练手的编程新手其次是对人工智能感兴趣但被各种复杂理论吓退的初学者从这里入手可以建立最直观的感性认识最后哪怕是已经有一定经验的开发者通过亲手实现一个经典的LeNet-5网络也能帮你夯实CNN的基础理解卷积、池化这些核心操作到底在干什么。整个过程我们都会使用Python配合PyTorch这个当下最主流的深度学习框架之一来完成它设计优雅像搭积木一样构建网络对新手非常友好。2. 核心思路与方案选型为什么是CNN和LeNet-5当我们决定用深度学习来解决手写数字识别时面前其实有很多条路。最简单的我们可以把一张28x28的图片的784个像素点直接拉成一条长线输入到一个全连接网络也叫多层感知机MLP里去。这确实能工作我在最早期的实验中就这么干过准确率也能做到97%以上。但很快我就发现了问题首先全连接网络参数量巨大784个输入到128个隐层就有超过10万个参数训练慢且容易过拟合更关键的是它完全忽略了图片的空间结构信息。对网络来说左上角的像素和右下角的像素是平等且独立的它无法理解“相邻的像素点组合起来可能代表一条边或一个角”这个概念。这就像让你通过只听每个独立音符来识别一首曲子而不是听旋律事倍功半。所以我们引入了卷积神经网络CNN。它的核心思想是“局部连接”和“权值共享”。想象一下你判断一个数字是不是“8”可能会先看它中间有没有两个圈。CNN里的“卷积核”就像是一个小小的、可移动的“特征探测器”比如一个3x3的小方块它只扫描图片的一小块局部区域专门检测某种特定的模式比如从亮到暗的垂直边缘。这个探测器在整个图片上滑动共享同一套参数这样无论边缘出现在图片的哪个位置都能被检测出来。这极大地减少了参数量并且让网络拥有了平移不变性——数字“7”在图片中间还是角落都应该被识别出来。紧接着的“池化”操作比如最大池化则是对卷积后的特征图进行降采样它保留了最显著的特征比如某个区域内最强的边缘响应同时让特征对微小的位置变化不那么敏感进一步增强了模型的鲁棒性。在众多CNN结构中我们选择了LeNet-5。这不是因为它最强大事实上它很简单而是因为它足够经典、足够清晰是理解CNN架构的绝佳蓝图。由Yann LeCun等人在1998年提出最初就是用于银行支票上的手写数字识别。它的结构一目了然两次“卷积-池化”的交替后面接上几个全连接层。这个设计范式至今仍是许多复杂网络的基石。选择复现LeNet-5就像学书法先临摹颜真卿掌握了基本笔法和结构以后再去创造自己的风格比如更深的ResNet、更高效的MobileNet就会心中有数。对于MNIST数据集28x28的灰度图来说LeNet-5的性能已经绰绰有余轻松达到99%以上的准确率让我们能把更多精力放在理解流程和代码实现上而不是一味地堆叠模型复杂度。框架方面我选择了PyTorch。相比其他框架PyTorch的“动态计算图”和Pythonic的设计哲学让它用起来非常直观。你可以像写普通Python程序一样构建和调试网络每一步操作都清晰可见。这对于学习和实验阶段来说是巨大的优势。TensorFlow当然也很强大但其静态图模式和稍显复杂的API对新手可能不太友好。PyTorch活跃的社区和丰富的教程也能确保你在遇到任何坑时都能快速找到解决方案。3. 环境搭建与数据准备打造你的数字实验室工欲善其事必先利其器。在开始写模型代码之前一个干净、可复现的开发环境至关重要。我最推荐的方式是使用Anaconda来管理Python环境它能很好地解决不同项目间包版本冲突的问题。首先去Anaconda官网下载并安装适合你操作系统的版本。安装完成后打开命令行Windows用Anaconda PromptMac/Linux用终端我们创建一个专门用于本项目的环境conda create -n handwrite_digits python3.8这里我指定了Python 3.8这是一个在深度学习领域兼容性非常广的版本。环境名handwrite_digits可以按你喜好修改。创建完成后激活它conda activate handwrite_digits你会看到命令行提示符前面变成了(handwrite_digits)这表示你已经进入了这个独立的环境。接下来安装核心的PyTorch。访问PyTorch官网利用它的安装命令生成器。根据你是否有NVIDIA显卡以及对应的CUDA版本来选择命令。如果你没有显卡或不确定就选择CPU版本对于MNIST这样的小数据集CPU训练完全可行只是慢一点。例如在Mac上安装CPU版本pip install torch torchvision torchaudio安装完成后可以在Python里import torch测试一下并打印torch.__version__看看是否成功。数据是模型的燃料。MNIST数据集堪称深度学习界的“果蝇”它包含了6万张训练图片和1万张测试图片每张都是28x28像素的灰度手写数字。使用torchvision这个PyTorch的视觉工具包我们可以用几行代码就把它下载并准备好import torch from torchvision import datasets, transforms # 定义数据预处理转换将图片数据转换为Tensor并做归一化将像素值从0-255缩放到0-1之间 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) # MNIST数据集的均值和标准差 ]) # 下载并加载训练集和测试集 train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) # 创建数据加载器用于小批量读取数据打乱顺序并行加载 train_loader torch.utils.data.DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader torch.utils.data.DataLoader(test_dataset, batch_size1000, shuffleFalse)这里有几个关键点需要注意ToTensor() 这一步不仅将PIL图像或NumPy数组转换成PyTorch的Tensor格式[C, H, W]即通道、高度、宽度还自动将像素值从0-255的整数缩放到0-1的浮点数。这是深度学习模型的标准输入格式。Normalize() 归一化是加速训练收敛的重要技巧。这里的(0.1307,)和(0.3081,)是MNIST数据集全体像素的均值和标准差。归一化后数据分布会更接近标准正态分布有利于优化器工作。DataLoader 它负责在训练时按batch_size一批的大小给我们提供数据。shuffleTrue在训练时打乱数据顺序防止模型学到数据顺序的虚假规律。batch_size64是一个常用的起始值太小了训练不稳定太大了内存可能不够。对于测试集我们通常不shuffle并且可以用更大的batch_size来快速评估。注意第一次运行代码下载数据集可能会比较慢取决于你的网络。root./data指定了数据下载到当前目录下的data文件夹请确保你有写入权限。下载完成后后续运行就不会再下载了。4. LeNet-5模型详解与PyTorch实现现在我们来亲手搭建LeNet-5这座“小房子”。理解每一层的作用比单纯复制代码更重要。LeNet-5的原始输入是32x32的图像但我们的MNIST是28x28。为了适配我们通常会在网络的第一层做一些微调或者简单地将MNIST图片填充pad到32x32。这里我们采用一种更常见的、直接适配28x28的变体。让我们一层一层地拆解这个网络并用PyTorch的nn.Module来实现它import torch.nn as nn import torch.nn.functional as F class LeNet5(nn.Module): def __init__(self): super(LeNet5, self).__init__() # 第一个卷积块输入通道1灰度图输出6个特征图卷积核5x5 self.conv1 nn.Conv2d(in_channels1, out_channels6, kernel_size5, padding2) # 关键padding2使得输出尺寸不变 # 第一个池化层2x2窗口步长为2的最大池化 self.pool1 nn.MaxPool2d(kernel_size2, stride2) # 第二个卷积块输入6个特征图输出16个特征图卷积核5x5 self.conv2 nn.Conv2d(in_channels6, out_channels16, kernel_size5) # 第二个池化层 self.pool2 nn.MaxPool2d(kernel_size2, stride2) # 展平操作将三维特征图拉成一维向量为全连接层做准备 # 经过两次池化后特征图尺寸计算(28-54)/1 1 28 - /2 14 - (14-5)/1110 - /2 5 # 所以展平后的向量长度是 16 * 5 * 5 400 self.flatten nn.Flatten() # 第一个全连接层400个输入特征120个输出特征 self.fc1 nn.Linear(in_features16 * 5 * 5, out_features120) # 第二个全连接层120 - 84 self.fc2 nn.Linear(in_features120, out_features84) # 输出层84 - 10 (对应0-9十个数字) self.fc3 nn.Linear(in_features84, out_features10) def forward(self, x): # 前向传播过程定义了数据如何流过网络 # 卷积块1: Conv - ReLU - Pool x self.pool1(F.relu(self.conv1(x))) # 卷积块2: Conv - ReLU - Pool x self.pool2(F.relu(self.conv2(x))) # 展平 x self.flatten(x) # 全连接层: Linear - ReLU x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) # 输出层不接ReLU因为后面要用CrossEntropyLoss它内部包含了Softmax x self.fc3(x) return x关键点解析与实操心得padding2的奥秘 原始LeNet-5输入是32x32卷积核5x5不填充padding的话输出会是28x28。我们的输入直接是28x28为了保持尺寸一致以便于后续计算我在conv1中设置了padding2。这意味着在图片四周各补两圈0让5x5的卷积核在滑动时中心能对准原图的边缘像素从而输出尺寸仍是28x28。这是处理尺寸匹配问题时一个常用的小技巧。展平Flatten层的计算 这是新手最容易出错的地方。你必须清楚卷积和池化后特征图的尺寸变化。公式是输出尺寸 (输入尺寸 - 卷积核尺寸 2*填充) / 步长 1。池化层通常步长等于核大小。我上面代码注释里已经算好了最终得到5x5的特征图共16个通道所以展平后是400维。把这个数字算对nn.Linear的in_features参数才能填对否则会运行时错误。激活函数ReLU 我们使用F.relu()这是整流线性单元公式是f(x)max(0,x)。它相比传统的Sigmoid或Tanh函数能有效缓解梯度消失问题让深层网络更容易训练。它现在是深度学习中最默认的激活函数。输出层没有激活函数 注意看self.fc3(x)之后我们没有接任何如Softmax的激活函数。这是因为PyTorch的nn.CrossEntropyLoss损失函数在设计时已经将Softmax计算集成在内了。这样设计在数值稳定性上更好。所以网络最后一层直接输出10个“分数”logits即可。实操心得在定义好模型类后立即实例化并打印一下模型结构并传入一个随机张量测试前向传播是否通畅这是一个非常好的习惯。model LeNet5() print(model) # 测试一个批量 test_input torch.randn(4, 1, 28, 28) # 4张图1通道28x28 output model(test_input) print(f输出形状{output.shape}) # 应该是 torch.Size([4, 10])这能帮你快速发现网络结构定义中的维度错误。5. 模型训练全流程调参、迭代与损失监控模型搭好了数据也准备好了接下来就是最核心的训练环节。训练的本质是让模型在训练数据上“学习”通过不断调整内部参数使得它的预测输出越来越接近真实答案标签。这个过程依赖于三个关键组件损失函数、优化器和迭代循环。首先我们定义损失函数和优化器import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model LeNet5().to(device) # 将模型移动到GPU如果可用或CPU criterion nn.CrossEntropyLoss() # 交叉熵损失分类任务标配 optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9) # 随机梯度下降优化器损失函数CrossEntropyLoss 它衡量的是模型输出的概率分布与真实标签的“距离”。对于手写数字识别这样的多分类问题交叉熵损失是最直接、最有效的选择。优化器SGD with momentum 我们选择了带动量的随机梯度下降。lr0.01是学习率控制着每次参数更新的步长这是最重要的超参数之一。momentum0.9给优化过程增加了“惯性”可以帮助加速收敛并减少震荡。对于MNIST和LeNet-5这个配置是一个很好的起点。设备选择model.to(device)这行代码很重要。如果有NVIDIA GPU且安装了CUDA版本的PyTorch模型和数据都会被移到GPU上训练速度会有数量级的提升。接下来我们进入训练循环。一个完整的循环Epoch是指模型看遍了整个训练集一次。def train(model, device, train_loader, optimizer, criterion, epoch): model.train() # 将模型设置为训练模式启用Dropout、BatchNorm等 train_loss 0 correct 0 total 0 for batch_idx, (data, target) in enumerate(train_loader): data, target data.to(device), target.to(device) # 数据移至设备 optimizer.zero_grad() # **关键步骤**清空上一轮计算的梯度 output model(data) # 前向传播得到预测输出 loss criterion(output, target) # 计算损失 loss.backward() # 反向传播计算梯度 optimizer.step() # 根据梯度更新模型参数 # 记录本批次的统计信息 train_loss loss.item() _, predicted output.max(1) # 取概率最大的类别作为预测结果 total target.size(0) correct predicted.eq(target).sum().item() # 每处理一定批次打印一次进度 if batch_idx % 100 0: print(fTrain Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} f({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f}) # 计算本Epoch的平均损失和准确率 avg_loss train_loss / len(train_loader) accuracy 100. * correct / total print(f\nEpoch {epoch} 训练结果平均损失{avg_loss:.4f}, 准确率{accuracy:.2f}%) return avg_loss, accuracy训练过程中的核心技巧与避坑指南optimizer.zero_grad()必不可少 PyTorch的梯度是累加的。如果不每批次清零梯度会不断累积导致更新方向错误。这是新手常犯的错误结果就是模型无法收敛损失变成NaN。model.train()和model.eval() 在训练循环开始前我们调用model.train()在后面的测试评估时需要调用model.eval()。这两个模式主要影响像Dropout和BatchNorm这样的层。在训练时Dropout会随机“关闭”一部分神经元以防止过拟合BatchNorm会使用当前批次的统计量在评估时Dropout不起作用BatchNorm会使用训练阶段估算的全局统计量。用错了模式评估结果会不准确。损失值loss.item()loss是一个包含计算图的张量。使用.item()可以将其转换为Python标量数字方便打印和累加。直接对loss进行累加会导致计算图不断膨胀最终内存溢出。学习率与收敛观察 训练开始后要密切关注损失值的变化。理想情况下损失值应该随着Epoch增加而稳步下降最终趋于平缓。如果损失值剧烈震荡或迟迟不降可能是学习率设得太高如果下降极其缓慢可能是学习率太低。你可以尝试使用学习率调度器如optim.lr_scheduler.StepLR在训练后期降低学习率以微调模型。6. 模型评估与性能分析看看它学得怎么样训练不是闭门造车我们需要定期在模型从未见过的测试集上评估其性能这才能真正反映模型的泛化能力。评估代码和训练循环类似但更简单因为不需要计算梯度和更新参数。def test(model, device, test_loader, criterion): model.eval() # **关键**切换到评估模式 test_loss 0 correct 0 total 0 with torch.no_grad(): # **关键**禁用梯度计算节省内存和计算资源 for data, target in test_loader: data, target data.to(device), target.to(device) output model(data) test_loss criterion(output, target).item() # 累加损失 _, predicted output.max(1) total target.size(0) correct predicted.eq(target).sum().item() avg_loss test_loss / len(test_loader) accuracy 100. * correct / total print(f\n测试集结果平均损失{avg_loss:.4f}, 准确率{accuracy:.2f}%\n) return avg_loss, accuracy评估模式的核心要点model.eval() 如之前所述这会关闭Dropout等层的随机行为让模型以“确定性”的方式运行。with torch.no_grad(): 这是PyTorch的一个上下文管理器。在评估时我们不需要计算梯度因为不更新参数。这个语句块内的所有计算都不会被记录在计算图中可以大幅减少内存消耗并加速计算。务必养成习惯在评估时加上它。通常我们会在每个训练Epoch结束后或在训练了几个Epoch后调用一次test函数来监控模型在测试集上的表现。理想的训练过程是训练损失和测试损失同步下降训练准确率和测试准确率同步上升。如果出现“训练准确率持续上升但测试准确率停滞不前甚至下降”的情况那很可能发生了过拟合——模型把训练数据中的噪声和特定细节都记住了导致在新数据上表现不佳。针对MNIST和LeNet-5的预期与调优预期性能 一个正确实现的LeNet-5在MNIST上经过10-20个Epoch的训练测试集准确率很容易达到99%以上。如果准确率卡在98%左右可能是学习率、初始化或模型结构有小问题。如果低于95%那几乎可以肯定代码有bug。可视化分析 除了看准确率数字画出损失和准确率随Epoch变化的曲线图非常有帮助。你可以使用matplotlib库。如果曲线抖动厉害尝试减小学习率或增大batch_size。如果测试准确率很早就停止提升而训练准确率还在升可以考虑加入Dropout层在fc1和fc2之后来减轻过拟合或者使用数据增强如对训练图片进行随机微小旋转、平移来增加数据多样性。错误案例分析 查看模型具体在哪些图片上预测错了是极好的调试方法。你可以把测试集中预测错误的样本图片、真实标签和预测标签都保存下来直观地看看模型到底“看错”了什么。有时候你会发现一些错误样本连人眼都难以辨认这情有可原但如果是很清晰的数字却错了那就需要深入分析原因了。7. 常见问题排查与实战技巧实录在实际动手实现的过程中你几乎一定会遇到各种各样的问题。下面我整理了一份从我自己和学生们那里总结出来的“踩坑实录”和解决方案希望能帮你快速排雷。问题1运行时维度错误例如 “RuntimeError: mat1 and mat2 shapes cannot be multiplied...”原因 这是最常见的问题几乎100%出在全连接层nn.Linear的输入维度不匹配上。根本原因在于你计算卷积/池化后的特征图尺寸算错了。排查 在模型forward函数的每一步都打印一下x.shape。例如def forward(self, x): print(x.shape) # 初始输入 x self.pool1(F.relu(self.conv1(x))) print(x.shape) # 第一次卷积池化后 # ... 以此类推对比打印出的形状和你计算出的形状找到第一个对不上的地方。重点检查卷积层的padding和stride参数设置是否正确以及展平后的维度是否与第一个全连接层的in_features完全一致。问题2训练时损失值Loss不下降或者变成NaNLoss不降学习率太大或太小 尝试调整lr比如从0.01调到0.001或0.1。可以先用一个很小的学习率如0.001试几个批次看Loss是否微降如果能再调大。数据未归一化 确认数据预处理中是否做了ToTensor()缩放到0-1和Normalize()。没有归一化的数据会极大影响训练稳定性。模型初始化问题 PyTorch的nn.Conv2d和nn.Linear有默认的初始化方式通常没问题。但在某些极端情况下可以尝试其他初始化如nn.init.kaiming_normal_。Bug 检查optimizer.zero_grad()、loss.backward()、optimizer.step()这三步是否都在循环内且顺序正确。Loss变成NaN学习率过大 这是最可能的原因。过大的学习率会导致参数更新步伐太大直接“飞”出损失函数的合理范围。立即降低学习率比如除以10。数据包含异常值 检查数据集中是否有像素值异常如不是0-255。MNIST一般没问题但如果是自己的数据集要小心。梯度爆炸 在反向传播时梯度变得极大。除了降低学习率还可以尝试“梯度裁剪”torch.nn.utils.clip_grad_norm_。问题3训练准确率很高但测试准确率很低过拟合现象 训练几个Epoch后训练准确率接近100%但测试准确率可能只有97%-98%且不再增长。解决方案引入Dropout 在全连接层之间加入Dropout层随机丢弃一部分神经元。例如在fc1和fc2之后self.dropout1 nn.Dropout(0.5) # 丢弃概率为0.5 self.dropout2 nn.Dropout(0.5)在forward中x self.dropout1(F.relu(self.fc1(x)))。注意只在训练时使用Dropout评估时需关闭model.eval()会自动处理。数据增强 对训练图像进行随机变换增加数据多样性。可以在transforms.Compose里添加transforms.RandomRotation(10), # 随机旋转10度以内 transforms.RandomAffine(degrees0, translate(0.1, 0.1)) # 随机平移10%以内注意数据增强只应用于训练集测试集必须保持原样。简化模型/早停 如果模型复杂度远超问题需要对于MNISTLeNet-5已经足够更复杂的模型更容易过拟合。也可以监控测试集损失当它连续几个Epoch不再下降时就停止训练早停。问题4GPU内存不足CUDA out of memory原因batch_size设置过大或者模型/中间变量占用了过多显存。解决减小batch_size如从64减到32或16。在训练循环中使用with torch.no_grad():包裹不需要梯度的计算部分。及时将不需要的变量从GPU移回CPU或删除del variable。使用torch.cuda.empty_cache()清理缓存但这通常治标不治本。一个提升训练效率的小技巧使用tqdm进度条安装pip install tqdm然后可以让你训练循环的进度一目了然from tqdm import tqdm def train(...): ... for batch_idx, (data, target) in enumerate(tqdm(train_loader, descfEpoch {epoch})): ...这会让你的命令行显示一个漂亮的进度条包括预计剩余时间对于长时间训练非常有用。8. 项目总结与扩展思考当你看到测试集准确率稳稳地停在99.2%以上时这个基于LeNet-5的手写数字识别项目就算成功完成了。回顾整个过程我们从理解问题本质开始选择了合适的CNN架构搭建了开发环境准备了数据实现了模型设计了训练循环并最终评估了性能。这几乎是一个标准深度学习项目的微缩模板。但这个项目的价值远不止于此。你可以把它当作一个起点进行各种有趣的扩展这能让你学到更多挑战更复杂的数据集 把MNIST换成Fashion-MNIST衣物分类、CIFAR-10小物体彩色图片分类。你会发现LeNet-5在CIFAR-10上效果不佳准确率可能只有70%左右这会自然引向你对更现代、更深的网络如VGG、ResNet的探索需求。尝试不同的模型架构 自己设计一个更深的CNN比如增加卷积层数量使用更小的3x3卷积核或者加入BatchNorm层来加速训练和提高稳定性。对比它们与LeNet-5的性能差异。探索不同的优化器和技巧 把SGD优化器换成Adam看看收敛速度有何变化。尝试加入学习率调度器torch.optim.lr_scheduler比如在准确率平台期时将学习率减半。从分类到部署 学习如何将训练好的PyTorch模型.pth文件保存下来然后加载到一个简单的Web应用比如用Flask或Gradio搭建中。你可以做一个网页让用户用鼠标画一个数字然后实时调用你的模型进行识别。这个过程会让你理解模型从训练到实际应用的完整链路。深入原理 如果你对模型为什么有效感兴趣可以去了解卷积核到底学到了什么特征通过可视化第一层卷积核或者使用诸如Grad-CAM之类的技术来可视化模型在做决策时关注了图片的哪些区域。我个人在带新手做这个项目时最大的体会是跑通第一个模型带来的信心提升是巨大的。它打破了AI的神秘感让你明白再复杂的技术也是由一个个可理解、可控制的模块搭建而成。过程中遇到的每一个错误查资料、分析、解决的过程都是实实在在的能力成长。希望你在完成这个项目后不仅能收获一个能识别数字的程序更能获得一套解决未来更复杂深度学习问题的基本方法论和信心。