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 Prompt,Mac/Linux用终端),我们创建一个专门用于本项目的环境:
conda create -n handwrite_digits python=3.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', train=True, download=True, transform=transform) test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform) # 创建数据加载器,用于小批量读取数据,打乱顺序,并行加载 train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=1000, shuffle=False)这里有几个关键点需要注意:
ToTensor(): 这一步不仅将PIL图像或NumPy数组转换成PyTorch的Tensor格式([C, H, W],即通道、高度、宽度),还自动将像素值从0-255的整数缩放到0-1的浮点数。这是深度学习模型的标准输入格式。Normalize(): 归一化是加速训练收敛的重要技巧。这里的(0.1307,)和(0.3081,)是MNIST数据集全体像素的均值和标准差。归一化后,数据分布会更接近标准正态分布,有利于优化器工作。DataLoader: 它负责在训练时按batch_size(一批的大小)给我们提供数据。shuffle=True在训练时打乱数据顺序,防止模型学到数据顺序的虚假规律。batch_size=64是一个常用的起始值,太小了训练不稳定,太大了内存可能不够。对于测试集,我们通常不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_channels=1, out_channels=6, kernel_size=5, padding=2) # 关键:padding=2使得输出尺寸不变 # 第一个池化层:2x2窗口,步长为2的最大池化 self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 第二个卷积块:输入6个特征图,输出16个特征图,卷积核5x5 self.conv2 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5) # 第二个池化层 self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 展平操作:将三维特征图拉成一维向量,为全连接层做准备 # 经过两次池化后,特征图尺寸计算:(28-5+4)/1 +1 = 28 -> /2 =14 -> (14-5)/1+1=10 -> /2 =5 # 所以展平后的向量长度是 16 * 5 * 5 = 400 self.flatten = nn.Flatten() # 第一个全连接层:400个输入特征,120个输出特征 self.fc1 = nn.Linear(in_features=16 * 5 * 5, out_features=120) # 第二个全连接层:120 -> 84 self.fc2 = nn.Linear(in_features=120, out_features=84) # 输出层:84 -> 10 (对应0-9十个数字) self.fc3 = nn.Linear(in_features=84, out_features=10) 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关键点解析与实操心得:
padding=2的奥秘: 原始LeNet-5输入是32x32,卷积核5x5,不填充(padding)的话,输出会是28x28。我们的输入直接是28x28,为了保持尺寸一致以便于后续计算,我在conv1中设置了padding=2。这意味着在图片四周各补两圈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(), lr=0.01, momentum=0.9) # 随机梯度下降优化器- 损失函数
CrossEntropyLoss: 它衡量的是模型输出的概率分布与真实标签的“距离”。对于手写数字识别这样的多分类问题,交叉熵损失是最直接、最有效的选择。 - 优化器
SGD with momentum: 我们选择了带动量的随机梯度下降。lr=0.01是学习率,控制着每次参数更新的步长,这是最重要的超参数之一。momentum=0.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(f'Train 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)不下降,或者变成NaN
Loss不降:
- 学习率太大或太小: 尝试调整
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(degrees=0, translate=(0.1, 0.1)) # 随机平移10%以内 - 简化模型/早停: 如果模型复杂度远超问题需要(对于MNIST,LeNet-5已经足够,更复杂的模型更容易过拟合)。也可以监控测试集损失,当它连续几个Epoch不再下降时,就停止训练(早停)。
- 引入Dropout: 在全连接层之间加入Dropout层,随机丢弃一部分神经元。例如,在
问题4:GPU内存不足(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, desc=f'Epoch {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的神秘感,让你明白再复杂的技术也是由一个个可理解、可控制的模块搭建而成。过程中遇到的每一个错误,查资料、分析、解决的过程,都是实实在在的能力成长。希望你在完成这个项目后,不仅能收获一个能识别数字的程序,更能获得一套解决未来更复杂深度学习问题的基本方法论和信心。