手写数字识别这个任务说它是深度学习的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上都很成熟。安装直接用pippip 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而神经网络里常用的激活函数对输入尺度很敏感。如果不做归一化梯度很容易不稳定。我的做法是统一除以255x_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. 模型搭建从零写一个CNN3.1 网络结构设计的选择逻辑CNN在处理图像时有个天然优势先通过卷积层提取局部特征再通过池化层降低分辨率、扩大感受野最后用全连接层做分类决策。MNIST图像虽然只有28x28但完全值得走一遍标准CNN流程。我采用的网络结构是第一层Conv2D32个3x3卷积核ReLU激活paddingsame第二层MaxPooling2D池化窗口2x2第三层Conv2D64个3x3卷积核ReLU激活paddingsame第四层MaxPooling2D池化窗口2x2第五层Flatten把特征图展平第六层Dense128个神经元ReLU激活第七层Dropout丢弃率0.5第八层Dense10个神经元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), activationrelu, paddingsame), layers.MaxPooling2D(pool_size(2, 2)), layers.Conv2D(64, kernel_size(3, 3), activationrelu, paddingsame), layers.MaxPooling2D(pool_size(2, 2)), layers.Flatten(), layers.Dense(128, activationrelu), layers.Dropout(0.5), layers.Dense(10), ])注意最后一层我没有写activationsoftmax。这是有讲究的在实际训练时把最后这一层当成logits输出然后在损失函数里设置from_logitsTrue数值上更稳定也避免了两步计算带来的精度问题。如果你习惯把softmax直接写在最后一层也可以但那样损失函数要用SparseCategoricalCrossentropy的默认from_logitsFalse。两种写法都能跑关键是一致。写完模型后一定要打印一下结构model.summary()这个习惯非常重要。你可以立刻看到每一层输出shape和参数量很多shape问题在训练前就能发现。3.3 模型参数量要心里有数MNIST上这个模型参数量非常小总共约42万。我手动拆一下给你看以后你搭其他网络时也能估算大概规模。第一个卷积层输入通道为1卷积核3x3输出通道32算上偏置3x3x1x32 32 320第二个卷积层输入通道32输出通道643x3x32x64 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( optimizeroptimizers.Adam(learning_rate0.001), losslosses.SparseCategoricalCrossentropy(from_logitsTrue), metrics[accuracy] )损失函数我用SparseCategoricalCrossentropy并且因为前面的模型最后一层没有套softmax所以from_logitsTrue。如果你在最后一层写了activationsoftmax那这里就保持默认的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, monitorval_loss, save_best_onlyTrue, verbose1 ) early_stop callbacks.EarlyStopping( monitorval_loss, patience5, restore_best_weightsTrue ) reduce_lr callbacks.ReduceLROnPlateau( monitorval_loss, factor0.5, patience2, min_lr1e-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_size64, epochs20, validation_data(x_val, y_val), callbacks[checkpoint, early_stop, reduce_lr], verbose1 )如果你只想快速跑通也可以不接回调但我不建议省略。后面你会感谢这几个自动机制。4.3 训练曲线怎么读才不算白读fit方法返回的history对象里存着每个epoch的训练loss、训练准确率、验证loss、验证准确率。我通常会把它画出来观察趋势而不是只看最后一个数字。import matplotlib.pyplot as plt history_dict history.history plt.plot(history_dict[loss], labeltrain_loss) plt.plot(history_dict[val_loss], labelval_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_weightsTrue最终拿到的模型权重就是验证集上最好的那一步而不是最后一轮。最后可以考虑数据增强。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, verbose0) print(fTest accuracy: {test_acc:.4f})在MNIST上用上面这个结构大概率能在测试集上拿到99%以上的准确率。这已经相当不错因为MNIST本身包含一些手写得很潦草、人眼看起来都不好确认的样本。只看整体准确率其实会掩盖很多细节。我习惯再打一个混淆矩阵看看模型到底在哪些类别之间犯糊涂import numpy as np from sklearn.metrics import confusion_matrix, classification_report preds model.predict(x_test, verbose0) pred_labels np.argmax(preds, axis1) 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, channels1) 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, verbose0) 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和训练曲线打印出来很多坑都不用踩。
阅读完成 · 觉得有帮助?