1. 为什么MNIST值得反复“折腾”
如果你翻过任何一本深度学习入门书,MNIST几乎必然出现在第一章。28×28的灰度手写数字,60000张训练图、10000张测试图,文件小到用手机流量都能下载,跑一个全连接网络在普通笔记本上几分钟就能出结果。也正因为太“简单”,很多人跑完一个99%准确率的模型就把它丢到一边,觉得没什么可挖的。
但我在实际带新人和自己做实验的过程中发现,MNIST真正的价值根本不在“分类准确率”上,而在于它是一个可以完全掌控的沙盒。数据规模小,意味着你可以把每一张图都拿出来看;类别只有10个,意味着混淆矩阵一眼就能读懂;像素只有784维,意味着你可以用PCA、t-SNE甚至直接手写降维代码去观察特征空间。换句话说,当你在真实项目里被几百万张高分辨率图片和几十个类别搞得焦头烂额时,MNIST是你唯一能把“数据—模型—可视化”这条链路完整走通、且每一步都看得见的地方。
这篇内容就是围绕“MNIST数据可视化”这个主题展开的。我会从数据加载开始,一路讲到像素级可视化、降维投影、卷积核特征图、误分类样本分析,以及训练过程曲线的解读。适合已经跑过至少一个MNIST分类模型、但想真正“看懂”数据在发生什么的读者。如果你还停留在model.fit()之后只看一个accuracy数字的阶段,那这篇内容应该能帮你打开一扇窗。
需要提前说明的是,可视化不是为了好看,而是为了诊断。一张图能不能告诉你模型哪里出了问题,才是判断它有没有价值的标准。下面所有的代码和思路,都是围绕这个目标来的。
2. 把MNIST从“黑盒数组”变成“看得见的图”
2.1 加载数据时最容易忽略的维度问题
大多数人用框架自带的数据集接口加载MNIST,拿到的是形状为(60000, 28, 28)或(60000, 784)的数组。这里第一个坑就来了:不同框架、不同版本的默认形状不一样。有的返回(N, 28, 28),有的返回(N, 784),还有的返回(N, 1, 28, 28)。如果你直接拿去做可视化,形状不对就会报错或者画出诡异的图。
我的习惯是加载完第一件事就打印形状和数据类型:
import numpy as np from tensorflow.keras.datasets import mnist (x_train, y_train), (x_test, y_test) = mnist.load_data() print(x_train.shape, x_train.dtype, x_train.min(), x_train.max()) # 典型输出: (60000, 28, 28) uint8 0 255看到uint8和0~255这两个信息很关键。uint8意味着像素值是整数,直接做除法会得到浮点数;0~255意味着如果你用plt.imshow()显示,需要指定cmap='gray'并且注意vmin/vmax,否则matplotlib会自动缩放对比度,把一张正常的图显示得忽明忽暗。
我见过有人把uint8数据直接送进某些可视化库,结果因为整数溢出导致像素值回绕,图上一片雪花。所以加载后的第一步永远是:确认形状、确认类型、确认数值范围。
2.2 用网格图快速建立对数据的“手感”
单张图看多了会麻木,真正有效的是把几十张图拼成网格,一次性观察类内差异和类间差异。下面这段代码是我常用的:
import matplotlib.pyplot as plt fig, axes = plt.subplots(4, 10, figsize=(12, 5)) for i, ax in enumerate(axes.flat): ax.imshow(x_train[i], cmap='gray', vmin=0, vmax=255) ax.set_title(str(y_train[i]), fontsize=8) ax.axis('off') plt.tight_layout() plt.show()跑出来你会立刻注意到几件事:同一个数字“1”的写法差异巨大,有的倾斜、有的带钩、有的几乎是一条竖线;数字“7”有的带横杠有的不带;数字“4”有的封口有的开口。这些类内方差正是模型容易出错的地方。如果你只盯着准确率,永远不会意识到“7”和“1”在某些书写习惯下几乎难以区分。
提示:网格图的行列数不要贪多。4×10或5×10足够,再多单张图就小到看不清笔画细节了。我试过10×10,结果每张图只有几十个像素宽,反而失去了观察价值。
2.3 像素级统计:均值图和方差图告诉你的秘密
把60000张图按像素位置求均值和方差,能得到两张28×28的“统计图”。均值图看起来像一个模糊的“平均数字”,方差图则高亮出所有数字变化最剧烈的区域。
mean_img = x_train.mean(axis=0) std_img = x_train.std(axis=0) fig, axes = plt.subplots(1, 2, figsize=(8, 4)) axes[0].imshow(mean_img, cmap='gray') axes[0].set_title('Pixel Mean') axes[1].imshow(std_img, cmap='hot') axes[1].set_title('Pixel Std') for ax in axes: ax.axis('off') plt.show()均值图的意义在于:它告诉你哪些像素区域是“公共背景”。MNIST图像四周大量像素接近0,这些区域对分类几乎没有贡献。方差图则告诉你哪些区域携带了区分信息。中心区域方差高,说明数字的笔画主要集中在那里;边缘方差低,说明那里基本是空白。
这个观察直接引出一个实用技巧:如果你要做数据增强或者裁剪,应该围绕高方差区域进行,而不是盲目地对整张图做变换。我曾经做过一个实验,把方差低于某个阈值的边缘像素全部置零,模型准确率几乎不掉,但输入维度降低了近30%,训练速度明显提升。
3. 降维投影:把784维塞进2维平面
3.1 PCA和t-SNE的选择逻辑
784维的数据人眼无法直接观察,降维到2维或3维是最直观的手段。常用的有PCA和t-SNE,但两者用途完全不同。
PCA是线性方法,追求保留最大方差方向。它的优点是快、可解释、结果稳定。缺点是对于MNIST这种非线性流形,PCA投影后不同类别往往糊成一团。t-SNE是非线性方法,追求保留局部邻域结构,投影后同类样本容易聚成团,但计算慢、参数敏感、每次结果不一样。
我的建议是:先用PCA快速看全局,再用t-SNE看局部聚类。不要一上来就跑t-SNE,60000个点跑一次可能要几分钟到十几分钟,而且如果perplexity参数没调好,出来的图可能毫无意义。
from sklearn.decomposition import PCA pca = PCA(n_components=50) x_pca50 = pca.fit_transform(x_train.reshape(-1, 784)) print(pca.explained_variance_ratio_[:10].sum()) # 通常前50个主成分能解释80%以上的方差注意这里先降到50维而不是直接降到2维。原因是t-SNE在过高维度上效果不好,先用PCA做一次线性降维去噪,再送进t-SNE,效果通常比直接对784维跑t-SNE更好。这是我踩过几次坑之后总结出来的流程。
3.2 t-SNE参数调优的实战经验
t-SNE最关键的参数是perplexity,它大致控制“每个点考虑多少个邻居”。值太小,局部结构碎片化;值太大,全局结构被抹平。对于MNIST,我实测下来perplexity在30到50之间比较稳。
from sklearn.manifold import TSNE tsne = TSNE(n_components=2, perplexity=40, n_iter=1000, random_state=42) x_tsne = tsne.fit_transform(x_pca50[:5000]) # 先取5000个点试这里我特意只取了5000个点。原因很实际:t-SNE的时间复杂度大约是O(N²),60000个点跑起来太慢,而5000个点已经足够看出聚类趋势。如果你非要跑全量,建议用基于Barnes-Hut的近似实现,或者考虑UMAP作为替代。
画图时用类别标签上色:
plt.figure(figsize=(10, 8)) scatter = plt.scatter(x_tsne[:, 0], x_tsne[:, 1], c=y_train[:5000], cmap='tab10', s=5, alpha=0.6) plt.colorbar(scatter, ticks=range(10)) plt.title('t-SNE projection of MNIST (5000 samples)') plt.show()跑出来你会看到大部分类别形成清晰的团簇,但总有一些点“跑错阵营”。这些边界点往往就是模型容易误分类的样本。把它们对应的原图调出来看,通常能发现书写极其潦草或者本身就有歧义的数字。这个“从投影异常点回溯原图”的思路,是我做数据清洗时最常用的手段之一。
3.3 投影图上的误分类叠加分析
如果你已经训练了一个模型,可以把预测错误的样本在t-SNE图上用不同标记标出来。具体做法是:先得到测试集的预测结果,找出误分类索引,然后在投影图上用红色叉号覆盖。
# 假设 y_pred 是模型对 x_test 的预测 mis_idx = np.where(y_pred != y_test)[0] # 在投影图上叠加 plt.scatter(x_tsne[mis_idx, 0], x_tsne[mis_idx, 1], c='red', marker='x', s=30, label='misclassified')如果误分类点集中在某些团簇的交界处,说明模型在这些类别边界上判别力不足;如果误分类点散落在团簇内部,那可能是标签噪声或者模型欠拟合。这两种情况的处理策略完全不同,而投影图能帮你快速区分。
4. 卷积核与特征图:打开CNN的“黑箱”
4.1 第一层卷积核的可视化
训练一个简单的CNN之后,第一层卷积核的权重可以直接当成28×28以下的小图来看。这些核学到的往往是边缘、笔画方向等低级特征。
# 假设 model 的第一层是 Conv2D,权重形状为 (3, 3, 1, 32) weights = model.layers[0].get_weights()[0] fig, axes = plt.subplots(4, 8, figsize=(12, 6)) for i, ax in enumerate(axes.flat): if i < weights.shape[3]: ax.imshow(weights[:, :, 0, i], cmap='gray') ax.axis('off') plt.show()你会看到有些核像竖直边缘检测器,有些像水平边缘检测器,还有些呈现对角线模式。这些模式和人眼视觉系统的初级处理有相似之处,也是CNN能够自动提取特征的一个直观证据。
注意:卷积核权重需要在训练充分之后再看。如果只训练了一两个epoch,核的图案可能还是随机噪声,看不出任何结构。我一般至少训练到验证集准确率稳定后再做这个可视化。
4.2 中间层特征图的提取与观察
比卷积核更有意思的是特征图,也就是输入图片经过某一层之后得到的激活值。它能告诉你“模型在这一层看到了什么”。
from tensorflow.keras.models import Model # 假设我们要看第3层的输出 intermediate = Model(inputs=model.input, outputs=model.layers[3].output) feature_maps = intermediate.predict(x_test[0:1]) fig, axes = plt.subplots(4, 8, figsize=(12, 6)) for i, ax in enumerate(axes.flat): if i < feature_maps.shape[-1]: ax.imshow(feature_maps[0, :, :, i], cmap='viridis') ax.axis('off') plt.show()不同通道的特征图会对不同的笔画区域产生高响应。有的通道对数字的上半部分敏感,有的对闭合区域敏感。把这些特征图和你输入的原图对照着看,能建立起“输入像素—中间表示—最终分类”的直觉。
我个人的经验是:浅层特征图分辨率高、语义弱,深层特征图分辨率低、语义强。如果你看到某一层的特征图几乎全黑或者全白,说明那一层可能出现了“死亡ReLU”或者梯度问题,这是诊断网络健康度的好方法。
4.3 用Grad-CAM定位模型的“注意力”
Grad-CAM是一种更高级的可视化方法,它能把模型做决策时“关注”的区域高亮出来。对于MNIST,你可以看到模型判断一个数字是“3”时,到底看的是哪几个笔画。
import tensorflow as tf def grad_cam(model, img, layer_name): grad_model = Model(inputs=model.input, outputs=[model.get_layer(layer_name).output, model.output]) with tf.GradientTape() as tape: conv_out, preds = grad_model(img[np.newaxis, ...]) pred_class = tf.argmax(preds[0]) loss = preds[:, pred_class] grads = tape.gradient(loss, conv_out) pooled = tf.reduce_mean(grads, axis=(0, 1, 2)) heatmap = tf.reduce_sum(conv_out[0] * pooled, axis=-1) heatmap = tf.maximum(heatmap, 0) / tf.reduce_max(heatmap) return heatmap.numpy()把热力图叠加到原图上,你会看到模型判断数字时高亮的区域。如果高亮区域落在数字主体上,说明模型学到了合理特征;如果高亮区域落在边缘空白处,那就要警惕了,模型可能学到了某种虚假相关。
我做过一个对比实验:正常训练的模型,Grad-CAM高亮区域集中在笔画上;而一个用错误标签训练过的模型,高亮区域明显偏移到了图像边缘。这个对比非常直观地说明了可视化在诊断模型问题上的价值。
5. 误分类样本的深度剖析
5.1 混淆矩阵:先看整体错在哪
准确率只给一个总数,混淆矩阵才能告诉你错在哪些类别之间。用sklearn的confusion_matrix配合热力图:
from sklearn.metrics import confusion_matrix import seaborn as sns cm = confusion_matrix(y_test, y_pred) plt.figure(figsize=(8, 6)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues') plt.xlabel('Predicted') plt.ylabel('True') plt.show()MNIST上最常见的混淆对通常是4/9、3/5、7/1。看到这些数字后,不要急着调模型,先把这些混淆对的原图调出来看。很多时候你会发现,人类自己看那些图也分不清。
5.2 把误分类样本按置信度排序
模型预测错误时,有的错得“离谱”(置信度很低),有的错得“自信”(置信度很高)。后者更值得关注,因为高置信度的错误往往意味着数据本身有问题或者模型学到了错误模式。
probs = model.predict(x_test) mis_idx = np.where(y_pred != y_test)[0] mis_conf = probs[mis_idx, y_pred[mis_idx]] sorted_idx = mis_idx[np.argsort(-mis_conf)] fig, axes = plt.subplots(2, 10, figsize=(15, 4)) for i, ax in enumerate(axes.flat): idx = sorted_idx[i] ax.imshow(x_test[idx], cmap='gray') ax.set_title(f'T:{y_test[idx]} P:{y_pred[idx]}\n{mis_conf[i]:.2f}', fontsize=7) ax.axis('off') plt.show()把置信度最高的误分类样本排在前面,你会看到一些非常有意思的案例。有些图确实写得模棱两可,有些则可能是标注错误。我曾在一次实验中发现了十几张标签疑似错误的样本,手动修正后模型准确率提升了0.3个百分点。这个提升幅度在MNIST上已经不小了。
5.3 从误分类反推数据增强策略
观察误分类样本的共性,可以直接指导数据增强。比如如果模型经常把倾斜的“1”误判为“7”,那就可以在训练时加入随机旋转;如果经常把细笔画的“8”误判为“3”,那就可以加入随机膨胀或腐蚀。
from tensorflow.keras.preprocessing.image import ImageDataGenerator datagen = ImageDataGenerator( rotation_range=10, width_shift_range=0.1, height_shift_range=0.1, zoom_range=0.1 )但要注意,增强幅度不能太大。MNIST的数字本身已经居中且尺寸统一,过度旋转或缩放反而会引入不真实的样本。我一般把旋转控制在±10度以内,平移控制在10%以内。这个范围是我对比了多组实验后确定的,再大就会开始损害准确率。
6. 训练过程曲线的正确读法
6.1 损失曲线和准确率曲线的联合诊断
很多人只看验证准确率,但损失曲线往往包含更多信息。下面是我常用的双轴曲线:
fig, axes = plt.subplots(1, 2, figsize=(12, 4)) axes[0].plot(history.history['loss'], label='train') axes[0].plot(history.history['val_loss'], label='val') axes[0].set_title('Loss') axes[0].legend() axes[1].plot(history.history['accuracy'], label='train') axes[1].plot(history.history['val_accuracy'], label='val') axes[1].set_title('Accuracy') axes[1].legend() plt.show()几种典型模式:
| 曲线形态 | 含义 | 处理方向 |
|---|---|---|
| 训练损失降,验证损失先降后升 | 过拟合 | 加正则、加Dropout、早停 |
| 两条损失都高且平 | 欠拟合 | 加层、加宽度、调学习率 |
| 训练准确率高,验证准确率低很多 | 泛化差 | 数据增强、简化模型 |
| 损失出现剧烈震荡 | 学习率过大 | 降低学习率或加衰减 |
6.2 学习率与batch size对曲线的影响
学习率是最关键的超参数。太大,损失震荡甚至发散;太小,收敛慢且容易卡在局部极小。我习惯用学习率扫描来快速确定范围:从1e-1到1e-5各跑几个epoch,看哪个量级的损失下降最快且不震荡。
batch size的影响则更微妙。小batch(如32)梯度噪声大,有时反而有助于跳出尖锐极小值,泛化更好;大batch(如256)梯度稳定,训练快,但可能需要配合更高的学习率。我在MNIST上的经验是:batch size 64到128之间比较平衡,再小训练太慢,再大泛化略差。
6.3 用可视化判断是否“训练过头”
一个实用的技巧是:每隔几个epoch保存一次模型,然后对比不同阶段的误分类样本。如果后期模型开始把一些原本正确的、书写清晰的样本判错,那说明过拟合已经发生,模型在拟合训练集中的噪声了。
我通常会保存epoch 5、10、20、50的模型,分别计算它们在测试集上的混淆矩阵。如果epoch 50的混淆矩阵在某些类别上反而比epoch 20更差,那就是过拟合的明确信号。这种“时间维度上的可视化对比”比单看一条曲线更有说服力。
7. 一些踩过坑之后才明白的事
第一个坑是关于归一化的。很多人做可视化时用原始0~255的像素值,做训练时用0~1的归一化值,结果可视化的图和模型看到的图不一致,导致分析结论对不上。我的做法是:可视化时也用归一化后的数据,只是显示时通过vmin=0, vmax=1来还原对比度。这样保证“看到的”和“模型吃进去的”是同一份数据。
第二个坑是关于随机种子的。t-SNE、PCA的随机初始化、模型的权重初始化,都会影响可视化结果。如果你发现每次跑出来的投影图差异很大,先检查随机种子有没有固定。我在做对比实验时,会把所有能固定的种子都固定,确保差异来自模型本身而不是随机性。
第三个坑是关于颜色映射的。灰度图用cmap='gray'没问题,但特征图用灰度往往看不出差异,换成viridis或hot会清晰很多。另外,imshow默认会做归一化,如果你想让不同图之间可比,一定要手动指定vmin和vmax。我吃过这个亏:两张特征图看起来亮度差不多,实际上数值范围差了一个量级,因为matplotlib各自做了归一化。
第四个坑是关于内存的。60000张28×28的uint8数组大约47MB,不大。但如果你把它转成float64,就变成376MB;如果再复制几份做增强,很容易把内存吃满。我的习惯是:原始数据保持uint8,只在送进模型的batch上做归一化,可视化时按需转换。这样既省内存,又避免精度问题。
最后一个体会是关于可视化的目的。我见过很多人把可视化做得很漂亮,配色讲究、布局精致,但看完之后说不出模型哪里有问题。这就本末倒置了。可视化的第一目标是诊断,第二目标才是展示。每次画图之前先问自己:我想通过这张图回答什么问题?如果回答不了,那这张图就不值得画。
MNIST虽然小,但把它吃透之后,你面对更大的数据集和更复杂的模型时,会有一套完整的“观察—假设—验证”的方法论。这套方法论比任何单个模型的准确率都值钱。