news 2026/10/13 2:48:51

MNIST手写数字识别实战:三种神经网络模型对比分析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MNIST手写数字识别实战:三种神经网络模型对比分析

MNIST这个数据集,玩深度学习的人基本都绕不开。上一篇我们用传统机器学习方法打了一遍底,最终在测试集上拿到了92%左右的准确率,这个分数对于传统模型来说已经不错了,但离实际可用还有距离。这次我们把神经网络搬上来,目标很明确:用三种不同结构的模型在同一份数据上做训练,最后把准确率、训练效率、模型体积放在一起对比,看看从简单到复杂,每一层设计到底带来了多少改变。文章会从环境准备写到训练排错,所有代码我都跑过,你照着敲就能复现。适合刚学完Python、想上手深度学习的读者,也适合那些看过理论但没实操过的朋友。

1. 项目背景与整体思路

1.1 MNIST为什么值得反复拿来练手

MNIST是手写数字识别,28×28的灰度图,0到9共10个类别。训练集有6万张,测试集1万张。单张图像像素低,几乎任何一个普通笔记本都能在几分钟内完成一轮简单模型的训练,这特别适合用来做深度学习实验的“最小闭环”。很多朋友一上来就跑去跑大型比赛数据集,结果被数据预处理和各种参数折腾得失去信心。我的建议是先把MNIST吃透,把每一个环节都弄明白,再去做复杂项目。

它最大的价值不在于数据集本身,而在于它能帮你验证一套标准流程:加载数据、设计模型、训练验证、评估对比。这套流程放到车牌识别、票据识别、快递单号识别上,逻辑完全一样。MNIST还有一个优点:类别均衡。十个数字的训练样本都在6000张上下,没有明显的长尾问题。你在它上面验证出来的训练策略,放到真实数据上时,至少不会因为类别不均衡而一开始就踩坑。

1.2 本篇目标与对比方案的确定

既然这是系列第二篇,上一篇已经确立了一个传统模型的基线:测试准确率92%。本篇要回答的问题是:换成神经网络,我们能提升多少?以及网络结构、训练技巧在其中起多大作用?

我选了三个有代表性的模型:

  1. 浅层全连接网络,只有一个隐藏层,算是从传统模型到神经网络的自然过渡;
  2. 深层全连接网络,加入Dropout和BatchNormalization,用来观察“深度”和正则化带来的变化;
  3. 卷积神经网络CNN,用卷积和池化提取空间特征,代表现代图像分类的主流思路。

这三者共享同一套数据划分、同一个评估函数和相同的损失函数,保证对比公平。除准确率外,我还会关注训练速度、参数量和过拟合程度,这些在项目落地时同样重要。后面所有结果都会统一整理成表格。

2. 训练环境准备与数据加载

2.1 环境配置与随机种子设定

我用的是Python 3.9 + TensorFlow 2.10这套组合,Keras的Sequential API非常适合快速搭模型。之所以选TensorFlow,而不是自己用numpy从头写反向传播,是因为生产环境里大家都是在框架上做工程,框架封装的自动求导、GPU并行、模型保存这些能力可以节省大量时间。当然,不是说你必须用TensorFlow,PyTorch同样可以完成本篇所有实验,只是API写法会稍有不同。

安装依赖很简单,我用的是pip:

pip install tensorflow==2.10.0 numpy matplotlib scikit-learn

如果机器上同时有TensorFlow和PyTorch,可能要留意环境隔离。我习惯用虚拟环境管理,避免不同项目把依赖搞乱。MNIST本身不挑环境,但随机种子一定要设好。

代码开始前,一定要固定随机种子。神经网络初始化权重是随机的,训练过程中的数据打乱也是随机的,不固定种子的话,每次跑出来的结果都会有波动,后面就没法对比了。

import tensorflow as tf import numpy as np import matplotlib.pyplot as plt tf.random.set_seed(42) np.random.seed(42)

这里我习惯再设置一个全局随机种子,如果用到了Python标准库random,也需要一起固定。一篇实验下来,你至少能保证别人复现出来的数字和你在同一水平线上。

2.2 数据加载、归一化与训练验证集划分

Keras内置了MNIST下载和读取接口,第一次运行会自动从网上下载,后面就会用本地缓存,这一点很省事。

# 加载数据 (x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data() # 归一化到0-1范围 x_train = x_train.astype("float32") / 255.0 x_test = x_test.astype("float32") / 255.0 # 从训练集中再切出验证集,用于早停和调参 from sklearn.model_selection import train_test_split x_train, x_val, y_train, y_val = train_test_split( x_train, y_train, test_size=0.1, random_state=42 ) print(x_train.shape, x_val.shape, x_test.shape)

归一化这一步很多人会忘,却特别关键。原始像素值是0到255的整数,如果不做归一化,网络的前向传播会被大数值输入搞得数值非常不稳定,梯度很容易爆炸,训练损失直接飘到NaN都是常事。除以255以后,输入落到0到1之间,梯度更新会更平稳。

验证集不是用来训练模型的,它的作用是“旁观者”。我在每个epoch结束后都用验证集算一次准确的loss和accuracy,当发现验证集指标连续若干轮不再提升时,就用EarlyStopping自动停下来。这样既节省时间,又能防止模型在训练集上一直学过头。

另外,可以随手画几个数字看看数据长什么样。我习惯把前10张图打印出来,确认标签和图像对齐。

plt.figure(figsize=(10, 4)) for i in range(10): plt.subplot(2, 5, i + 1) plt.imshow(x_train[i], cmap="gray") plt.title(f"Label: {y_train[i]}") plt.axis("off") plt.show()

这个步骤看似多余,但真能减少后面的定位时间。我见过有人把图读进来之后才发现通道顺序错了,出来的图像是倒着的,模型倒是能训练,只是测试集上准确率一直提不上去。先可视化一次,后面就不会有这种问题。

3. 三种神经网络的搭建与训练

3.1 浅层全连接网络(基线神经网络)

先写一个最简单的全连接网络,结构是:输入层784个节点(把28×28展平)→ 隐藏层128个节点,ReLU激活 → 输出层10个节点,Softmax激活。

model = tf.keras.Sequential([ layers.Input(shape=(28, 28)), layers.Flatten(), # 28*28=784 layers.Dense(128, activation="relu"), layers.Dense(10, activation="softmax") ])

为什么用128个隐藏节点?这个数字来自我的经验,既不会太小导致欠拟合,也不会太大让模型在MNIST上迅速过拟合。你可以试试64或256,差异不会特别大,但128在训练速度和表达能力的平衡上最舒服。顺便说一句,全连接网络处理图像的方式其实很“笨”:它把一个像素当成一个独立特征,学习到的权重是像素与像素之间的组合,完全不考虑像素彼此的空间位置。所以它也能工作,但效率不高。

这里的参数量很好算:784×128 + 128 = 100352,加上输出层的128×10 + 10 = 1290,总共约10.2万个参数。对于这个数据量来说,参数量不大,训练很快。

编译时我用SparseCategoricalCrossentropy作为损失函数。注意,如果标签是整数0到9,就选“sparse”版本;如果标签做成了one-hot编码,就要用CategoricalCrossentropy。这两个搞混了会直接报维度错误,前面检查了标签类型,就能少踩一个坑。

model.compile( optimizer="adam", loss="sparse_categorical_crossentropy", metrics=["accuracy"] ) history = model.fit( x_train, y_train, batch_size=32, epochs=20, validation_data=(x_val, y_val), callbacks=[tf.keras.callbacks.EarlyStopping(patience=3, restore_best_weights=True)] )

Adam优化器用默认学习率0.001,这套配置在我的机器上基本一遍就能收敛。训练完后的测试准确率大概能到97%左右,相比上一篇的92%传统基线已经高出一截,证明神经网络确实能学到传统模型学不到的边界特征。

3.2 深层全连接网络:加入Dropout与BatchNorm

接下来把网络加深,但同时用正则化手段防止过拟合。我用的结构和平时业务里常见的中型全连接网络接近:

model_deep = tf.keras.Sequential([ layers.Input(shape=(28, 28)), layers.Flatten(), layers.Dense(256, activation="relu"), layers.Dropout(0.5), layers.Dense(128, activation="relu"), layers.BatchNormalization(), layers.Dense(64, activation="relu"), layers.Dropout(0.3), layers.Dense(10, activation="softmax") ])

深层的价值是能拟合更复杂的非线性关系,但层数多了之后,过拟合和梯度流动问题也会跟着出现。Dropout是一种随机“丢弃”部分神经元的训练技巧,它让模型不能过度依赖某几个节点,本质上是做了模型集成。我把Dropout放在第一个和最后一个隐藏层外面,比例分别是0.5和0.3,这是根据以往经验调出来的。

BatchNormalization的作用是让每一层的输入分布尽量稳定。实验里我发现它能让收敛速度明显加快,后面学习率就算稍微调大一点也不会轻易出问题。注意,BatchNormalization一般放在Dense层之后、激活函数之前更好,不过Keras里也可以放在激活之后,效果差异不大,自己保持一致就好。

深层网络的参数量会增加到24.8万左右。训练结束后测试准确率能到98.2%上下,明显高于浅层网络。但我也观察到,训练集上的准确率接近99.5%,验证集上只有98.2%,这说明有了轻微过拟合。如果没有Dropout和BatchNorm,差距会更大,所以这两个组件的效果在表格里能看出明显差异。

3.3 卷积神经网络:利用空间结构进阶

CNN是图像任务的主力模型。和全连接层只看单个像素位置不同,卷积核能同时看到一个小区域内的邻近像素,从而提取边缘、拐角、纹理这些局部特征。它还有一个优势是参数共享,相同的卷积核会扫过整张图,所以哪怕模型看起来更深,实际参数量也比某些全连接层少得多。

下面是一个适合MNIST的小型CNN,结构比论文里的大网络精简很多:

model_cnn = tf.keras.Sequential([ layers.Input(shape=(28, 28, 1)), # 增加通道维度 layers.Conv2D(32, (3, 3), activation="relu"), layers.Conv2D(64, (3, 3), activation="relu"), layers.MaxPooling2D((2, 2)), layers.Dropout(0.25), layers.Flatten(), layers.Dense(128, activation="relu"), layers.Dropout(0.5), layers.Dense(10, activation="softmax") ])

这里特别需要注意输入维度。全连接模型用的是28×28,卷积模型必须显式增加一个通道维度,变成28×28×1。因为MNIST是灰度图,只有一个通道。如果漏了这一步,第一层卷积调用就会报维度错。

训练CNN时,我在前面加了数据增强:

data_augmentation = tf.keras.Sequential([ layers.RandomTranslation(height_factor=0.1, width_factor=0.1), layers.RandomRotation(factor=0.1), ])

随机平移和轻微旋转能让模型见过更多变体,减少过拟合。不过MNIST的数字本身已经很规整,增强强度不宜太大。我试过把旋转角度调到0.3,结果模型把一些正的数字也当成斜的,准确率反而下降了。

CNN的参数量大概在120万,其中大部分来自Flatten后的全连接层。我的笔记本CPU上每个epoch大约5秒,电脑稍好的会更快。最终测试准确率能到99.3%以上,基本是MNIST上比较理想的水平。

4. 训练细节与超参数调整实录

4.1 学习率、批次大小与优化器选择

训练神经网络,最常改的三个超参数就是学习率、batch size和优化器。很多人喜欢听“默认值”,但最好还是明白每个参数为什么这么设。

我用的是Adam优化器配合默认学习率0.001。Adam相当于给不同参数自适应地调整步长,它对学习率的敏感度比原生SGD低很多,省去了手工调整的麻烦。如果你发现损失曲线震荡得厉害,先把学习率降到0.0003看看,一般都能缓解。

批次大小我设成32。批次越小,梯度更新越频繁,训练波动会大一些,但有时能跳出局部最优;批次越大,训练越稳定,但也会占用更多内存,而且太大反而可能降低模型泛化能力。在MNIST上,32和64的最终准确率差不了0.3%,但batch_size=32的内存占用更小,所以我选了32。

我还试过学习率从0.01开始,配合指数衰减到0.0001。这种策略在更深的大模型上通常有效,但对本文这三个小模型来说,收益不明显,反而更容易在训练初期造成损失震荡。所以后面统一用了固定0.001,配合EarlyStopping,简单又稳定。

学习率浅层全连接最终准确率训练过程表现
0.0196.1%训练前几个epoch震荡明显
0.00197.1%收敛平稳,结果稳定
0.000196.8%收敛太慢,epoch不够时欠拟合

这个表格很能说明问题:学习率不是越大越好,也不是越小越好。如果训练轮数充足,小学习率也能追上,但浪费的训练时间会很多。

还有一个经验:不要只盯训练损失,要同时看验证损失。训练损失一直降,验证损失开始回升,这就是过拟合的信号。这时候该停就停,别舍不得。

4.2 正则化与数据增强的实战影响

为了验证这些技巧是不是玄学,我在深层全连接网络上做过一组对照实验:去掉所有Dropout和BatchNormalization,其他全部不变。结果训练准确率能冲到99.7%,但验证准确率只有97.2%,都比加了正则化的模型差。这就说明正则化不是为了压训练准确率,而是为了让模型在没见过的数据上表现更好。

数据增强的收益在CNN上更明显。没有增强时,CNN大概99.1%;加了轻微平移和旋转后能到99.3%以上。别看只有0.2%的提升,在工业级OCR场景中,每提升0.1%都意味着成百上千张图片的判断错误减少。

当然,增强不是越多越好。而且要注意,增强操作要在训练时对输入应用,而不是在验证或测试时使用。测试时模型必须看到原始图片,否则评估结果就没有参考价值。另外,数据增强要放在模型的第一层,我通常会单独构建一个data_augmentation模型,拼在model_cnn前面,这样训练时自动增强,测试时不会影响。

4.3 早停、模型检查点与复盘工具

除了上面这些,我强烈建议训练时加上两个回调:EarlyStopping和ModelCheckpoint。

checkpoint = tf.keras.callbacks.ModelCheckpoint( "best_model.h5", monitor="val_accuracy", save_best_only=True, verbose=1 ) early_stop = tf.keras.callbacks.EarlyStopping( monitor="val_loss", patience=5, restore_best_weights=True )

EarlyStopping会在验证指标连续几轮不涨时自动停止,避免浪费时间。ModelCheckpoint则在每轮后把表现最好的那一版权重存下来,防止后面训练过拟合把权重带偏。用这两个回调,几乎可以托管式训练。

我还会把训练历史中的准确率和损失曲线画出来,保存成图片。曲线比数字更能反映问题:如果训练损失和验证损失中间出现明显“分叉”,那过拟合就从那个位置开始了。这个习惯我一直保留,因为回到项目里看曲线,往往能回想起当初调参时的思路。

5. 不同模型结果对比与深入分析

5.1 准确率、参数量与训练时间对比表

我把三种模型放在同一台机器、同一个数据集、同一个随机种子下各跑三次,取平均结果。这里先说明,我手边是普通办公本,没独显,所以训练速度代表的是CPU场景下的体验。如果你是GPU机器,时间会缩短非常多,但模型之间的相对关系不变。

模型参数量每轮训练耗时(约)测试准确率训练集准确率是否过拟合
浅层全连接10.2万2秒97.1%98.8%轻微
深层全连接24.8万3秒98.2%99.4%中等,但可接受
小型CNN120.4万5秒99.3%99.6%轻微

训练集和测试集准确率的差值,可以粗略反映过拟合程度。差值越小,说明泛化越好。浅层全连接差值1.7%,深层全连接差值1.2%,CNN差值0.3%,说明CNN在MNIST这种带空间结构的数据上,确实最不容易过拟合。

你可能会问:深层全连接加了一堆正则化,为什么比浅层更容易过拟合?因为它的容量更大,参数量多了一倍多,理论上能记住更多样本,如果没有正则化压制,过拟合会更严重。我们加Dropout和BatchNorm,就是为了让“容量大”和“泛化好”这两件事尽量兼得。

5.2 混淆矩阵和错误样本分析

除了准确率,我还会看混淆矩阵,它能告诉我模型具体在哪些类别上犯错。

from sklearn.metrics import confusion_matrix import seaborn as sns y_pred = np.argmax(model_cnn.predict(x_test), axis=-1) cm = confusion_matrix(y_test, y_pred) plt.figure(figsize=(8, 6)) sns.heatmap(cm, annot=True, fmt="d", cmap="Blues") plt.xlabel("Predicted") plt.ylabel("True") plt.show()

以浅层全连接网络为例,最常见的错误是把“7”识别成“1”,把“4”识别成“9”。这其实是合理的,因为全连接网络对像素位置没有任何空间假设,它看不到“横竖撇捺”之间的笔画关系,只能靠整体像素分布去猜。

CNN的错误就很分散了,主要集中在几个连体数字、写法特别奇怪的“7”和“9”上。这种误差属于训练集本身标注和图像质量导致的,即使在人类看来也有争议。我抽出十几张错样本发现,大多数图片中的数字都有明显倾斜或断笔,CNN能把这些都识别错,说明它已经擅长捉住局部细节,但面对笔画残缺时依然会遇到挑战。

这个分析给我们的启发是:在真实业务里,单看准确率不够,必须关注错误类型。如果系统识别“1”和“7”经常混淆,那在后续流程里可以增加一个上下文规则,比如和前后字符一起做校验,这样整体效果会好很多。

6. 常见问题排查与避坑手册

6.1 损失不下降或准确率卡住

这类问题在入门阶段出现频率最高。我的排查顺序是:

  1. 确认输入数据已经归一化,并且维度正确;
  2. 确认损失函数和标签格式匹配,整数标签用sparse,one-hot用categorical;
  3. 确认最后一层激活函数是softmax,并且在二分类时也可以考虑sigmoid;
  4. 降低学习率到0.0001或0.0003试试,排除学习率过大导致震荡;
  5. 检查权重初始化,尽量用Keras默认初始化,不要自己设成全0。

如果训练多个epoch后loss几乎不动,我有一个快速判断方法:先用一个batch的数据过一遍模型,看loss是否下降。如果连一个batch都学不进去,那问题基本在模型定义或数据管道,不在超参数。

sample_x = x_train[:32] sample_y = y_train[:32] model.fit(sample_x, sample_y, epochs=1, batch_size=8)

这样做的好处是几秒内就能看到结果。如果这一步loss能往下掉,说明整个训练链路是通的,只是超参数需要调整;如果连一个batch都训练不进去,就要重新检查数据和模型结构。

6.2 显存不足或CPU内存溢出

显存不足多半是因为batch_size太大,或输入图像分辨率太大。MNIST本身很小,但如果你在更复杂的任务上遇到OOM,解决办法是逐级减少batch_size,比如从128减到64、32。同时可以把图片尺寸适当缩小,或者改用混合精度训练。

另一种“内存溢出”发生在画图时。我习惯训练完一次性保存所有预测错误图片,结果在内存里存了几百张数组,办公本直接卡死。正确做法是只保存前20张,或者画完立刻调用plt.close()释放资源。

如果你是GPU用户,还有一个小技巧:在训练前设置gpu_growth,不要让TensorFlow一次性占满显存。

gpus = tf.config.experimental.list_physical_devices("GPU") if gpus: try: for gpu in gpus: tf.config.experimental.set_memory_growth(gpu, True) except RuntimeError as e: print(e)

这样多个程序可以共享显存,不会一跑就爆。

6.3 预测结果总是一个类别

模型训练得很差,对所有输入都输出同一个类别,原因可能是:

  • 最后一层用了sigmoid而不是softmax,导致输出不满足概率和为1;
  • 输入数据尺度不对,像素值没归一化,梯度爆炸把权重推入死区;
  • 标签类别有严重不平衡,但MNIST没有这问题;如果你用的是自己的数据,需要做类别平衡处理;
  • 预测时忘了增加batch维度,导致输入形状变成(28, 28)而不是(1, 28, 28)或(1, 28, 28, 1)。

遇到这个现象,先print一下训练集的label分布,再看一下模型预测输出的概率分布,很快就能定位。

sample = x_test[0].reshape(1, 28, 28, 1) prob = model_cnn.predict(sample, verbose=0) print(np.argmax(prob), prob.max())

如果prob.max()永远在0.1附近,说明模型根本没有学到有效特征,而不是某些bug。这种时候我通常会回到第6.1节,从数据归一化和学习率开始重新检查。

最后分享一个我自己的习惯:训练结束不要只看测试集准确率,一定要再找一批“风格不同”的数据做外部验证。MNIST训练集里的手写比较规整,而现实中人们写字千奇百怪,所以我会随手画几个数字,用保存下来的模型推断一下,看看它到底能不能泛化。我试过好几次,模型在MNIST测试集上有99%,但对我手写的歪歪扭扭的“2”和“5”却会认错,这时候才会真正理解,所谓“准确率”只能在数据分布相似的前提下才有意义。

这篇把神经网络到CNN的路径走完之后,我下一步打算把注意力机制和模型解释模块加进来,继续折腾这个MNIST老朋友。先把这三条路跑通,后面做真实项目会顺手很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 2:48:49

离线环境Docker与Docker Compose安装包选择与部署指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 2:45:58

Android+Spring Boot文件选择上传下载全链路实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 2:44:12

PS5远程串流全指南:把主机变成你的私人游戏服务器

PS5买了两年,从一开始只接客厅电视,到后来吃灰吃到大半年没开机,直到我捣鼓出“AnyPS5”这套方案,才算把这台主机用明白了。所谓AnyPS5,按我的理解就是三个Any:任何地方、任何设备、任何时间,都…

作者头像 李华
网站建设 2026/10/13 2:43:16

侨银股份资金困局:环卫龙头的现金流与大股东减持剖析

先说一个很多人没注意到的矛盾点:侨银股份这家头顶“环卫龙头”光环的公司,最近却频频因为“现金流告急”“大股东减持”登上话题中心。一边是行业地位稳固、合同金额屡创新高的环卫服务巨头,一边是实控人密集套现、账上资金链条绷紧的财务现…

作者头像 李华
网站建设 2026/10/13 2:43:14

Remeda v1 到 v2 迁移:evolve 函数对 Symbol 键的类型约束修复

开发工具 【免费下载链接】remeda A utility library for JavaScript and TypeScript. 项目地址: https://gitcode.com/gh_mirrors/re/remeda 点击查看 免费下载 本文是 Remeda 官方逐函数迁移指南(v1-migration)中 evolve 条目的完整展开&a…

作者头像 李华