说到用TensorFlow 2训练MNIST,很多人的路径都是一样的:上篇跟着示例代码把第一个CNN跑通了,验证集准确率冲到98%甚至99%,然后突然就不知道该干什么了。代码能跑,但里面全是问号——卷积层为什么用32个滤波器而不是128个?池化层删掉会怎样?学习率从0.001改成0.01为什么就炸了?这篇中篇,就是来回答这些"上篇不会写、文档里也查不到"的问题。内容围绕CNN训练MNIST数据集的完整链路展开,适合已经跑通基础代码、想真正理解模型行为并且亲手做调参实验的读者。
我会直接给出一份baseline代码作为基准,然后用消融实验逐个验证卷积、池化、全连接的作用,再聊学习率、batch size、epochs这三个参数怎么配合,最后把训练过程中最常遇到的三个异常症状过一遍排查思路。文章末尾还会分享一个我很喜欢的玩法:把CNN内部的特征图和卷积核翻出来看,你会发现模型学到的远比想象中具体。
1. 中篇的起点:先把baseline模型稳稳跑起来
1.1 从上篇到中篇,你手里的代码应该是这个状态
在做任何对比实验之前,需要一个稳定可控的基准模型。上篇的代码可能各有各的写法,这里我统一成一版,后面所有改动都基于这版,方便对照:
import tensorflow as tf from tensorflow.keras import layers, models # 加载 MNIST (x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data() # 关键预处理:归一化 + 增加单通道维度 x_train = x_train.reshape(-1, 28, 28, 1).astype("float32") / 255.0 x_test = x_test.reshape(-1, 28, 28, 1).astype("float32") / 255.0 # one-hot 编码 y_train = tf.keras.utils.to_categorical(y_train, 10) y_test = tf.keras.utils.to_categorical(y_test, 10) model = models.Sequential([ layers.Input(shape=(28, 28, 1)), layers.Conv2D(32, (3, 3), activation="relu", padding="same"), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation="relu", padding="same"), layers.MaxPooling2D((2, 2)), layers.Flatten(), layers.Dense(128, activation="relu"), layers.Dense(10, activation="softmax"), ]) model.compile( optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"], ) history = model.fit( x_train, y_train, batch_size=128, epochs=10, validation_data=(x_test, y_test), )这版模型在MNIST测试集上稳定能到99.2%以上,训练时间在普通CPU上也就是几分钟的事。有两个细节值得多说一句。
第一,reshape(-1, 28, 28, 1)这一步特别容易被漏掉。MNIST原始数据是(60000, 28, 28),但Conv2D要求输入是四维张量(batch, height, width, channels)。很多人第一次跑直接拿三维数组喂模型,报错后就卡住了。灰度图是单通道,所以最后一维是1。
第二,除以255的归一化不是可有可无的步骤。如果不做这一步,像素值范围是0到255,数值跨度大,梯度更新很容易震荡,loss下降会非常痛苦。归一化把数据压到0到1区间后,优化过程会平滑很多。这两点看着基础,但我在之后的调参实验里反复踩过,所以先放在前面。
1.2 硬件差异带来的两个现实问题
上篇跑通之后,第二个容易让人困惑的问题是硬件对实验的影响。我自己的经验是:如果你用的是CPU,直接跑上面这版代码,每个epoch大概5到10秒,10个epoch几分钟完事,完全能接受。但如果你手头有一块入门级GPU,也别高兴太早——MNIST这种28x28的小图,数据量又不大,GPU的并行能力根本喂不饱,训练速度的差距可能只有两三倍,远没有你在跑大模型时感受到的那么悬殊。
真正拉开差距的是把网络加深之后。比如把模型改成5个卷积层,CPU上的训练时间可能直接翻好几倍,GPU虽然也会变慢,但相对从容很多。所以中篇的实验我会刻意控制在"CPU也能跑得动"的规模内。这样做的目的不是回避性能问题,而是:当每个实验只需要一两分钟的时候,你才愿意反复改参数、反复跑对比,这种高频试错积累出来的直觉,比任何理论都值钱。
如果你发现自己的机器跑上述代码特别吃力,可以考虑两个调整:一是把batch_size从128降到32,每轮迭代更快,但epoch变多;二是适当减少epochs,先跑到5轮看趋势。不过做对比实验时要注意:所有对照组的训练轮数、batch size要保持一致,否则变量不单一,结论就不干净了。
2. 卷积、池化、全连接各有分工:用消融实验验证每一层的价值
2.1 三个核心组件分别在做什么
很多教程会把卷积、池化、全连接讲得很抽象,什么"局部感受野"、"平移不变性",听着专业,但没过多久就忘了。我用大白话捋一遍。
卷积层干的事情是"扫描局部特征"。一个3x3的滤波器在整张28x28的图像上滑动,每个位置算一次点积,输出一张新的特征图。这张特征图上的每个点,都代表了原图某个局部区域里是否存在某种模式——第一层可能是横边、竖边、斜线,更深层可能是拐角、圆圈的一部分。用32个滤波器,就相当于用32种不同的"放大镜"去扫描图像。
池化层的核心操作很简单:在2x2的窗口里取最大值(或者平均值),把特征图尺寸缩小一半。它做了两件事——压缩信息量和增大后续层的感受野。14x14的图经过一次池化变成7x7,虽然分辨率降低了,但每个点对应原图的区域变大了,这让模型对笔画位置的轻微偏移不那么敏感。
全连接层则是把前面卷积池化提取出来的"特征地图"拉平,然后做传统神经网络该做的事——组合特征、输出分类概率。说白了,前面的卷积池化负责"看",全连接层负责"判断"。
这套分工看着合理,但光听概念没用。我建议你亲手做个实验,把某一层删掉,看看模型到底会变成什么样。
2.2 消融实验怎么做:删掉某一层看效果
消融实验的名字听着高级,实际上特别简单:从完整模型里去掉一个组件,保持其他设置不变,训练后对比指标。下面是我在baseline基础上做的几组变体:
# 变体B:去掉卷积层,变成纯MLP model_b = models.Sequential([ layers.Input(shape=(28, 28, 1)), layers.Flatten(), layers.Dense(128, activation="relu"), layers.Dense(10, activation="softmax"), ]) # 变体C:去掉两个池化层,卷积层保持不变 model_c = models.Sequential([ layers.Input(shape=(28, 28, 1)), layers.Conv2D(32, (3, 3), activation="relu", padding="same"), layers.Conv2D(64, (3, 3), activation="relu", padding="same"), layers.Flatten(), layers.Dense(128, activation="relu"), layers.Dense(10, activation="softmax"), ]) # 变体D:去掉第二个卷积层 model_d = models.Sequential([ layers.Input(shape=(28, 28, 1)), layers.Conv2D(32, (3, 3), activation="relu", padding="same"), layers.MaxPooling2D((2, 2)), layers.Flatten(), layers.Dense(128, activation="relu"), layers.Dense(10, activation="softmax"), ])每组训练10个epoch,batch size固定128,优化器同为Adam,学习率用默认的0.001。我跑出来的结果是这样的:
| 网络变体 | 参数量(约) | 测试集准确率(约) | 训练时间感受 |
|---|---|---|---|
| 完整CNN(baseline) | 42万 | 99.2%~99.4% | 每epoch约5~10秒(CPU) |
| 纯MLP(去卷积) | 10万 | 97.8%~98.2% | 比CNN快一倍 |
| 去池化(保卷积) | 644万 | 99.0%~99.2% | 明显变慢 |
| 去第二个卷积 | 80万 | 98.4%~98.8% | 稍快于完整版 |
2.3 结果背后的结论
三组对照实验能读出不少信息。
纯MLP准确率掉到98%左右,说明在全连接网络眼里,MNIST真的不算简单。它需要每个像素位置上的信息做独立权重组合,784个像素之间没有天然的局部关联建模。而CNN靠卷积的权重共享和局部连接,能用少得多的参数学会同样的任务,泛化还更好。
去掉池化层最值得玩味。模型参数从42万暴涨到644万——为什么?因为两个卷积层输出的特征图尺寸没变,依然是28x28,第二层输出64个通道后,Flatten得到的向量是28*28*64 = 50176维,和128个神经元的全连接层之间光是权重就有600多万个参数。准确率依然保持在99%以上,但训练时间明显拉长,而且参数量这么夸张,在更大的数据集上很容易过拟合。池化层在这里的贡献就很清楚了:它不只是"降分辨率",更是通过减少特征图尺寸来控制后续全连接层的参数规模。
去掉第二个卷积层,准确率降到98.5%左右,说明模型容量变小了,卷积层提取特征的抽象层次也少了一级。第一个卷积层学到的更多是边缘和方向,第二个卷积层才能组合出笔画、拐角这类更高层的模式。少了这层,模型的"判断依据"就不够充分。
我自己做这个系列实验最大的感受是:在MNIST上,准确率从98%到99%看似只差1个百分点,但这1个百分点恰恰是CNN结构设计价值的体现。你把这几个变体的loss曲线画在一起看,会发现CNN的收敛速度还更快——不光是终点更高,路径也更顺。
3. 训练参数的组合拳:学习率、batch size、epochs要一起调
3.1 每个参数管什么,为什么不能孤立看
进入调参阶段之前,先说清楚这三个参数各自的脾气。
学习率决定了每次更新参数时迈多大的步子。步子太大,loss在谷底附近反复横跳,甚至直接跳飞;步子太小,训练半天还在原地磨蹭。MNIST上比较合适的初始范围是0.0001到0.01,但具体配哪个优化器,表现差异很大。
batch size决定了每次更新梯度时用多少个样本。它的作用在于梯度方向的"噪声水平":batch越小,每次梯度越粗糙,但更新频率高,有时候反而能跳出局部极小值;batch越大,梯度方向越平滑,训练过程稳定,但可能收敛到比较"尖锐"的解,泛化不见得更好。
epochs是循环遍历完整训练集的次数。理论上训练时间越长,训练集上的loss越低,但验证集准确率到一定程度后不升反降,这就是过拟合的信号。
这三个参数不是独立的。你单独把学习率调大,效果如何取决于batch size多大;你单独把batch size调小,训练稳定度又受学习率影响。组合起来才叫调参。
3.2 常见组合实测对照
我用baseline模型做了一批组合实验,每个组合训练10个epoch,默认batch size 128时学习率0.001作为参照:
| 学习率 | batch size | 观察到的主要现象 |
|---|---|---|
| 0.001 | 128 | 稳定收敛,第5个epoch左右验证集acc达到99%以上 |
| 0.01 | 128 | 头两个epoch acc涨得很快,但loss震荡,最终在98.5%附近徘徊 |
| 0.0001 | 128 | 训练非常稳但慢,10个epoch只到98%左右,明显欠收敛 |
| 0.001 | 32 | 收敛略慢一点,最终acc能到99.3%左右,波动更小 |
| 0.001 | 512 | 每epoch速度快,但梯度过于平滑,后期训练acc略低于batch 128 |
这里有个有意思的发现:在MNIST上,学习率从0.001跳到0.01,很多人第一反应是"训练更快",实际是"早期看似更快,后期受限于震荡,终点反而低了"。而那些默认参数(Adam学习率0.001,batch size 128)之所以成为通用选择,是因为它在一个很宽的场景里都能稳定收敛,不需要你做太多额外调整。
另外,如果你用小batch size(比如32),可以适当把学习率调低一点,因为每次更新频繁、梯度噪声大,步子太大容易摇摆。反过来,如果你用大batch size(比如512),梯度方向更稳,可以试探稍微大一点的学习率。但这种"试探"一定要以小步快跑的方式进行,一次改一个变量,别同时动两个。
3.3 优化器选哪个:Adam与SGD的取舍
优化器在MNIST这种小数据集上的差异会被放大,因为训练时间短,任何收敛快的机制都能显现出来。我对比过几个常见的:
| 优化器 | 收敛速度 | 最终准确率(约) | 备注 |
|---|---|---|---|
| Adam(默认lr=0.001) | 快 | 99.3% | 基本不用调参 |
| RMSprop(默认lr=0.001) | 快 | 99.1%~99.2% | 和Adam差距不大 |
| SGD(lr=0.01) | 慢 | 98.5%左右 | 10个epoch不够,需要多跑 |
| SGD(lr=0.01, momentum=0.9) | 中等 | 99.2%~99.3% | 调好后不输Adam |
一个容易被忽略的事实是:SGD加Momentum调好了,在MNIST上同样能到99%以上,甚至可能比Adam更稳。但Adam赢在"省心"——它对学习率不那么敏感,在不同网络结构上表现都说得过去。对于中篇这个阶段,用Adam做默认选择是非常合理的做法,先保证训练过程稳定,再去理解其他优化器的差异。
我建议你至少做一次这样的对比实验:同一个baseline,分别用Adam和SGD(无momentum),把训练曲线画在一起。你会看到Adam前期冲得很快,SGD像蜗牛爬。但如果你把SGD的学习率调成0.1,它可能直接发散——这就是参数敏感度的直观感受,比看十篇文档都管用。
4. 训练曲线不听话:三大异常症状的排查链路
4.1 症状一:loss震荡、迟迟不收敛
这是最常遇到的异常。典型表现是loss像锯齿一样上下起伏,训练集准确率也高高低低,十几二十个epoch之后还在原地晃悠。
我的排查顺序是固定的:第一步,确认数据有没有归一化。MNIST的像素值如果不除以255,模型大概率就是这幅德性。别笑,我见过不少人在这里卡了一下午。第二步,看学习率。如果你用的是Adam但把学习率调成了0.01,立刻降回0.001试试,90%的震荡问题会当场消失。第三步,确认标签和损失函数是否匹配。多分类用categorical_crossentropy,标签要对应用to_categorical处理过;如果标签是稀疏整数,就该换sparse_categorical_crossentropy。这个不匹配虽然不报错,但loss曲线的形态会很怪。
还有一个不太显眼的原因:网络初始化导致的早期不稳定。如果你的模型层数很深(比如加了5个卷积层),且没有用BatchNormalization,前几个epoch的loss也可能出现剧烈波动。这时候可以先加一个BatchNormalization层看效果,也可以把学习率临时调低一点。
4.2 症状二:训练准确率极高、验证准确率封顶
训练集上面的acc冲到99.9%,验证集只停在99%上下徘徊,甚至掉头向下。这种"分裂"就是过拟合的典型信号。
MNIST因为图像尺寸小、类别明确,过拟合不像大数据集那么猛烈,但只要你把网络加深加宽,它一定出现。我做一个加深版模型时(两层32通道卷积再接两层64通道卷积),第8个epoch之后验证集acc就开始波动,训练集acc还在缓慢上升。经典的解决方案有三个。
加Dropout是最省事的。在Flatten之后接一个Dropout(0.5),相当于随机让一半的全连接输入失活,迫使网络不依赖少数神经元。二是数据增强,对训练图做轻微旋转、平移、缩放,相当于把训练集"扩大"了,但这个在MNIST上效果温和,毕竟数字已经很规整。三是减小模型容量,把Dense层从128降到64,或者减少卷积滤波器数量,参数少了,记忆训练集的能力就弱了。
我的建议是先加Dropout,再跑10个epoch看曲线。如果验证集acc能稳住,说明问题解决;如果还不行,再考虑数据增强。
4.3 症状三:loss变成nan
loss突然变成nan,看着吓人,原因往往不复杂。最常见的是学习率过高导致梯度爆炸,参数更新一步跨出太大,数值直接溢出。解决办法很简单:把学习率从0.001降到0.0001,一般立竿见影。
另外两个可能的原因也要留意:一是数据里混入了异常值,MNIST本身很干净,但如果你自定义了数据加载流程,检查有没有像素值出现负数或极端大值;二是模型某层激活函数输出的数值范围太大,尤其是没有归一化的情况。还有一个防护手段:在优化器里加梯度裁剪,比如Adam(clipnorm=1.0),这样即使梯度爆炸,更新步长也会被限制住。
4.4 排查顺序的重要性
把三个症状放在一起看,排查思路其实有共同路径:先检查数据,再调整优化器和学习率,最后才怀疑模型结构。原因很简单——数据问题影响最大,修改成本最低;参数问题其次;结构问题需要重新设计和训练,成本最高。
| 异常现象 | 优先检查项 | 常用处理手段 |
|---|---|---|
| loss震荡不降 | 归一化 / 学习率 / 标签格式 | 恢复归一化、学习率降10倍、换匹配的损失函数 |
| 训练acc高验证acc低 | 模型容量 / 过拟合 | 加Dropout、数据增强、减小Dense尺寸 |
| loss变nan | 学习率 / 数值稳定性 | 降低学习率、加梯度裁剪 |
我个人的习惯是,每改一个参数只跑一遍训练,把loss曲线打印出来瞄一眼,不急着做更多改动。有时候一眼就能看出问题所在,比一次改三个参数然后对着结果猜是哪一步起的效果要高效太多。
5. 把CNN的"眼睛"打开:特征图与卷积核可视化
5.1 提取中间层输出的两种方式
调参调得差不多之后,一个特别有意思的玩法是看看CNN内部到底在"看"什么。TensorFlow 2提供了很简洁的方式提取中间层输出。
第一种方式,重新定义一个从输入到指定层输出的模型:
from tensorflow.keras.models import Model # 假设我想看第一个卷积层和第二个卷积层的输出 layer_outputs = [ model.layers[1].output, # Conv2D,输出形状 (None, 28, 28, 32) model.layers[3].output, # Conv2D,输出形状 (None, 14, 14, 64) ] vis_model = Model(inputs=model.input, outputs=layer_outputs) # 取一张测试集图像 sample = x_test[0:1] # shape: (1, 28, 28, 1) outs = vis_model.predict(sample) print(outs[0].shape) # (1, 28, 28, 32) print(outs[1].shape) # (1, 14, 14, 64)拿到输出后,把每个通道对应的二维矩阵用matplotlib画成灰度图即可。第一层32个通道,就是32张28x28的图;第二层64个通道,就是64张14x14的图。
第二种方式是直接可视化卷积核本身:
# 取第一个卷积层的权重 weights = model.layers[1].get_weights()[0] # shape: (3, 3, 1, 32) # 每个滤波器是一个3x3的矩阵 for i in range(32): kernel = weights[:, :, 0, i] # 用 imshow(kernel, cmap='gray') 画出来3x3的卷积核只有9个数值,画出来特别小,但你可以把32个核排成一张大图看,能明显看出不同类型的边缘检测模式。
5.2 从特征图里读出模型学到了什么
我实际做这个实验时,第一层卷积核可视化出来的结果非常有意思:有一部分核长得像Sobel算子——中间亮两边暗,或者中间暗两边亮,这就是在检测竖边;还有一部分核是左上亮右下暗,那就是在检测对角线方向。也就是说,CNN在底层学到的边缘检测器,和几十年前人们手工设计的经典图像处理算子惊人地一致。这不是巧合,而是图像局部特征的客观规律。
再看特征图。拿一张数字"7"的图像,经过第一个卷积层后,有些通道上"7"的斜线区域亮了,有些通道上横线区域亮了,说明这些滤波器分别被斜边和横边激活了。经过第二个卷积层后,特征图的空间分辨率降到了14x14,每个点的语义更复杂,它激活的不再是单纯的边,而是组合形状——可能是"7"的交叉结构,也可能是"0"的闭合圆环特征。
这个可视化实验解决了我很大的一个困惑——CNN的"特征提取"到底是玄学还是规律。它是规律的,而且规律就长在图像本身的结构里。你换一张完全不同的图(比如一只猫),第一层卷积核大概率还是那样,因为它们提取的本来就是最基础的边缘纹理;但更高层的特征图会有明显差异,说明深层网络关注的是和任务强相关的模式。
5.3 顺手用上TensorBoard
除了手动可视化,训练过程本身也值得实时盯一下。
model.fit( x_train, y_train, batch_size=128, epochs=10, validation_data=(x_test, y_test), callbacks=[ tf.keras.callbacks.TensorBoard(log_dir="./logs"), ], )训练结束后在终端运行tensorboard --logdir ./logs,浏览器里打开localhost:6006,可以看到loss曲线、acc曲线,还能看模型结构图。做消融实验或者调参对比的时候,把不同run的日志写到不同子目录,TensorBoard会自动叠加对比,比每次print几个数字直观得多。我到现在做实验都习惯先开一个TensorBoard挂着,它的曲线刷新是实时的,训练过程中就能看出模型走向,不用等epoch全部结束。
5.4 这个玩法还能怎么扩展
特征图可视化其实可以延伸到很多方向。比如用某一层特征图做聚类分析,把数字图像投影到特征空间里看分布,你会发现同类数字在特征空间里自动聚集在一起。再有就是类激活热力图这类方法——通过梯度反传算出图像中哪些区域对分类贡献最大,画出来就是一张"模型注意力热力图",能看出模型是根据图像哪一块做出的判断。
这些内容有点偏进阶,放到中篇可能太满,但值得知道它们存在。当你发现CNN内部不是黑盒、而是有清晰规律可循的时候,后面学什么都会顺手很多。
写到这里,中篇的核心内容基本收尾了。我自己做这几个实验最大的体会是:MNIST真是个完美的"实验田"——训练快、结果稳定、改动反馈即时可见。所以别怕在上面花时间,把网络结构拆一遍、参数组合跑一遍、异常症状踩一遍,这种亲手攒出来的直觉,看多少文档都换不来。下一篇再聊把模型精度继续往上推的时候,你至少已经知道每一步调整是在动哪块骨头了。