TensorFlow 2.0 MNIST实战:从零搭建神经网络入门教程 TensorFlow 2.0 里跑 MNIST可以说是我接触深度学习以来觉得最值得先做一遍的实战项目。数据集不大、模型不复杂、训练也快但它把神经网络从“纸上公式”变成“能跑起来的程序”的那条路完整地走了一遍。无论你是刚看完理论、准备动手入门还是已经写过一些其他框架的代码、想快速熟悉 TF 2.0 的语法习惯这篇笔记都能帮你在几小时内把它跑通并且搞明白每一步到底在干什么。1. 为什么从 MNIST 入手以及 TensorFlow 2.0 带来的变化1.1 MNIST 的经典价值MNISTMixed National Institute of Standards and Technology database是手写数字识别的入门级数据集里面包含 0-9 共 10 个类别每张图片是 28x28 像素的灰度图。训练集有 6 万张测试集有 1 万张。它为什么经久不衰因为识别手写数字这个问题足够简单简单到用一个全连接神经网络就能达到 90% 以上的准确率用上卷积之后能逼近 99% 以上。但“简单”不意味着“没价值”——数据处理、模型搭建、训练评估、问题排查这些深度学习流程里的核心环节它一个都不缺。我第一次跑 MNIST 时犯过一个典型错误照着网上的代码抄了一遍准确率很高但让我改几行代码就不知道从哪下手了。后来我才明白跑通 MNIST 的目的不是“拿到一个漂亮的结果”而是借这个最小的完整项目建立对模型流程的直觉数据长什么样、模型里的每个参数影响什么、loss 和准确率代表什么。这套直觉会直接迁移到后面做图像分类、文本分类、推荐模型等更复杂的问题上。1.2 TF 2.0 相比 1.x 的核心变化如果你之前接触过 TensorFlow 1.x应该记得那种“先建图、再开 Session、还要 placeholder 喂数据”的繁琐流程。2.0 最大的变化就是默认开启Eager Execution动态图模式代码写出来是什么逻辑跑起来就是什么逻辑不需要再单独维护一个计算图。这对调试和学习的友好度提升非常大——你可以在模型中间打印 tensor 的形状和值而不是对着抽象的 Graph 猜。另一个关键变化是Keras 被正式整合为高级 API。Keras 的 Sequential 模型和 function API 可以让开发者用几行代码就搭出网络同时也保留了子类化subclassing这种完全自定义的写法。对于入门来说Sequential 足够了它隐藏了底层细节、减少了出错概率等你需要做一些非标准结构时再升级到 function API 也不迟。提示如果你在网上搜到大量 TF 1.x 的代码比如tf.placeholder、session.run注意它们和 2.0 的写法完全不兼容。最直接的判断方式看有没有from tensorflow.keras或者tf.keras开头优先选这些。2. 环境准备与数据加载2.1 环境准备在动手之前先把环境装好。我的建议是使用虚拟环境或者 Docker避免和系统里的其他 Python 包互相干扰。我用的是conda create -n tf2 python3.8创建了一个独立环境然后执行pip install tensorflow-cpu。如果你的机器有 NVIDIA 显卡并且已经配好了 CUDA 和 cuDNN可以考虑pip install tensorflowGPU 版本。但说实话MNIST 这个量级的数据CPU 训练也就几分钟的事情第一遍跑不需要非用 GPU 不可少折腾环境反而是更重要的事。装好之后验证一下python -c import tensorflow as tf; print(tf.__version__)如果正常输出 2.x 的版本号说明基本环境没问题。接下来就可以打开 Jupyter Notebook 或者直接写一个.py脚本来做实验了。2.2 加载数据一行代码的事但背后有讲究TensorFlow 2.0 里加载 MNIST 非常方便from tensorflow.keras.datasets import mnist (train_images, train_labels), (test_images, test_labels) mnist.load_data()mnist.load_data()会自动从网络下载数据集到~/.keras/datasets/目录。下载完之后后续执行会直接读取缓存不会再重复下载。来看看数据的基本信息print(train_images.shape) # (60000, 28, 28) print(train_labels.shape) # (60000,) print(test_images.shape) # (10000, 28, 28) print(test_labels.shape) # (10000,)注意数据集中每个像素的取值范围是 0-255类型为 uint8。直接拿这种数据去训练不是不行但效果往往不理想。数值范围太长梯度会不稳定训练收敛也慢。所以接下来必须做归一化。2.3 归一化和数据形状的细节归一化最简单的做法是所有像素除以 255把取值压缩到 0-1 之间train_images train_images.astype(float32) / 255.0 test_images test_images.astype(float32) / 255.0为什么要转float32因为 uint8 除以 255 会直接被截断成整数很多值会变成 0信息直接损失了。这是新手特别容易忽略的点。关于数据的形状MNIST 默认是 28x28 的二维数组没有通道维度。如果后续使用全连接网络需要把它“展平”flatten成一维如果后续使用卷积网络就需要把它扩展成(28, 28, 1)的四维形状。你可以手动 reshape也可以用 Keras 里的Flatten或Reshape层来做这件事。我在全连接网络里习惯用Flatten层因为它的可读性强而且不用手动去算展平后的维度是多少。labels 的处理则取决于损失函数。如果用sparse_categorical_crossentropylabels 直接用整数数组0, 1, 2, ... 9即可如果用categorical_crossentropy则需要先做 one-hot 编码。MNIST 入门时我强烈建议直接用整数 labels 配合sparse_categorical_crossentropy省心太多。3. 模型搭建从 784 到 10 的层层递进3.1 用 Sequential 搭一个全连接网络我常用的一个基础结构是这样的from tensorflow.keras import Sequential from tensorflow.keras.layers import Dense, Flatten, Dropout model Sequential([ Flatten(input_shape(28, 28)), Dense(128, activationrelu), Dropout(0.2), Dense(10, activationsoftmax) ])Flatten把每张 28x28 的图片变成 784 个元素的一维向量第一个Dense(128, activationrelu)是全连接隐藏层有 128 个神经元中间的Dropout(0.2)在训练时随机丢弃 20% 的神经元用来抑制过拟合最后一个Dense(10, activationsoftmax)输出 10 个类别的概率分布。这里有几个值得展开细说的点。3.2 激活函数为什么选 relu隐藏层为什么是 128relu几乎是现代神经网络隐藏层的默认选择。它比 sigmoid 和 tanh 好在哪最直观的一点是sigmoid 在输入较大或较小时导数值趋近于 0反向传播时梯度会逐层衰减甚至消失深层网络根本训不动。relu 在正区间导数恒为 1梯度流通顺畅训练速度也会更快。它当然有缺点——负半轴神经元直接“死亡”但对于 MNIST 这种浅层网络影响不大。隐藏层神经元数量 128 并不是一个必须遵守的数值64、256、512 都可以跑。我选 128 是平衡了表达能力和训练速度。你可以做一个对比实验把 128 改成 32准确率可能降到 95% 以下改成 512准确率会小幅提升但训练时间明显增加。MNIST 的分类任务复杂度有限128 到 256 之间已经足够更大的隐藏层只会带来过拟合风险。这个“试不同配置、看结果变化”的过程恰恰是初学者建立直觉的最好方式。3.3 输出层 softmax 的意义最后一层的 10 个神经元对应 10 个数字类别softmax会把网络输出的 10 个分数转换为 10 个概率值彼此之和为 1。模型预测时我们通常会取概率最大的那个位置作为最终分类结果import numpy as np predictions model.predict(test_images[:5]) predicted_classes np.argmax(predictions, axis1)argmax返回 10 个概率中最大值的索引也就是模型认为这张图片是哪个数字。理解这个从“分数”到“概率”到“类别”的链条比背模型 API 更关键。注意如果你把最后一层换成了sigmoid不会报错但它的设计意图和多分类问题不匹配——sigmoid 是独立的二分类概率softmax 才解决“多个类别互斥”的问题。初学者最容易在这里犯迷糊。4. 编译、训练与评估真正看到模型在学习4.1 编译的时候你在配置什么训练之前需要调用compile它的作用是告诉模型三件事用什么损失函数、用什么优化器、用什么评价指标。model.compile( optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy] )optimizeradam自适应学习率的优化器。相比传统的 SGDAdam 在大多数问题上不需要精细调学习率收敛也更快。初学者先无脑用 Adam 没有太大问题等搞懂了学习率、动量这些概念之后再回过头去试 SGD 也不迟。losssparse_categorical_crossentropy因为我们用的 labels 是整数索引形式所以用 sparse 版本如果做了 one-hot就用categorical_crossentropy。metrics[accuracy]训练和评估时额外计算准确率方便直观地观察模型表现。很多初学者会问损失函数和准确率有什么关系简单说损失函数是模型训练时用来计算梯度的目标函数准确率只是给我们人类看的指标。准确率可能下降但损失下降也可能损失平稳但准确率在缓步上升这两者不总是一一对应的。4.2fit训练过程里的参数细节调用训练history model.fit( train_images, train_labels, epochs5, batch_size32, validation_data(test_images, test_labels) )这里 5 个 epoch 已经足以让全连接网络在 MNIST 上达到 97% 以上的准确率。batch_size32表示每次迭代从训练集中取 32 张图片计算一次梯度。batch size 选大训练稳定但吃内存而且有时候收敛不到好的结果选太小梯度噪声大训练震荡。32 是一个经验性的默认值MNIST 上用得很顺手。history里记录了每一轮训练和验证的 loss 和 accuracy后面做可视化会用到。训练过程在控制台里也会显示进度条和指标数值可以直观地看到 loss 是不是在逐轮下降。4.3 评估和预测模型到底成色如何test_loss, test_acc model.evaluate(test_images, test_labels) print(fTest accuracy: {test_acc:.4f}) sample test_images[0].reshape(1, 28, 28) prediction model.predict(sample) predicted np.argmax(prediction) print(fPredicted: {predicted}, True: {test_labels[0]})注意model.predict接受的是一个批次所以单张图片需要 reshape 成(1, 28, 28)的批量形状。不写这个 reshape 会直接报维度错误。把训练后的模型拿去预测测试集的图片并且拿其中几张打印出来肉眼观察一下能建立真正的信任感——模型不是只在指标上好看它确实“看”懂了手写数字。5. 踩坑记录与问题排查你能遇到的大部分问题都在这里5.1 Loss 不降反升或者直接变成 NaN这个现象通常有几个原因。一是学习率设置得太高Adam 默认的学习率是 0.001初学者基本不会改到但如果你手动画蛇添足把 learning rate 设成 0.5loss 就会剧烈震荡甚至变成 NaN。二是数据处理出了问题比如没有做归一化或者归一化除了问题导致一部分输入异常。三是模型最后一层的输出形式和 loss 不匹配比如用sigmoid却配合categorical_crossentropy会让梯度方向混乱。我排查这类问题的顺序是先检查数据范围和 dtype再确认模型的最后一层激活函数和 loss 函数是否配套最后才去怀疑学习率。前两步用几行打印语句就能完成反而节省时间。5.2 训练集准确率高测试集准确率低过拟合如果你把隐藏层加到 512 个神经元或者训练了 30 个 epoch很可能会出现训练准确率 99% 以上、测试准确率只有 96% 的情况这就是过拟合——模型把训练数据的细节特征背了下来拿到新数据上反而泛化不好。缓解方法有几个加入 Dropout、减小网络规模、加正则化参数、提前停止训练。MNIST 这个场景比较温和最简单的就是从减掉一半隐藏层神经元开始再配合 Dropout。实际操作中我见过很多新手一看到过拟合就去堆 Dropout其实第一原则是“让模型简单一点”MNIST 用 128 个隐藏单元已经很充裕了。5.3 各种 shape mismatch 报错这个问题在刚入门时遇到得最多因为你不清楚不同时刻数据到底长什么样。比如你忘了用FlattenDense 层接收的就是(None, 28, 28)而不是(None, 784)或者model.predict时传入了单张图片而不是一个批次。解决这个问题最简单的方法是遇到报错时把数据的 shape 逐行打印出来print(train_images.shape) print(model.input.shape)另一个技巧是查看模型每一层的输出形状直接用model.summary()这条命令会列出每一层的名称、输出 shape 和参数量一眼就能看出结构问题。养成“报错先看 shape再看 type”的习惯90% 的入门期报错都能解决。5.4 数据集下载超时或网络受限mnist.load_data()需要联网下载如果网络条件不好可能会卡在下载那一步。如果你的机器上已经有一份 MNIST 的压缩包或者 numpy 文件可以直接用本地方式加载避免反复等待。我自己遇到过几次下载到一半失败的情况后来就把数据集文件提前下载好放在~/.keras/datasets/目录下并且把文件名保持为mnist.npz再调用load_data()就会直接读取本地缓存不用重新下载。6. 扩展模型保存、加载与训练曲线可视化6.1 保存与加载模型让训练成果落地训练好的模型如果只存在于内存里关闭程序就没了。TF 2.0 提供了很简洁的保存方式model.save(mnist_model.h5)加载回来继续使用from tensorflow.keras.models import load_model new_model load_model(mnist_model.h5) loss, acc new_model.evaluate(test_images, test_labels)保存模型之后你就可以把训练和推理分成两个阶段训练阶段可能只跑一次推理阶段随时加载模型做预测。这个能力在真实项目里非常重要因为你不可能每次都重新训练几个小时再去预测一张新图。6.2 用 Matplotlib 绘制训练曲线看出模型的学习过程model.fit返回的history.history里装着每个 epoch 的loss、accuracy、val_loss、val_accuracy画出来比一组数字直观得多import matplotlib.pyplot as plt def plot_training(history): plt.figure(figsize(12, 4)) plt.subplot(1, 2, 1) plt.plot(history.history[loss], labeltrain loss) plt.plot(history.history[val_loss], labelval loss) plt.title(Loss) plt.legend() plt.subplot(1, 2, 2) plt.plot(history.history[accuracy], labeltrain acc) plt.plot(history.history[val_accuracy], labelval acc) plt.title(Accuracy) plt.legend() plt.tight_layout() plt.show()观察训练曲线的意义在于你可以一眼看出模型是欠拟合训练和测试 loss 都很高、过拟合训练 loss 持续下降但测试 loss 拐头向上还是正常收敛。这些判断能力比单纯追求高准确率要重要得多。7. 从 MNIST 走向更复杂的模型结构7.1 换用 CNN 的收益与实践全连接网络把 28x28 像素展开成 784 维向量会丢失空间位置关系——这对图像识别来说是一种浪费。卷积神经网络CNN通过卷积核在图像上滑动可以在保留局部空间结构的前提下提取特征。一个简化的 CNN 模型如下from tensorflow.keras.layers import Conv2D, MaxPooling2D cnn_model Sequential([ Conv2D(32, (3, 3), activationrelu, input_shape(28, 28, 1)), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activationrelu), MaxPooling2D((2, 2)), Flatten(), Dense(64, activationrelu), Dropout(0.5), Dense(10, activationsoftmax) ])注意CNN 需要输入包含通道维度的形状(28, 28, 1)MNIST 图片本身就是灰度图所以要在加载后做一次 reshapetrain_images train_images.reshape(-1, 28, 28, 1) test_images test_images.reshape(-1, 28, 28, 1)有了全连接网络的基础再切换到 CNN 知识体系的时候你依然是在操作同样的compile/fit/evaluate流程只需要把层换掉就行——这恰恰是 TF 2.0 把 API 统一之后带来的好处。7.2 数据增强和 BatchNorm 的进阶用法当模型对 MNIST 已经接近 99% 的准确率时再用同样的结构去提升非常困难。这时候可以考虑数据增强随机旋转、平移、缩放等它相当于人为制造更多样的训练样本增强模型的泛化能力。也可以在卷积层之间加入BatchNormalization它能对每一层的输入做标准化加速收敛而且某种程度上也有正则化效果。不过我想提醒一句MNIST 本身就是标准数据集你在这里做数据增强更多是为了“学到一种处理手段”而不是为了把准确率从 98% 拉到 99.5%。真正用到数据增强的领域是 CIFAR-10、ImageNet 这类带丰富背景和形变的数据集。思路是通用的但你要清楚它的动机。8. 个人踩坑记录与写在最后我再分享几个只有实际跑过才知道的小细节。第一点是数据集缓存的位置。mnist.load_data()下载完成后数据实际上是以mnist.npz的形式存在~/.keras/datasets/。如果你多次执行这段代码不会重复下载但在某些版本里如果中断了下载可能会留下不完整的缓存文件导致load_data()反复报错。遇到这种情况去缓存目录把文件删掉再重新加载即可。第二点是随机数种子的问题。神经网络初始权重是随机的所以每次训练结果会有细微差异。如果为了实验对比你希望同一套代码每次结果一致可以在开头加import random import numpy as np import tensorflow as tf random.seed(42) np.random.seed(42) tf.random.set_seed(42)第三点是耐心。MNIST 是一个 5 分钟就能看到结果的玩具级数据集但千万不要求快。建议你把每个环节都动手改一遍——改 batch size、改学习率、改网络层数、把 relu 换成 sigmoid然后观察结果的变化。这些实验的意义不在于找“最优配置”而在于建立你对模型行为的直觉。等到你将来面对真实项目时这种“猜到哪儿出了问题并能快速验证”的能力恰恰是决定项目进度的关键。最后想说TensorFlow 2.0 的 MNIST 只是第一块敲门砖。跑通它之后你可以接着试试 Fashion-MNIST也是 28x28 的 10 分类问题但难度高不少或者把手写数字识别扩展到更大的数据集。学习深度学习确实很容易被各种理论和工具的复杂程度劝退但有了这条最小路径打底后续的学习曲线会平缓得多。我始终觉得先跑通一个最小可行的例子再逐步往里填理论细节是最适合成年人、尤其是边工作边学习的人的方式。希望这篇笔记对你有帮助。