
在接触深度学习的最初阶段我最困惑的不是某个激活函数怎么推导而是打开编辑器不知道先写哪一行。后来我把别人跑通的入门项目拆开看了一遍又一遍发现无论手写数字识别还是衣服裤子分类代码骨架都高度相似加载数据、做预处理、用Sequential搭网络、compile、fit、evaluate。这个骨架固定到了什么程度固定到连每一行的先后顺序都几乎不会变。我习惯叫它“神经网络八股”。这篇笔记就围绕MNIST手写数字识别和Fashion MNIST衣裤分类两个任务把这条八股流程完整走一遍。我会用TensorFlow与Keras的写法从数据加载一直讲到模型预测并把每步为什么要这么做、调整某个参数会带来什么影响也一并说清楚。适合刚学完基本概念、想自己动手跑通第一个图像分类项目的读者也适合已经跑通过、但还想把细节理解得更扎实的同学。1. 为什么神经网络代码会长成“八股”样1.1 “八股”在深度学习里不是贬义“八股”这个词听上去像是刻板、僵硬但在工程实践中一套固定模板反而是最低成本的起点。原因很简单图像分类任务本质上是同一类问题——把像素矩阵映射到类别标签。既然问题的数学结构一致代码结构当然也一致。我以前总想着每个项目都要“从头开始设计”后来发现那是浪费时间。真正需要设计的是模型内部的结构和参数而不是外围那圈数据加载、训练、评估的流程。把外围流程固定下来相当于给自己搭好脚手架剩下的精力才可以全部放在模型本身。1.2 这段流程的顺序有严格的依赖关系不少初学者会问为什么一定要按“数据→模型→编译→训练→评估”的顺序写能不能先建模型再处理数据答案是可以但逻辑上说不过去。因为模型第一层的输入维度取决于数据的形状输出维度取决于标签的类别数loss函数的选择又取决于标签是整数还是独热编码。这意味着如果你不先看一眼数据后面的代码就无从写起。更深一层训练的前提是模型已经编译而模型编译的前提又是模型已经搭好。每一步都在为下一步准备输入。这种单向依赖关系正是八股流程如此稳定的根本原因。1.3 整套流程先看个全景先给出一个极简的八股模板后面的章节再逐个填空。# 1. 导入工具库 import tensorflow as tf from tensorflow import keras # 2. 加载并预处理数据 (x_train, y_train), (x_test, y_test) keras.datasets.mnist.load_data() x_train x_train.astype(float32) / 255.0 x_test x_test.astype(float32) / 255.0 # 3. 搭建模型 model keras.Sequential([ keras.layers.Input(shape(28, 28)), keras.layers.Flatten(), keras.layers.Dense(128, activationrelu), keras.layers.Dense(10, activationsoftmax) ]) # 4. 编译模型 model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy]) # 5. 训练模型 history model.fit(x_train, y_train, batch_size64, epochs5, validation_split0.1) # 6. 评估模型 test_loss, test_acc model.evaluate(x_test, y_test)这个模板放到Fashion MNIST上同样成立唯一要改的是第二行加载数据那一句。理解了为什么是这六个步骤你就算掌握了一半的入门内容。2. 先建立数据集认知MNIST与Fashion MNIST的“打开”方式2.1 两行代码就能拿到官方数据TensorFlow把常用数据集内置在keras.datasets里MNIST和Fashion MNIST都属于开箱即用的类型。加载方式非常统一from tensorflow import keras # MNIST手写数字 0-9 (x_train, y_train), (x_test, y_test) keras.datasets.mnist.load_data() # Fashion MNIST十类衣裤鞋包 (x_f_train, y_f_train), (x_f_test, y_f_test) keras.datasets.fashion_mnist.load_data()第一次调用时程序会自动下载数据到本机缓存目录之后再运行就直接从本地读取。数据格式需要注意训练集60000张图片每张是28x28的灰度图测试集10000张图片同样28x28标签0到9的整数。x_train.shape输出是(60000, 28, 28)代表60000个样本、每个样本28行28列像素。MNIST和Fashion MNIST在这个维度上完全一致所以代码可以无缝切换。2.2 图片样本长什么样类别分别是什么拿到数据后第一时间看一下图片是很有必要的。很多人直接跳过这一步等到训练完成才发现把“裤子”和“衬衫”搞混了回头才补做可视化。import matplotlib.pyplot as plt plt.figure(figsize(10, 4)) for i in range(10): plt.subplot(2, 5, i 1) plt.imshow(x_train[i], cmapgray) plt.title(flabel: {y_train[i]}) plt.axis(off) plt.show()MNIST的10个类别就是数字0到9。Fashion MNIST的10个类别如下表所示标签值含义标签值含义0T恤/上衣5凉鞋1裤子6衬衫2套头衫7运动鞋3连衣裙8包4外套9短靴两个数据集都是10分类问题这也是它们适合做对照实验的原因。一样的模型结构、一样的训练流程差别只在图像内容本身。2.3 归一化为什么要把像素除以255原始像素值范围是0到255而神经网络在计算时倾向于数值较小的输入。把数据统一缩放到0到1之间本质上是降低输入特征的量纲差异。如果不做归一化初始梯度会被放大训练过程会变得极不稳定loss在头几个epoch可能跳动很剧烈甚至直接变成NaN。即使网络勉强收敛最终准确率往往也不理想。归一化只需要两行代码x_train x_train.astype(float32) / 255.0 x_test x_test.astype(float32) / 255.0要注意的是/255.0会让整数数组自动变成浮点数。我更建议显式先astype(float32)这样在移动端或嵌入式环境里跑的时候可以避免一些类型隐式转换带来的小坑。3. 搭网络Flatten和Dense是八股的核心部件3.1 Sequential模型的适用边界Keras里最友好的模型接口是Sequential它按层一层层堆叠前一层的输出就是后一层的输入。对于极简全连接网络Sequential是首选。model keras.Sequential([ keras.layers.Input(shape(28, 28)), keras.layers.Flatten(), keras.layers.Dense(128, activationrelu), keras.layers.Dense(10, activationsoftmax) ])如果未来需要跳层连接、多输入多输出或者想自定义训练循环再改用更灵活的ModelAPI。对这篇文章的两个案例Sequential足够。3.2 为什么全连接层前面要加FlattenFlatten做的事情很简单把输入从多维变成一维。一张28x28的图片会被展开成784个数值的向量。有人会问28x28本身已经是二维矩阵模型能不能直接吃全连接层Dense的输入必须是一个特征向量。如果不加Flatten直接丢给DenseKeras会报错或者得到不符合预期的shape。所以Flatten在这里相当于“把矩阵拍平成向量”的适配器是图像分类进入全连接层前必须的一步。顺带提一句Input(shape(28, 28))这一层指定了输入的形状训练时一次性读取一个batch每个样本都是28行28列。3.3 激活函数的选择逻辑中间隐藏层用的是relu输出层用的是softmax这个搭配几乎是八股标配。relu计算简单、能缓解梯度消失在隐藏层中使用非常稳定。softmax会把输出层的10个实数转换成10个概率值且所有概率加起来等于1天然适合多分类任务的概率解释。如果你把输出层改成sigmoid每个节点虽然也能输出0到1之间的值但并没有归一化不能真正当作多分类概率来用。二分类问题用sigmoid没问题多分类问题的输出层基本都选softmax。3.4 看一眼参数量和模型结构model.summary()可以列出每一层的输出形状和参数数量建议每次搭完模型都跑一下。model.summary()这个简单模型的参数量主要来自两处第一个Dense层输入784个特征输出128个神经元参数量为784x128128100352第二个Dense层输入128个特征输出10个神经元参数量为128x10101290。合计约10万个参数。对这么小的网络来说训练起来非常快CPU也能轻松跑完。这也是入门案例选它的原因之一。4. 编译、训练与评估三段式操作详解4.1 compile里三个参数分别是什么compile是训练前的最后一道准备工序它告诉框架三件事用什么优化器、用什么损失函数、关心什么指标。model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy])optimizeradam梯度下降的具体策略。Adam能自适应调整学习率大部分情况下比SGD收敛更快对新手更友好。losssparse_categorical_crossentropy多分类损失函数。名字里的sparse代表标签是整数比如数字“7”直接传7。metrics[accuracy]训练过程中除了显示loss之外额外显示准确率。这里最容易踩的坑是损失函数对应关系。如果你的标签是独热编码one-hot比如数字“7”变成[0,0,0,0,0,0,0,1,0,0]就必须用categorical_crossentropy如果标签是整数就用sparse_categorical_crossentropy。两者用反了训练过程不会直接报错但准确率会一直停留在很低的位置。4.2 fit训练过程的参数含义history model.fit(x_train, y_train, batch_size64, epochs5, validation_split0.1)batch_size64表示每64张图片算一次梯度。batch_size过小训练波动大过大内存压力大收敛也可能变慢。epochs5表示把整个训练集过5遍。validation_split0.1表示从训练集里拿出最后10%当作验证集用于观察模型在没见过的数据上的表现。返回的history对象中保存了每个epoch的训练loss、准确率和验证集指标。很多人训练完就扔了其实这里的信息非常宝贵print(history.history.keys()) # dict_keys([loss, accuracy, val_loss, val_accuracy])如果train_loss持续下降但val_loss开始反弹说明模型已经过拟合如果两个loss都下降缓慢则说明学习率可能偏低或模型容量不足。这些判断都可以通过history完成。4.3 evaluate和predict的区别训练完成后我们关心两个问题模型整体表现如何对具体某张图片会怎么判断evaluate负责第一个问题在测试集上算出最终loss和accuracytest_loss, test_acc model.evaluate(x_test, y_test) print(f测试集准确率: {test_acc:.4f})predict负责第二个问题输出每个样本属于各个类别的概率predictions model.predict(x_test[:10]) print(predictions[0]) # 输出一个长度为10的向量例如 [0.01, 0.02, ..., 0.94]predict得到的是一串概率想要最终类别用argmax取最大值所在的下标import numpy as np pred_classes np.argmax(predictions, axis1)从概率分布里能读到比准确率更丰富的信息如果最高概率和次高概率非常接近说明模型对这张图也很“犹豫”这时要么加强训练要么需要更多数据。4.4 更进一步混淆矩阵与错分样例大多数入门笔记到evaluate就结束了。如果真想判断模型哪些类别容易混淆建议把预测结果和真实标签做成混淆矩阵。from sklearn.metrics import confusion_matrix, classification_report pred_test np.argmax(model.predict(x_test), axis1) print(classification_report(y_test, pred_test))classification_report会把每个类别的精确率、召回率、F1值列出来。比如Fashion MNIST里“衬衫”和“T恤/上衣”经常被模型互相认错单看总体准确率你压根发现不了只有看分类报告才会注意到问题出在哪几类。这是八股流程之外非常值得加的一步。5. 同一套八股搬到Fashion MNIST真就改一行5.1 换数据集模型与训练流程原封不动说“只改一行”稍微有点夸张实际上加载数据那一行换掉之后后面的归一化、建模型、编译、训练全部可以复用。严谨一点完整的Fashion MNIST训练代码如下from tensorflow import keras import numpy as np # 换了一行数据集换成fashion_mnist (x_train, y_train), (x_test, y_test) keras.datasets.fashion_mnist.load_data() x_train x_train.astype(float32) / 255.0 x_test x_test.astype(float32) / 255.0 model keras.Sequential([ keras.layers.Input(shape(28, 28)), keras.layers.Flatten(), keras.layers.Dense(128, activationrelu), keras.layers.Dense(10, activationsoftmax) ]) model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy]) history model.fit(x_train, y_train, batch_size64, epochs5, validation_split0.1) test_loss, test_acc model.evaluate(x_test, y_test) print(fFashion MNIST 测试集准确率: {test_acc:.4f})模板的威力就在这里体现。同一个网络结构数据换成衣服裤子鞋包流程一字不改系统就能完成一次完全不同的分类任务。5.2 两个数据集的实验结果差异我在本机分别跑过一次MNIST和Fashion MNIST网络结构和超参数完全相同结果对比如下项目MNISTFashion MNIST训练样本数6000060000测试样本数1000010000图片尺寸28x28灰度28x28灰度类别数1010隐藏层神经元128128epochs55测试集准确率约0.98约0.91MNIST明显更容易达到高准确率因为手写数字背景简单、笔画结构稳定。Fashion MNIST里的衣服裤子和鞋靴形状更复杂同类物品之间差异大不同类之间又存在相似特征比如衬衫和T恤、凉鞋和运动鞋。所以同样的八股模板Fashion MNIST准确率低几个百分点是完全正常的现象不代表代码写错了。5.3 迁移到Fashion后可以做的简单调整如果想在Fashion MNIST上继续提升可以优先考虑两个方向一是增加训练轮数。5个epoch对Fashion来说往往还没完全收敛改到10或15个epoch后准确率通常会继续上升。二是增加模型容量。可以把隐藏层从一层128改成两层比如Dense(256, activationrelu)之后再接一个Dense(128, activationrelu)。参数量变大后模型表达能力增强但也要小心过拟合最好同时加入Dropout层。这里要强调一个原则不要一上来就堆参数。先用八股模板跑通基线再一个一个变量地调这样你才能知道每个改动到底起了多大作用。6. 把八股跑顺之后真正要避开的几个坑6.1 忘记归一化loss乱跳与NaN的元凶我在初学阶段犯过的最蠢错误就是加载完数据直接丢进模型。表面上看代码没报错但训练时loss从一开始就很高而且降得极慢有时到第3个epoch直接变成NaN。原因很简单输入像素值范围0到255经过累加求和之后网络中间层的输出值变得很大配合交叉熵损失计算梯度可能溢出。这不是玄学而是数值计算的不稳定性。规避方法就一句话训练前先确认输入数据范围。打印x_train.min()和x_train.max()如果在0到255之间说明归一化没做回去补上。6.2 标签格式与损失函数不匹配这是很多人绕过的弯路。标签是整数如7用sparse_categorical_crossentropy标签是独热编码如[0,0,0,0,0,0,0,1,0,0]用categorical_crossentropy。如果把独热编码的标签丢给sparse_categorical_crossentropy训练时会报错形状对不上反过来整数标签丢给categorical_crossentropy有的版本会直接报错有的版本会静默地训练出很差的模型。后一种情况更难受因为程序不报错你以为是模型问题实际上是数据格式问题。我的习惯是只要标签是从load_data()直接拿来的就用sparse_categorical_crossentropy尽量不做无谓的one-hot转换。6.3 validation_split和shuffle的细节validation_split0.1的机制是从训练集末尾按顺序截取10%作为验证集。如果你的原始数据有明显顺序比如前60000张里前几万都是同一种类别验证集的代表性就会受到影响。正规一点的做法是提前用train_test_split手动切分或者训练前手动调用shuffle打乱顺序。fit默认会在每个epoch开始前shuffle训练数据但验证集不会shuffle这是设计好的。不要自己去测试集上做数据增强或shuffle否则评估结果会失真。6.4 随机种子与可复现性神经网络权重初始化、数据打乱都带有随机性。同一段代码跑两次最终准确率可能差0.2到0.5个百分点。为了实验可复现可以在开头固定随机种子。import numpy as np import tensorflow as tf np.random.seed(42) tf.random.set_seed(42)注意即使固定这些种子在GPU上训练时某些底层运算仍然可能引入随机性。不要指望每次完全相同而是保证“大方向稳定”。如果你的准确率在两次运行之间波动特别大那通常说明模型训练不充分或学习率设置不合适而不是随机性的锅。6.5 把八股模板当成自己的脚手架最后说点心得。这个八股模板不是最终形态它是脚手架。熟练之后你完全可以在保留整体框架的前提下自由改造把Dense层换成Conv2D和MaxPooling2D就是CNN版本的图像分类把Flatten换成GlobalAveragePooling2D可以减少全连接层参数量把model.fit换成自定义训练循环就能接入更复杂的损失函数与优化策略。但无论怎么改数据加载、归一化、模型构建、编译、训练、评估这条主干不会变。这也是为什么我说先把八股背熟再谈创新。我第一次完整跑通这套流程时激动得把输出截图看了好几遍。后来这种成就感慢慢变成一种踏实我知道自己手里有一把能循环使用的钥匙遇到新的图片分类问题不用再从零开始纠结代码结构而是集中精力去思考数据、模型和调参。这大概就是八股模板带给我最大的价值。