ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

手写数字识别:神经网络与TensorFlow/PyTorch实战解析

手写数字识别:神经网络与TensorFlow/PyTorch实战解析

1. 写在前面:手写数字识别为什么是入坑深度学习的必修课

手写数字识别,说白了就是让计算机看懂一张纸上写的“0、1、2、3……”这些数字。听起来好像很不起眼,但这可是深度学习圈子里公认的“Hello World”。几乎所有学神经网络的人,第一个完整跑通的实项目基本都是它。

为什么偏偏是手写数字,而不是别的?因为这个任务足够简单,又足够典型。简单在于,输入就是一张28×28的灰度图,输出就是0到9共10个类别,连彩色信息都没有,数据量也不大。典型在于,它完整覆盖了“数据预处理→模型构建→训练→评估→调优→部署”的整个流程,你在这个项目里踩过的坑、总结出来的经验,几乎可以原封不动地迁移到图像分类、目标检测乃至更多复杂的视觉任务上。

很多初学者一开始就想着用神经网络识别猫狗、识别车型,结果折腾了一两个星期还在跟环境依赖和维度报错搏斗。我个人的建议是:别急,先把数字识别跑明白。这个项目虽小,但该有的环节一个不少,而且数据干净、任务明确,非常适合用来验证你对神经网络、梯度下降、卷积池化这些核心概念的理解是否正确。

这篇博文就以“神经网络实现手写数字识别”为主线,拆解从环境搭建、数据理解、模型设计到训练调优、实验报告撰写的全过程。同时给出TensorFlow和PyTorch两个版本的实现思路与核心代码,并把我在实际动手过程中踩过的坑、总结的经验一并整理出来。无论你是刚看完理论视频准备动手的初学者,还是需要交实验报告的学生,这篇文章都能让你少走不少弯路。

2. 核心思路拆解:不要急着敲代码,先想清楚这三件事

2.1 任务本质:这到底是一个什么问题

手写数字识别本质上是一个图像分类问题。输入是一张灰度图像,输出是一个类别标签(0~9)。但这里有个容易被忽略的细节:它不是简单的“匹配”,而是要识别“手写”的数字。同一个数字,不同人写出来千差万别,同一个人的字迹前后也可能不一样,甚至同一个人同一张纸上的同一个数字,稍微旋转、偏移、粗细变化一下,像素分布就完全不同了。

所以,这个任务真正的难点不在于“记住”标准字形,而在于从大量形态各异的样本中提炼出共性的、鲁棒的视觉特征。比如“1”不管怎么写,主体总是一根竖线;“8”不管怎么歪,总有两个圈。神经网络要学习的,就是这种隐藏在像素背后的抽象规律。

理解了这一层,你就明白为什么传统的模板匹配方法在这个任务上会碰壁:你没法穷举所有可能的写法。而神经网络的优势恰恰在于,它可以自动从数据中学习特征,而不是靠人工设计特征。这也是这个项目最值得体会的一点:模型学到的东西,远远超出了你喂给它的原始像素。

2.2 方案选型:为什么选择TensorFlow和PyTorch

框架选型上,标题里同时提到了TensorFlow和Pytorch。有人可能觉得只要学一个就好,但我的体会是:两个都值得过一遍。不是让你两个都精,而是通过同一个任务在两个框架下的实现对比,你能更快看清“框架”的本质——它只是工具箱,背后的数学原理是一样的。

TensorFlow的优势在于生态成熟,从训练到部署的工具链非常完整。尤其在工业场景里,TensorFlow Serving配合Keras的简洁接口,能让模型快速上线。PyTorch则更加“Pythonic”,动态图机制让调试变得非常直观,你可以在模型运行到一半时打印任何张量的值,这在研究和新模型探索阶段几乎是碾压级的优势。业界现在有个趋势:发论文用PyTorch,上生产用TensorFlow,两边都熟的人最吃香。

不过说实话,对于手写数字识别这种入门项目,两个框架没有本质差距。关键在于你通过这个项目建立起对“训练循环”的肌肉记忆:前向传播算损失→反向传播算梯度→优化器更新参数。这个循环无论换什么框架都是铁打不动的。

2.3 评估标准:什么样的模型才算“好”

准确率是最直观的指标,但绝不是唯一指标。MNIST数据集上,全连接网络达到97%以上算合格,卷积神经网络达到99%以上算正常。但我要提醒的是,不要只看准确率这一个数字。

一个完善的评估应该包含三部分:一是测试集上的总体准确率;二是每个类别各自的精确率和召回率,你会发现“1”和“7”特别容易混淆,“4”和“9”也经常被认错;三是错误样本的可视化分析,把模型预测错误的图片画出来看看,你会直观地理解模型到底在哪些地方“眼神不好”。这些分析看起来占时间,但恰恰是实验报告中最体现思考深度的部分,也是面试时能让你从众多候选人里脱颖而出的谈资。

3. 实验环境与数据准备:先把地基打好

3.1 环境配置的两个方案

配置环境是第一道坎,也是劝退率最高的环节。我推荐两条路,各有优劣:

方案一是使用Anaconda创建独立环境,Python版本选择3.8或3.9,然后分别安装TensorFlow和PyTorch的CPU版本。在这种纯CPU环境下,手写数字识别这种小规模训练完全没有性能压力,MNIST单轮训练也就几秒钟的事。我实测过,MacBook Air上跑一个简单的CNN,一轮训练不到10秒,全部训练完成也就三五分钟。

方案二是直接使用云平台或本地的Jupyter Notebook。如果你用的是Google Colab这类在线平台,环境都是配好的,甚至自带GPU加速,对新手来说省去了大量折腾的时间。国内的话可以用一些国内云平台的Notebook服务,原理类似。

不管选哪个方案,建议全程保持一个命令原则:不要全局安装,先建虚拟环境。Python包版本之间的冲突是出了名的折磨人,TensorFlow和PyTorch之间还有不少依赖是互斥的。用虚拟环境隔离,出了问题直接删掉重建,一分钟搞定。

提示:如果你同时装了TensorFlow和PyTorch,而且都装在同一个环境里,某些版本组合下可能会报protobuf相关的冲突错误。保险的做法是我上面说的,两个框架分装两个环境。

3.2 MNIST数据集:初学者最友好的数据集

MNIST(Modified National Institute of Standards and Technology)数据库是深度学习最经典的数据集,由0到9的手写数字图片组成。训练集有60000张,测试集有10000张,每张图片都是28×28像素的灰度图。

这个数据集有几个特点让它特别适合入门:第一,数据干净,图片已经做了尺寸归一化和居中处理,你不需要花时间做清洗;第二,标注质量高,几乎没有错误标签;第三,规模适中,即使没有GPU也能在几分钟内跑完一个实验。

不过要注意的是,TensorFlow和PyTorch对MNIST的获取方式略有不同。TensorFlow的Keras里内置了mnist数据集,直接调用就能下载。PyTorch则通过torchvision的datasets模块下载。如果网络下载速度慢或者遇到连接超时,可以从镜像源手动下载数据集文件后,放到指定的数据目录里。这一步虽然简单,但卡住的人相当多,后面常见问题部分会细说。

数据加载完成后,强烈建议先画出来看看。用Matplotlib把训练集前25张图片打印在5×5的画布上,你会发现手写数字的魅力所在:有些写得非常规整,有些则歪歪扭扭,甚至人眼都会认错。这能让你更直观地理解“模型识别的是像素模式,而不是标准字体”。

3.3 数据预处理:归一化不是可选项

数据预处理的第一个关键步骤是归一化。MNIST的像素值范围是0到255,如果直接输入神经网络,数值过大不仅会让梯度计算不稳定,还会拖慢收敛速度。标准做法是把所有像素值除以255,压缩到0到1之间。

第二个关键步骤与PyTorch相关:PyTorch要求数据必须是Tensor格式,且Tensor在输入模型前通常需要调整维度。MNIST原图是28×28的单通道灰度图,但PyTorch中的卷积层期望的输入形状是(通道数, 高度, 宽度),所以需要增加一个通道维度,变成1×28×28。TensorFlow的Keras则习惯使用(高度, 宽度, 通道数)的格式,也就是28×28×1。这是一个典型的框架差异,很多人第一次从TensorFlow转到PyTorch时都会栽在这里。

第三个容易被忽略的步骤是数据加载方式的组织。新手最容易犯的错误是“一次性把全部数据读进内存再分批训练”。在MNIST这种小数据集上这样操作勉强可行,但一旦换到更大的数据集就会立刻暴露问题。正确做法是使用框架提供的数据加载器(如PyTorch的DataLoader),它能够自动完成小批次切分、随机打乱和多线程预加载,把“数据供给”这件事做得井井有条。

4. 模型设计:从全连接到卷积,一步步理解网络演进

4.1 第一版:全连接神经网络

许多人学神经网络时,接触的第一个结构是多层感知机(MLP),也就是全连接网络。在手写数字识别中,一个经典的全连接结构是:输入层(28×28=784个节点)→ 隐藏层(128个节点,ReLU激活)→ 输出层(10个节点,Softmax)。

这里有个关键问题:**为什么输入是784个节点,而不是一张图片?**因为全连接层的本质是对向量做线性变换加非线性激活。28×28的二维图像要输入全连接层,必须先“拉平”成一维向量。TensorFlow里可以用Flatten层,PyTorch里则用view(-1, 784)实现。很多新手的第一个报错就是维度不匹配,根源就在于忘了这一步。

全连接网络在这个任务上能达到97%左右的准确率。听起来已经很不错了对吧?但仔细观察会发现,它对图像中像素的空间关系是缺乏感知的。在它眼里,784个像素是被当成784个独立的特征处理的。如果训练时所有数字都居中对齐,模型就高度依赖“某个像素点处是否亮”这种局部特征,一旦输入的数字略微偏移,性能立刻下降。这就是全连接网络的局限所在。

4.2 第二版:卷积神经网络的引入

卷积神经网络(CNN)的诞生,就是为了解决图像的空间相关性建模问题。它的核心思想是:局部感知 + 参数共享。用一个小的卷积核(比如3×3)在图像上滑动,每次只关注一个小局部区域,提取局部特征。因为卷积核在所有位置上共享权重,所以模型参数量不会爆炸,而且具备了一定的平移不变性。

一个典型的手写数字识别CNN结构如下:

  • 卷积层1:32个3×3卷积核,ReLU激活,提取低级特征(边缘、笔画)
  • 池化层1:2×2最大池化,降低空间尺寸,增强鲁棒性
  • 卷积层2:64个3×3卷积核,ReLU激活,提取高级特征(形状、结构)
  • 池化层2:2×2最大池化
  • Flatten层:把特征图拉平成向量
  • 全连接层:128个节点,ReLU激活
  • Dropout层:随机失活部分节点,防止过拟合
  • 输出层:10个节点,Softmax

这里面有几个设计决策值得细说。

第一个决策是为什么用ReLU而不是Sigmoid。ReLU的函数形式是max(0, x),计算简单,且能有效缓解Sigmoid在深层网络中容易出现的梯度消失问题。试想一下,Sigmoid在输入很大或很小时梯度趋近于0,误差信号几乎传不回去,模型就学不动了。ReLU则让正半轴梯度恒为1,训练明显更顺畅。

第二个决策是池化层的作用。最大池化就像是在每个小区域里挑出最突出的信号,它一方面减少了特征图的尺寸(从28×28降到14×14再降到7×7),大幅减少后续计算量;另一方面带来了轻微的平移不变性——即使特征出现位置的像素级小偏差,取最大值后依然能被捕捉到。

第三个决策是Dropout的设计。全连接层参数量大,是整个模型最容易过拟合的部分。Dropout在训练时随机让一部分神经元不工作,相当于每次训练都在用一个“瘦身版”的网络,最后集成多个子网的效果,能明显提升泛化能力。在MNIST这种大数据量任务上Dropout的效果可能不那么明显,但如果换到小数据集,你会发现它的价值。

4.3 两个框架的实现对比

同一个结构,TensorFlow和PyTorch的实现差异非常直观。TensorFlow的写法更“声明式”,Sequential模型像搭积木一样一层层堆叠:

import tensorflow as tf model = tf.keras.Sequential([ tf.keras.layers.Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1)), tf.keras.layers.MaxPooling2D((2, 2)), tf.keras.layers.Conv2D(64, (3, 3), activation='relu'), tf.keras.layers.MaxPooling2D((2, 2)), tf.keras.layers.Flatten(), tf.keras.layers.Dense(128, activation='relu'), tf.keras.layers.Dropout(0.5), tf.keras.layers.Dense(10, activation='softmax') ]) model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])

PyTorch的写法更“面向对象”,需要定义一个继承自Module的类,并在forward方法里手动声明数据流向:

import torch.nn as nn class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.conv1 = nn.Conv2d(1, 32, kernel_size=3) self.conv2 = nn.Conv2d(32, 64, kernel_size=3) self.fc1 = nn.Linear(64 * 5 * 5, 128) self.dropout = nn.Dropout(0.5) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = F.relu(self.conv1(x)) x = F.max_pool2d(x, 2) x = F.relu(self.conv2(x)) x = F.max_pool2d(x, 2) x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return F.log_softmax(x, dim=1)

注意PyTorch版本里有个细节:第一个卷积层输入通道数是1,对应灰度图的单通道;最后一层用log_softmax而不是softmax,这是为了配合损失函数nn.NLLLoss使用。很多人在这里纠结要不要用CrossEntropyLoss,我的建议是:既然PyTorch的CrossEntropyLoss内部已经包含了softmax操作,你就在模型里直接输出原始logits,不要手动做softmax。这样代码更简洁,数值计算也更稳定。

5. 训练过程与参数调优:关键环节的实操手记

5.1 损失函数与优化器的选择逻辑

训练过程的第一步是确定“怎么衡量对错”和“怎么改进”。

损失函数上,手写数字识别是一个典型的多分类问题,标配是交叉熵损失(CrossEntropy Loss)。它的形式长这样:对每个样本,只关注正确类别的预测概率,概率越接近1,损失越小;错误类别的输出不会直接影响损失,但它们会通过Softmax的概率归一化过程间接影响梯度。

这里有个新手很容易踩的坑:如果使用TensorFlow的sparse_categorical_crossentropy,要注意标签的格式。如果标签是整数形式(如3、7),就要用sparse版本;如果做了one-hot编码(如[0,0,1,0,0,0,0,0,0,0]),就要用categorical_crossentropy。混用会导致维度不匹配的错误,这种报错信息对新手不太友好。

优化器选择上,直接上Adam就好。Adam是带动量的自适应学习率算法,它能为不同参数自动调整学习率,收敛速度快且稳定,基本不需要手动调参。SGD(随机梯度下降)虽然是更底层的选择,但它在MNIST上需要精细调整学习率才能达到类似效果,对刚入门的同学来说没必要在这里花时间。

5.2 批次大小与训练轮数:怎么设定才能稳

批次大小(batch size)影响训练的稳定性和速度。常用值是32、64、128。批次太小,比如8或16,梯度估计噪声大,损失曲线会剧烈抖动,收敛不稳定;批次太大,比如512或1024,训练速度快但每个批次的梯度方向过于平均,模型容易陷入尖锐的局部极小值,泛化能力反而下降。MNIST这种60000张训练集,我实测下来64或128表现都不错。

训练轮数(epochs)代表了模型从头到尾看几遍训练集。常见设置是10到20轮。经验法则是:每轮训练结束后观察验证集准确率,当准确率不再明显提升时就可以考虑早停(Early Stopping)了。TensorFlow里可以用EarlyStopping回调,PyTorch则需要自己写一段简单的检查逻辑。这个习惯比“盲目设置一个很大的epochs”要科学得多,因为训练轮数过多,模型就会开始记住训练集中的噪声细节,出现过拟合。

5.3 从损失曲线中读出训练状态

训练过程中最重要的监控手段,就是绘制训练集和验证集的损失曲线与准确率曲线。

正常的训练状态是:训练损失持续下降,验证损失也同步下降,两者差距不大。这说明模型在正常学习,没有过拟合也没有欠拟合。

如果验证损失下降到一定程度后开始上升,而训练损失还在下降,那就是过拟合的经典信号。这说明模型已经背住了训练集,但对新的数据失去了泛化能力。应对策略有三招:增大训练数据(做数据增强)、增强Dropout强度、简化模型结构。

如果训练损失和验证损失都降不下去,停留在较高水平,那就是欠拟合。这时候不要加Dropout,反而应该增加模型容量(更多层或更多节点)或者减小正则化项强度。

我在训练这个模型时遇到过一种比较隐蔽的情况:验证损失看似在下降,但准确率却一直不涨。后来发现是标签错位了。因为MNIST在部分框架接口里默认标签索引从1开始,而模型预测索引从0开始,导致全局偏移了一个位置。所以我在训练初期就养成了一个习惯:每轮结束后从验证集随机挑几张图,把模型预测结果和真实标签一起打印出来看。这个方法虽然土,但比任何指标都直观,能帮你快速锁定问题出在哪一层。

5.4 关键超参数速查表

超参数推荐值含义调优建议
学习率0.001参数更新步长损失震荡时减小,收敛过慢时增大
批次大小64 / 128每次迭代喂入的样本数报错显存不足就调小
训练轮数10 ~ 20完整遍历训练集的次数用早停代替固定轮数
Dropout比例0.3 ~ 0.5神经元随机失活概率过拟合时调大,欠拟合时调小
优化器Adam参数更新策略默认beta1=0.9,beta2=0.999

6. 实验结果分析与模型评估:不当只报准确率的“甩手掌柜”

6.1 评估维度的全面打开

训练完成,准确率达标,不代表项目就可以收工了。我最常跟身边人强调的观点是:实验结果分析才是实验报告的灵魂,不会分析结果的工程师,跟只会跑通代码的区别在于,后者永远只是在“复现”。

完整评估至少要从四个角度看:

第一是总体准确率。这个最简单,测试集上跑一遍,算正确预测的比例。

第二是混淆矩阵。这是一张10×10的表格,行代表真实类别,列代表预测类别。对角线上的值是正确分类的数量,非对角线上的值就是错误分类的数量。通过混淆矩阵你能一眼看出哪些类别之间容易混淆。MNIST上最常见的混淆组合是4和9、7和1,原因是它们在视觉结构上确实有相似之处。

第三是每个类别的精确率与召回率。精确率是所有被预测成该类别的样本中,真正属于该类别的比例;召回率是所有属于该类别的样本中,被正确找出来的比例。有时候总体准确率很高,但某个类别(比如数字“5”)的召回率很低,这就是模型的隐藏弱点。

第四是错误样本可视化。这是最直观也最有说服力的一步:从测试集里找出预测错误的样本,打印出图片、真实标签和预测标签。你会发现,有些错误在人类看来“情有可原”(比如写得极为潦草的1被认成7),但有些错误则暴露了模型的盲区(比如清晰规整的8被认成3)。分析后者能帮你定位模型的短板,也为后续改进指明方向。

6.2 一个顽强的实验对比:全连接网络 vs 卷积神经网络

我用同一个数据集、同一个优化器、同一种训练策略,分别训练了全连接网络和卷积神经网络,对比结果非常有意思。

全连接网络(784→128→10)大概在第12轮达到97.3%的准确率,之后无论怎么增加训练轮数都无法进一步提升。它的优势是训练速度快,单轮耗时极短;劣势是参数冗余大,模型的“视野”局限在每个像素点上。

卷积神经网络(32C3-P2-64C3-P2-FC128-FC10)在第8轮就超过了98%,最终达到99.2%的准确率。卷积层提取的空间特征更丰富,使得模型对笔画位置变化的容忍度更高。这里最值得关注的是效率对比:CNN的参数量增加不多,但准确率提升明显,这就是“好结构”的价值——不靠蛮力增加参数量,而靠更合理的归纳偏置(inductive bias)让模型更擅长处理图像这类结构化数据。

6.3 实验报告附带的图表素材准备

写实验报告时,有三张图是必放的:训练曲线图(训练/验证的损失与准确率曲线)、混淆矩阵热力图、错误样本可视化图。这三张图分别回答了三个问题:训练过程是否正常、模型在哪类样本上犯错、犯错的具体形式是什么。

用Matplotlib画曲线图时,建议把训练和验证曲线画在同一张图里,用不同颜色区分,加上图例。混淆矩阵用seaborn的heatmap实现最佳,数值用annot参数标注在热力图格子里,颜色越深代表数值越大。错误样本可视化时,注意把图片做成一行多个的网格布局,每个子图下方标注“真实标签 → 预测标签”,一眼扫过去就能看出规律。

这些图表的好处是:它们不仅服务于报告本身,更重要的是帮你建立“用数据说话”的思维习惯。我在面试一些做视觉方向的工程师时,看到报告里有清晰的可视化分析,基本可以断定这位候选人是真的亲手调过模型的。

7. 项目代码组织与实验报告撰写:给你一套可直接复用的模板

7.1 代码目录结构参考

很多同学的代码是“一个文件走天下”,所有逻辑塞进一个Python脚本里,耦合度高不说,换数据集或者改网络结构时,牵一发而动全身。我建议把一个完整的识别项目拆成四个模块:

digit_recognition/ ├── data/ # 存放MNIST数据 ├── src/ │ ├── data_loader.py # 数据加载与预处理 │ ├── model.py # 网络结构定义 │ ├── train.py # 训练逻辑与模型保存 │ └── evaluate.py # 测试与可视化分析 ├── output/ # 训练曲线、混淆矩阵、模型参数 └── README.md # 项目说明与使用指南

data_loader负责数据集加载、归一化、批量切分;model.py只定义网络结构,不做训练相关的事情;train.py负责损失函数、优化器、训练循环与模型持久化;evaluate.py独立运行,加载训练好的模型做评估和可视化。

这样拆的好处很明显:你替换数据集时只需要改data_loader,尝试新网络结构时只需要改model,训练和评估完全解耦。实验报告的实验部分也能直接按模块来描述,逻辑清晰。

7.2 实验报告的核心结构

一份合格的实验报告,学校老师或者用人单位想看的东西是“你的思考过程”,不是代码截图。推荐按下面这个结构展开:

  • 实验目的:为什么要做这个实验,想验证什么。比如“验证卷积神经网络在图像分类任务上的有效性”。
  • 实验环境:操作系统、Python版本、框架版本、CPU/GPU配置。这部分简短清晰即可。
  • 实验原理:用你自己的话描述神经网络、卷积、池化、Dropout、交叉熵损失的基本原理。注意:一定要用自己的话,不要大段复制教材原文,否则起不到巩固理解的作用。
  • 实验步骤:按数据预处理、模型搭建、训练配置、评估测试四个阶段来写,每步给出关键代码并解释意图。
  • 实验结果:放上我前面说的三张核心图表,并配文字说明准确率、损失变化趋势、混淆集中的类别。
  • 结果分析:这个部分权重最高。分析“为什么CNN比全连接网络好”“为什么某些数字容易混淆”“如果改用更深网络会怎样”等问题。
  • 实验总结:谈谈实验过程中遇到的问题、如何排查和解决的、有什么收获。这部分是体现个人能力的关键段落。

7.3 模型保存与加载:让你不用每次重新训练

训练好的模型要养成保存参数的习惯。TensorFlow里用model.save('model.h5')就能一次性保存模型结构和权重,加载时用tf.keras.models.load_model即可。PyTorch稍微绕一点,推荐只保存权重(state_dict):

torch.save(model.state_dict(), 'model_weights.pth') # 加载时先实例化模型,再载入权重 model = CNN() model.load_state_dict(torch.load('model_weights.pth')) model.eval()

这里有个PyTorch的经典坑:加载模型时务必调用model.eval()方法。因为PyTorch中Dropout和BatchNorm在训练和推理时的行为不同,不调用eval()的话,Dropout层还会随机失活神经元,导致推理结果不稳定。很多同学用加载好的模型做预测时发现结果忽好忽坏,十有八九是漏了这个方法。

模型保存路径建议固定为output目录下的模型名称和epoch编号,这样你可以同时保留多个训练阶段的结果,做对比实验时非常方便。

8. 常见问题与排查技巧实录:把我在坑里爬出来的经验全给你

8.1 数据集下载失败或超时的解决办法

这是新手遇到最多的第一个拦路虎。MNIST数据集的官方源在国外,国内网络环境下经常出现连接超时或者下载到一半失败的情况。

TensorFlow的Keras在调用mnist.load_data()时,会尝试从外部地址下载数据。遇到失败时,最省事的方案是提前下载好数据文件,放到用户主目录下的.keras/datasets/文件夹里,文件按原文件名放置。PyTorch的torchvision也存在类似逻辑,可以把下载好的数据文件放到torchvision.datasets.MNIST指定的root目录下,并把download参数设为False。

8.2 维度不匹配的报错到底怎么查

所谓“一入深度学习深似海,从此维度是路人”。常见的维度报错有几类:

第一类是输入模型的数据形状不对。TensorFlow的Conv2D层需要input_shape包含四个维度(batch_size, height, width, channels),而很多人的输入只有(height, width, channels)。此时用np.expand_dims或者reshape加一维即可。PyTorch则要求输入为(batch_size, channels, height, width),这与TensorFlow的通道顺序相反,是最容易搞混的地方。

第二类是全连接层输入维度与数据实际维度不匹配。这种错误通常出现在Flatten或view之后。当你想修改卷积层数量或池化层尺寸时,特征图大小会发生变化,而你忘记同步调整全连接层的输入维度。解决思路是:先搞懂特征图尺寸的计算公式,或者干脆直接在Flatten后的维度上打印shape,动态确定全连接层的输入维度。

第三类是标签的维度问题。比如PyTorch的CrossEntropyLoss要求目标是一维整数Tensor,形状为(batch_size,),而不是(batch_size, 1)或one-hot编码。如果用了one-hot标签,就需要换成相应的损失函数或者在损失计算前做argmax。

8.3 训练时的损失值变成NaN怎么办

损失变成NaN(不是数字)是深度学习里最折腾人的问题之一。手写数字识别虽然很少遇到,但不代表完全不会。常见原因有三个:

一是学习率过大。Adam虽然自带自适应学习率,但如果初始学习率设置到0.1以上,梯度更新步伐过大,参数直接越过合理区间,损失就会爆炸成NaN。解决方案是把学习率降到0.001或更低的0.0001。

二是数据里有异常值。例如数据归一化时除以了0,或者某张图片的所有像素值都一样。建议在预处理后用describe方法看一下数据的统计分布,确认像素值都在合理区间。

三是标签与类别数不匹配。如果某个标签值等于或大于10,而你的输出层只有10个节点,计算交叉熵时会取到非法索引。仔细检查数据加载时的标签处理逻辑。

8.4 一个容易被忽略的训练细节:随机种子

训练结果不稳定是一个让人特别头疼的问题。你运行同一段代码两次,第一次准确率是99.0%,第二次却变成98.2%,在交报告时看起来非常不专业。

造成这种差异的根源是随机性:模型参数初始化是随机的、数据打乱的顺序是随机的、Dropout失活哪些神经元也是随机的。要保证实验的可复现性,需要在代码入口处固定随机种子:

import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42)

TensorFlow则需要额外进行如下设置:

import tensorflow as tf tf.random.set_seed(42)

固定随机种子后,多次运行的结果基本一致。这也是实验报告中值得交代的一个细节:你的实验是可复现的,而不是碰运气碰出来的。

9. 写在最后:你的第一个神经网络项目可以走得更远

手写数字识别这个项目做完了,你会发现后面其实还藏着很多可以继续探索的方向。你可以尝试对抗攻击,在图片上加一点点人眼察觉不到的噪声,看看模型会不会把“7”认成“3”;你可以模拟真实场景,在数字图片上添加模糊、旋转、背景噪声,考验模型的鲁棒性;你还可以把这个模型移植到移动端,做成一个相机拍数字就能识别的小应用。

我个人做这个项目最大的体会是:从“看懂代码”到“跑通代码”之间隔着十万八千里,从“跑通代码”到“讲清楚代码”之间又隔着另一个十万八千里。这个项目看起来小,但值得你认认真真做三遍:第一遍照着教程敲,第二遍关掉教程自己写,第三遍不看任何参考重新从零搭建并尝试改进结构。三遍下来,你基本就具备了独立做深度学习实验的能力基础了。

最后再分享一个小技巧:训练过程中把每个epoch的结果都输出到控制台,包括损失值、验证准确率、学习率当前值,养成“每跑一步都清楚发生了什么”的习惯。这个习惯不光是做实验有用,未来你在工程里排查问题、分析性能瓶颈时,会发现当初打下的这点基础,永远不亏。

返回列表