手写数字识别这个任务,说它是深度学习的Hello World一点都不夸张。我最近在复盘之前做的一个小项目:用TensorFlow 2搭一个CNN,把MNIST数据集的准确率稳定训练到99%以上。别看这套流程看起来简单,真自己动手的时候,版本兼容、数据形状、训练不收敛这些问题一个都不会少。这篇是系列的中篇,重点放在模型搭建和训练过程上,默认你已经搞定了环境,也成功读到了数据。如果你正准备亲手把CNN跑通,而不是停留在看教程阶段,那这篇里的思路、配置和踩坑记录应该能帮你省下不少时间。
1. 项目背景与整体思路
1.1 为什么这个项目值得再跑一遍
很多刚接触深度学习的人会有个疑问:MNIST都被人跑烂了,为什么还要再训练一遍?我的看法是,MNIST虽然简单,但它恰好覆盖了深度学习项目里最核心的完整链路:数据加载、预处理、模型搭建、训练调参、评估推理。这个链路如果能在MNIST上彻底跑通,后面换到真实项目,你至少不会在基础环节上卡壳。
另外,MNIST的数据非常干净,10个类别分布均匀,图像尺寸固定,任务目标明确。在这种情况下,模型表现好坏基本上完全取决于你自己的网络设计和训练策略。如果连MNIST都训不到理想水平,那问题大概率出在基本功上,而不是数据太难。
我见过不少新手一上来就用很复杂的网络和很花哨的技巧,最后连loss下降都看不明白,反而越调越乱。所以在中篇里,我刻意把网络结构控制得比较克制,目标是把CNN在TensorFlow 2里的写法讲清楚,把训练过程中的常见坑提前排掉。
1.2 中篇在整个流程里的定位
如果把这个系列拆开看,上篇应该解决的是环境搭建、TensorFlow 2安装、MNIST数据下载与基础查看。中篇则是从数据处理开始,到CNN定义、编译、训练、调参,再到评估和保存模型。下篇可以继续往深走,比如部署、模型压缩,或者换成更复杂的数据集。
这篇的定位很明确:不追求极限精度,不堆复杂结构,重点让训练过程透明可控。你可以看到每一步为什么这么做,也能直接参考完整的可运行代码。
我在实际动手时发现,大多数入门者真正卡住的不是模型结构,而是训练过程的“黑盒感”。比如loss为NaN、验证集准确率波动、过拟合、保存后不会加载。所以中篇我会把很多注意力放在这些细节上。
2. 环境准备与数据预处理
2.1 版本组合怎么选更省心
先说结论:如果你只是想跑通这个项目,不需要纠结“最新版”三个字,选一个稳定组合就够了。我自己用的是Python 3.9加TensorFlow 2.10,这个组合在Windows和Linux上都很成熟。
安装直接用pip:
pip install tensorflow如果你机器上有可用的显卡,并且想用GPU加速,可以装带GPU支持的版本,但注意一定要提前确认对应的CUDA和cuDNN版本。这块非常容易出问题,而且报错信息往往不够直观。我的建议是:第一次跑全部用CPU版,MNIST数据集非常小,CPU训练一个epoch也就几十秒,完全能接受。等你把流程跑通、理解清楚了,再折腾GPU不迟。
还有一个经常被忽略的点:TensorFlow 2已经把Keras作为官方高级API集成了进来,也就是tf.keras。所以你不需要额外安装独立的Keras,更不要在代码里混用两套写法,否则很容易出现版本不匹配的报错。
2.2 MNIST 数据加载的四个关键细节
TensorFlow 2加载MNIST非常方便,不需要自己下载数据集,框架内置了接口:
import tensorflow as tf (x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data() print(x_train.shape, y_train.shape) print(x_test.shape, y_test.shape)运行后你会看到训练集是60000张28x28的灰度图,测试集是10000张。注意这个接口返回的数据类型是NumPy数组,不是Tensor,所以后面可以直接用切片、变形等操作。
有一个细节必须强调:每张图的像素值范围是0到255,而神经网络里常用的激活函数对输入尺度很敏感。如果不做归一化,梯度很容易不稳定。我的做法是统一除以255:
x_train = x_train.astype('float32') / 255.0 x_test = x_test.astype('float32') / 255.0还有一个新手特别容易忽略的点:Conv2D层要求输入是四维张量,格式通常是(batch, height, width, channels)。而MNIST原始数据是三维的,每个样本是28x28,没有通道维度。所以必须把灰度通道补上:
x_train = x_train[..., tf.newaxis] x_test = x_test[..., tf.newaxis]这样x_train就变成了(60000, 28, 28, 1)。这一步漏掉的话,模型第一层就会直接报shape相关的错误。
最后是标签处理。这里我选择保留整数标签,配合TensorFlow 2里的SparseCategoricalCrossentropy损失函数。这样做的好处是直观,不需要手动做one-hot编码。如果你更习惯one-hot,也可以,但损失函数要换成CategoricalCrossentropy,逻辑上要注意对应。
2.3 验证集切片别偷懒
训练集60000张,测试集10000张。很多人上手就喜欢把全部训练集喂进去,再用测试集反复调参。这是不对的。测试集应该留到最后,当成“没见过的考试题”,而不是你在调参时反复对照的参考书。
我习惯从训练集里切出一部分作为验证集,专门用来观察模型在训练过程中的泛化表现:
x_val = x_train[:5000] y_val = y_train[:5000] x_train_part = x_train[5000:] y_train_part = y_train[5000:]后面训练时就只用后面55000张训练,用前面5000张做验证。这样模型在epoch过程中就能看到验证准确率,你也能及时发现过拟合的大致时间点。
有些人会倾向于保持类别分布均衡,MNIST本身每个类别在完整训练集里非常均衡,所以直接切片问题不大。如果你是处理自己业务数据,尤其是类别不均衡的数据,那就需要用分层采样,不能随便从头切。
3. 模型搭建:从零写一个CNN
3.1 网络结构设计的选择逻辑
CNN在处理图像时有个天然优势:先通过卷积层提取局部特征,再通过池化层降低分辨率、扩大感受野,最后用全连接层做分类决策。MNIST图像虽然只有28x28,但完全值得走一遍标准CNN流程。
我采用的网络结构是:
- 第一层:Conv2D,32个3x3卷积核,ReLU激活,padding=same
- 第二层:MaxPooling2D,池化窗口2x2
- 第三层:Conv2D,64个3x3卷积核,ReLU激活,padding=same
- 第四层:MaxPooling2D,池化窗口2x2
- 第五层:Flatten,把特征图展平
- 第六层:Dense,128个神经元,ReLU激活
- 第七层:Dropout,丢弃率0.5
- 第八层:Dense,10个神经元,Softmax激活或输出logits
为什么卷积核从32增加到64?因为经过池化后,每个特征图的分辨率减半,但信息量仍然不少,增加通道数可以保留更丰富的特征。这种“空间变小、通道变多”的设计在经典网络里很常见。
为什么全连接层用128而不是更大?因为MNIST类别只有10个,特征经过卷积和池化之后已经比较抽象,全连接层容量够用就行。把维度设得过大,不仅参数爆炸,还特别容易过拟合。
3.2 用tf.keras写CNN的具体实现
TensorFlow 2里最省事的写法是用Sequential模型,把各层一层层叠起来:
from tensorflow.keras import layers, models model = models.Sequential([ layers.Input(shape=(28, 28, 1)), layers.Conv2D(32, kernel_size=(3, 3), activation='relu', padding='same'), layers.MaxPooling2D(pool_size=(2, 2)), layers.Conv2D(64, kernel_size=(3, 3), activation='relu', padding='same'), layers.MaxPooling2D(pool_size=(2, 2)), layers.Flatten(), layers.Dense(128, activation='relu'), layers.Dropout(0.5), layers.Dense(10), ])注意最后一层我没有写activation='softmax'。这是有讲究的:在实际训练时,把最后这一层当成logits输出,然后在损失函数里设置from_logits=True,数值上更稳定,也避免了两步计算带来的精度问题。
如果你习惯把softmax直接写在最后一层,也可以,但那样损失函数要用SparseCategoricalCrossentropy的默认from_logits=False。两种写法都能跑,关键是一致。
写完模型后,一定要打印一下结构:
model.summary()这个习惯非常重要。你可以立刻看到每一层输出shape和参数量,很多shape问题在训练前就能发现。
3.3 模型参数量要心里有数
MNIST上这个模型参数量非常小,总共约42万。我手动拆一下给你看,以后你搭其他网络时也能估算大概规模。
第一个卷积层输入通道为1,卷积核3x3,输出通道32,算上偏置:
3x3x1x32 + 32 = 320
第二个卷积层输入通道32,输出通道64:
3x3x32x64 + 64 = 18496
经过两次2x2池化后,特征图从28x28变成7x7,第二层卷积输出64个通道,所以Flatten之后向量的维度是:
7x7x64 = 3136
全连接层参数:
3136x128 + 128 = 401536
输出层参数:
128x10 + 10 = 1290
加起来大约421642个参数。这个量级在CPU上训练非常轻松,一轮epoch也就几十秒。如果你发现自己的模型参数量动辄几千万,但任务本身又很简单,那就要怀疑是不是网络容量设计过大了。
4. 编译配置与训练过程
4.1 编译三步:损失函数、优化器、评价指标
编译模型这一步看起来只需要一行代码,但其中三个选择最好想清楚。
from tensorflow.keras import optimizers, losses model.compile( optimizer=optimizers.Adam(learning_rate=0.001), loss=losses.SparseCategoricalCrossentropy(from_logits=True), metrics=['accuracy'] )损失函数我用SparseCategoricalCrossentropy,并且因为前面的模型最后一层没有套softmax,所以from_logits=True。如果你在最后一层写了activation='softmax',那这里就保持默认的False,否则同一个模型会自动套两层softmax,训练指标会极其诡异。
优化器我推荐Adam。对新手来说,Adam默认的学习率0.001就是一个很合理的起跑点,几乎不用额外调节。传统SGD虽然更经典,但对学习率、动量这些参数更敏感,调起来费时间。等你理解了训练动态,再回头去折腾SGD也不迟。
评价指标直接用accuracy就够了。MNIST类别均衡,准确率能很好反映模型效果。如果是严重不平衡的业务数据,单看accuracy会骗人,那就要考虑查准率、查全率这些更细的指标。
4.2 训练时我必挂的三个回调
model.fit一行能跑起来,但为了省事和防止意外,我强烈建议加上回调。下面三个是我在这个项目里几乎必用的组合。
from tensorflow.keras import callbacks checkpoint = callbacks.ModelCheckpoint( 'cnn_model.keras', monitor='val_loss', save_best_only=True, verbose=1 ) early_stop = callbacks.EarlyStopping( monitor='val_loss', patience=5, restore_best_weights=True ) reduce_lr = callbacks.ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=2, min_lr=1e-6 )ModelCheckpoint会在每个epoch结束后检查验证集loss,只要比历史最好值更好,就自动把模型权重保存下来。这样即使训练中途意外中断,你手里也有一份最佳模型。
EarlyStopping是防过拟合和防时间浪费的利器。如果验证集loss连续多个epoch没改善,训练就可以提前终止。我这里给了5个epoch的耐心,不会因为一两次波动就草率停止。
ReduceLROnPlateau是另一个自动调参策略。当验证loss进入平台期时,学习率自动减半,帮助loss继续下降。注意它的patience要小于EarlyStopping的patience,否则早停可能抢在降学习率之前触发。
实际训练命令:
history = model.fit( x_train_part, y_train_part, batch_size=64, epochs=20, validation_data=(x_val, y_val), callbacks=[checkpoint, early_stop, reduce_lr], verbose=1 )如果你只想快速跑通,也可以不接回调,但我不建议省略。后面你会感谢这几个自动机制。
4.3 训练曲线怎么读才不算白读
fit方法返回的history对象里存着每个epoch的训练loss、训练准确率、验证loss、验证准确率。我通常会把它画出来观察趋势,而不是只看最后一个数字。
import matplotlib.pyplot as plt history_dict = history.history plt.plot(history_dict['loss'], label='train_loss') plt.plot(history_dict['val_loss'], label='val_loss') plt.legend() plt.show()看曲线时最重要的信号是训练loss和验证loss之间的距离。如果训练loss持续下降,但验证loss在某个epoch后掉头向上,说明模型开始过拟合。如果验证loss从一开始就横着不动,说明学习率或者网络结构有问题。
我一般在epoch过程中不会频繁盯控制台输出,但每次跑完一定看曲线。很多调参决策,比如Dropout该加多大、要不要提前停,都是根据曲线形态判断的。
5. 调参与避坑实录
5.1 训练不收敛的三个排查方向
训练不收敛是新手最容易崩溃的问题,但其实方向就那么几个。
第一个方向是loss直接变成NaN。这种时候第一嫌疑是学习率太高。Adam虽然自适应,但不代表任何学习率都能扛。解决方法是把learning_rate降到1e-4甚至更低,再看看loss能不能恢复正常。另外,如果数据没有归一化,像素值还在0到255范围内,也容易出现数值不稳定。
第二个方向是loss下降很慢,准确率一直在低位徘徊。先检查激活函数和输出层是否配对,比如二分类用sigmoid却配了softmax,或者标签类别数和输出神经元数量不一致。MNIST有10类,最后一层节点必须是10,如果你写成1或者5,模型根本没能力正确分类。
第三个方向是准确率一跳一跳,极不稳定。这种情况通常是批大小太小,每个batch的梯度方向差异太大。我把batch_size从32调到64甚至128后,曲线明显平滑很多。加大batch会让梯度估计更稳定,但也不能无限大,否则模型更新太慢、收敛变慢。
5.2 过拟合的识别和应对
MNIST数据量不算小,模型也不算复杂,但Dropout设置不当仍然可能出现过拟合。最典型的症状就是训练准确率一路冲到99%以上,但验证准确率卡在97%上下甚至下跌。
为什么会过拟合?因为全连接层的参数量占了整个模型的大头,如果模型容量大于任务需求,网络就会开始“背题”,而不是学抽象特征。应对手段可以按优先级排序:
首选Dropout。我在Dense层后加了0.5的丢弃率,效果立竿见影。要注意的是,Dropout只在训练时生效,预测时会自动缩放权重,所以不用担心测试时要手动关闭。
其次是EarlyStopping。它本质上是从时间维度阻止过拟合:一旦验证loss不再改善,就停止训练。配合restore_best_weights=True,最终拿到的模型权重就是验证集上最好的那一步,而不是最后一轮。
最后可以考虑数据增强。MNIST上我通常只做小幅随机旋转和平移,太强的增强反而会让模型更难收敛。真实项目里数据增强往往是重点,但如果任务简单,先别急着加花活。
5.3 随机种子与实验复现
这个项目看起来简单,但不同人跑出来的准确率可能不太一样,主要原因是随机性。TensorFlow里的参数初始化、数据打乱、Dropout都涉及随机过程。为了让实验尽可能稳定复现,我会在代码最前面设置种子:
import numpy as np tf.random.set_seed(42) np.random.seed(42)注意,设置了种子之后,同一个环境、同一个版本下,结果大概率可以复现,但不保证百分百,因为某些硬件操作和分布式环境仍然会引入微小差异。不过至少能让你调参时看到的趋势是可信的,而不是被随机波动误导。
另外,我建议每改动一个超参数就记录一次结果,哪怕只是在备注里写一行。你会发现,很多时候准确率提升来自某个不起眼的小改动,比如数据归一化方式、验证集划分方式,而不是你以为的“网络结构更酷了”。
6. 结果评估与后续扩展
6.1 测试集评估与混淆矩阵
模型训练完之后,先在测试集上做一个最终评估:
test_loss, test_acc = model.evaluate(x_test, y_test, verbose=0) print(f"Test accuracy: {test_acc:.4f}")在MNIST上,用上面这个结构,大概率能在测试集上拿到99%以上的准确率。这已经相当不错,因为MNIST本身包含一些手写得很潦草、人眼看起来都不好确认的样本。
只看整体准确率其实会掩盖很多细节。我习惯再打一个混淆矩阵,看看模型到底在哪些类别之间犯糊涂:
import numpy as np from sklearn.metrics import confusion_matrix, classification_report preds = model.predict(x_test, verbose=0) pred_labels = np.argmax(preds, axis=1) cm = confusion_matrix(y_test, pred_labels) print(cm) print(classification_report(y_test, pred_labels))真正跑过之后你会发现,最容易混淆的往往是4和9、7和2这类笔画结构相近的数字。这很正常,因为它们在局部特征上确实相似。这时候如果你想继续提升准确率,就可以专门针对这些困难样本做分析,而不是盲目调大模型。
6.2 保存模型和单张图片推理
训练结束后,模型要落盘保存。我用的是:
model.save('cnn_model.keras')下次要用的时候直接加载:
from tensorflow.keras import models loaded_model = models.load_model('cnn_model.keras')如果是把模型部署到服务里,最常见的需求是给定一张手写数字图片,返回预测类别。加载模型后,需要把图片预处理成和训练数据一样的形式:灰度图、缩放到28x28、归一化到0到1之间、补上batch维度和通道维度。
def preprocess_image(image_path): img = tf.io.read_file(image_path) img = tf.image.decode_image(img, channels=1) img = tf.image.resize(img, [28, 28]) img = img / 255.0 img = tf.reshape(img, [1, 28, 28, 1]) return img推理时:
img_tensor = preprocess_image('test_digit.png') logits = loaded_model.predict(img_tensor, verbose=0) pred_class = int(tf.argmax(logits[0])) print(pred_class)这里有个小坑:如果原图不是黑白反色风格,比如白底黑字训练出来的模型,遇到黑底白字图片,预测很可能出错。你需要在预处理时确认图像的颜色一致,或者做反色处理。
6.3 从这个项目还能延伸到哪里
跑通MNIST之后,不要急着说“我会深度学习”。这个项目的价值在于流程,但流程之外还有大量东西可以深挖。
最简单的延伸是把数据集换掉,比如换成Fashion-MNIST或者CIFAR-10。它们和MNIST一样入口友好,但图像颜色、纹理、类别复杂度完全不同,你会发现原本很好用的超参数可能立刻失效,需要重新调优。
也可以把网络结构换掉,比如加深卷积层、引入残差连接、试试BatchNormalization。你会发现,在MNIST上很多结构改进带来的收益并不明显,因为基准任务太简单,但这反而是理解不同技术效果的绝佳实验田。
更工程一点的延伸是把模型做成一个小的Web服务,接收上传的图片,返回识别结果;或者用TensorFlow Lite把模型迁移到移动端。无论走哪个方向,本篇里这套“数据处理、模型搭建、训练评估、保存加载”的链路都会是你继续探索的地基。
我个人在实际操作中的体会是:这类基础项目最大的价值不是把准确率刷到99.9%,而是让你对每个环节都有掌控感。当你看到loss曲线和验证曲线不再一脸茫然,当你能通过混淆矩阵判断模型问题出在哪,再去看那些更复杂的理论、更庞大的模型,就不会那么心虚了。下次训练之前,记得先把模型summary和训练曲线打印出来,很多坑都不用踩。