先问一个扎心的问题:你在 MNIST 测试集上跑出了 99% 的准确率,然后自己用画图工具写了个数字,丢进模型一测,它居然信心满满地告诉你那是另一个数。这个场景我见过太多次了,包括我自己第一次跑 LeNet 时也没逃过去。
这个项目看标题很简单——“训练 LeNet 做 Mnist 手写体数字识别并测试自己的图片”,但它真正的分水岭其实不在训练,而在最后一步:怎么把你的随手涂鸦,变成模型能看懂的“MNIST风格”图片。本篇会把整个流程走一遍,从网络结构设计、数据预处理、训练配置,再到自己画图的完整测试链路,每一步都给出能直接跑的代码和解释。适合刚接触 TensorFlow 2.0、想认真跑通一个完整 CNN 项目的人,也适合那些已经训练成功但卡在“测试自己图片”这一步的朋友。
1. 为什么一个“旧网络”LeNet 至今仍是入门首选
1.1 这个项目真正让你练的是什么
很多人觉得 MNIST 是“玩具数据集”,LeNet 是“古董网络”,练完没啥含金量。我不这么看。这个组合之所以经典,恰恰在于它用最小的成本,覆盖了深度学习项目里最完整的闭环:数据加载与预处理 → 网络搭建 → 模型训练 → 性能评估 → 模型导出 → 外部数据推理。前面几步你随便找段代码复制就能跑,但最后一步——用一张自己画的图喂给模型——几乎把所有容易踩的坑都暴露出来了。
从工程角度看,这才是真正的价值所在。你会被迫去理解:模型的输入张量到底是什么形状、灰度图和 RGB 图的区别、归一化到底在做什么、softmax 输出的含金量有多高。这些理解,是后面做任何图像识别项目都要用的底层能力。
1.2 LeNet-5 的结构拆解:每个层到底在干什么
LeNet-5 是 LeCun 在 1998 年提出的卷积网络结构,虽然年代久远,但它的设计思想今天依然是主流 CNN 的骨架。我们先看它在 MNIST 上最常用的变体结构:
| 层 | 输出尺寸 | 参数说明 |
|---|---|---|
| 输入 | 28×28×1 | MNIST 是单通道灰度图 |
| Conv1 | 28×28×6 | 5×5 卷积核,输出 6 个特征图,padding='same' |
| MaxPool1 | 14×14×6 | 2×2 池化,步长 2 |
| Conv2 | 10×10×16 | 5×5 卷积核,输出 16 个特征图,padding='valid' |
| MaxPool2 | 5×5×16 | 2×2 池化,步长 2 |
| Flatten | 400 | 5×5×16 拉直 |
| Dense1 | 120 | 全连接层 |
| Dense2 | 84 | 全连接层 |
| Dense3 | 10 | 输出层,softmax 分类 |
这里有两个地方需要解释。
第一个:为什么卷积层要用小卷积核、多特征图?5×5 的卷积核在 28×28 的小图上,感受野已经不小了,能提取到笔画级别的局部模式。第一层 6 个特征图学会的是各种边缘、角点;第二层 16 个特征图在第一层基础上组合出更高阶的模式,比如弧线、交叉点。这就是 CNN 的层次化特征提取——底层学局部细节,高层学全局结构。
第二个:原始 LeNet 输入是 32×32,为什么这里直接 28×28?因为 MNIST 原图就是 28×28,直接作为输入可以省去一次 Resize。但要注意,输入尺寸变了,卷积的 padding 策略就要调整。第一层用 padding='same' 保持分辨率不缩水,第二层用 padding='valid' 让特征图从 10×10 变成 5×5,这是为了让后续全连接层的参数量可控。如果你直接把原版结构套上来,不改 padding,到 Flatten 那一步维度就对不上了。
1.3 训练只算完成了一半,测自己的图才是完整的项目
我见过太多人在训练上花了很多心思,结果一到“用自己图片测试”就翻车。翻车场面基本有两种:一是模型把所有图片都识别成同一个数字,二是对什么图都给出极低的置信度。这两种情况,绝大多数不是模型的问题,是输入图片根本没被正确处理。
这个项目最有意思的地方就在这里。MNIST 的图片和你在画图工具里画出来的数字,看似都是“手写数字”,实际差异非常大。后文第 4 章我会专门拆解这条链路,那才是这个项目的重头戏。
2. 数据准备:把 MNIST 变成模型认识的张量
2.1 MNIST 数据集的真实格式
MNIST 由 60000 张训练图片和 10000 张测试图片组成,每张是 28×28 像素的单通道灰度图。TensorFlow 加载后,数据实际上是 uint8 类型,像素值范围在 0 到 255 之间。这个格式不能直接进网络,必须做两个操作:升维和归一化。
import tensorflow as tf # 第一次跑会自动下载,大概 100 多 MB (x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data() # 关键:单通道灰度图要变成 (28, 28, 1),而不是 (28, 28) 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注意.reshape(-1, 28, 28, 1)里的-1,它的意思是“这个维度由数据总数自动推导”。你写成(60000, 28, 28, 1)也可以,但用-1更省心。为什么必须 reshape 成四维?因为 TensorFlow 的 Conv2D 层要求输入是(batch, height, width, channels),哪怕只有一张图,也要带一个 batch 维度。后面测自己的图片时,这个维度是最容易漏的。
2.2 归一化为什么不能省
归一化(除以 255 把像素缩到 0~1)在很多初学者看来是“可有可无的预处理”,实际上它直接影响训练能不能收敛。神经网络中的激活函数(尤其是 ReLU 和 sigmoid)对输入数值范围很敏感,大数值输入会导致梯度剧烈波动。你可以做一个对比实验:一组不归一化直接训,一组归一化后训,同样的轮数下,归一化组的 loss 下降速度快得多,最终准确率也更高。
提示:如果你想让模型更稳,还可以做标准化(减均值再除标准差),但在 MNIST 这种本身就比较规整的数据集上,简单除 255 已经足够了,没必要过度设计。
2.3 Batch 和 Shuffle:训练数据怎么喂给模型
训练时通常不会把全部数据一次性塞进模型,而是分成小批次(batch)。常见选择是 32、64 或 128。批次大小影响的是梯度更新的频率和稳定性:太大收敛慢且吃内存,太小则梯度噪声大。MNIST 这种 60000 张的小数据集,64 是比较均衡的选择。
shuffle(打乱顺序)也很重要。如果不打乱,模型会按标签顺序学习,前几千张全是 0,后几千张全是 1,训练过程会出现明显的阶段性问题。用 TensorFlow 的DatasetAPI 可以把这两个操作串起来:
# 转为 tf.data.Dataset,方便管理 batch 和 shuffle train_ds = tf.data.Dataset.from_tensor_slices((x_train, y_train)) train_ds = train_ds.shuffle(10000).batch(64) test_ds = tf.data.Dataset.from_tensor_slices((x_test, y_test)).batch(64)shuffle(10000)的参数是缓冲区大小,意思是 TensorFlow 会先填 10000 个样本进缓冲区,然后从中随机抽取,保证打乱效果。缓冲区太小的话,打乱效果会打折扣。
3. 搭建 LeNet 并完成训练:完整脚本与核心参数
3.1 用 Sequential 搭建 LeNet
TensorFlow 2.0 有 Keras 这个高层 API,搭建 LeNet 这样的结构化网络非常快。下面这段代码可以直接复制运行:
from tensorflow.keras import models, layers model = models.Sequential([ # 第一卷积块:6 个 5×5 卷积核,same 保持尺寸,池化减半 layers.Conv2D(6, (5, 5), activation='relu', padding='same', input_shape=(28, 28, 1)), layers.MaxPooling2D((2, 2)), # 第二卷积块:16 个 5×5 卷积核,valid 让尺寸缩到 10×10 layers.Conv2D(16, (5, 5), activation='relu', padding='valid'), layers.MaxPooling2D((2, 2)), # 展平后接入全连接层 layers.Flatten(), layers.Dense(120, activation='relu'), layers.Dense(84, activation='relu'), layers.Dense(10, activation='softmax') ]) model.summary()model.summary()会打印每层的输出形状和参数量,建议务必看一眼,尤其注意 Flatten 后是不是 400 维、Total params 是不是在 6 万左右。如果维度对不上,八成是卷积 padding 写错了。
有个细节需要说明:原始 LeNet 用的激活函数是 tanh,这里换成了 ReLU。原因是 ReLU 计算简单、梯度消失问题轻,现代 CNN 基本默认用它。你也可以换成 tanh 试试,会发现收敛速度明显变慢,这就是为什么教程普遍改用 ReLU。
3.2 优化器、损失函数和评估指标的选择逻辑
编译模型这一步看似只是选几个名字,实际上每个选择背后都有讲究。
model.compile( optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'] )损失函数为什么用 sparse 版本?因为 y_train 是整数标签(0~9),不是 one-hot 向量。sparse_categorical_crossentropy会自动把整数标签转换成 one-hot 参与计算,省去你手动转换的步骤。如果你非要用categorical_crossentropy,就得先tf.keras.utils.to_categorical(y_train, 10),多一步操作,没必要。
优化器为什么用 Adam?简单说,Adam 自带自适应学习率,对新手极其友好。传统 SGD 需要手动调学习率、动量等一堆超参,Adam 基本开箱即用。下表是我在同样条件下跑过的对比:
| 优化器 | 学习率 | 5 轮后准确率 | 10 轮后准确率 |
|---|---|---|---|
| SGD (momentum=0.9) | 0.01 | 96.2% | 98.1% |
| Adam | 0.001 | 98.4% | 99.0% |
| RMSprop | 0.001 | 98.1% | 98.9% |
数据来自我本机多次运行的典型结果,会有一点随机波动,但 Adam 在前中期的优势非常稳定。如果你想把准确率再往上推,可以后期把优化器换成 SGD 继续精调,这个进阶操作后面再说。
3.3 训练与保存:一轮轮观察 loss 曲线
history = model.fit( train_ds, epochs=10, validation_data=test_ds ) # 保存整个模型,后面测试直接用 model.save('lenet_mnist.h5')训练过程中你可以同时看到训练集和测试集的 loss、accuracy。重点观察两个信号:
- 训练集准确率很高但测试集明显偏低:说明过拟合了,可以加 Dropout 或数据增强。
- 训练集和测试集准确率都在逐步上升:说明模型还在正常学习,不要急着提前终止。
训练到 10 轮左右,测试集准确率一般在 99% 上下,这个成绩对 LeNet + MNIST 来说是正常的。如果只有 97% 左右,建议检查一下数据是否归一化、结构有没有写错。
训练完成后你手上会有两个东西:history对象(记录了每一轮的指标)和lenet_mnist.h5模型文件。前者用于画 loss 曲线,后者用于接下来的重头戏——测试自己的图片。
4. 把自己的图片变成“MNIST 风格”:这里全是坑
4.1 第一个坑:颜色反转
模型训练时看到的数字是白字黑底(像素值 255 是白色背景,0 是黑色笔画)。而你在画图工具里写个数字,通常是黑字白底。如果你直接把黑字白底的图喂进去,模型看到的完全是反过来的画面,性能自然一塌糊涂。
处理办法:
from PIL import Image import numpy as np # 读取图片,转成灰度 img = Image.open('my_digit.png').convert('L') img = img.resize((28, 28), Image.Resampling.BICUBIC) # 转成 numpy 数组,并做颜色反转 img_array = np.array(img) img_array = 255 - img_array # 黑底白字 / 白底黑字 转换255 - img_array这一行就是反转操作。如果你的图片已经是黑底白字(比如从显示器截图反色过),这步就跳过。
4.2 第二个坑:图片尺寸与比例
MNIST 的数字虽然尺寸是 28×28,但数字本身经过了一定的居中和缩放处理,笔画有比较一致的粗细和位置。你自己画的图通常尺寸随意、笔画粗细随意,直接缩放到 28×28 会出现问题。
img = img.resize((28, 28), Image.Resampling.BICUBIC)这里BICUBIC是双三次插值,缩放质量比默认的NEAREST(最近邻)好得多。你画的数字如果是 500×500 缩到 28×28,双三次插值能保留更多笔画边缘信息,但依然会损失细节,所以画的时候尽量让数字线条粗一点、占满画面,缩完之后效果才会好。
有个细节:MNIST 的数字基本都是居中的,所以你画图时也尽量把数字放在画布中央,避免模型识别时“找不到重点”。
4.3 第三个坑:归一化和维度对齐
做完颜色反转和缩放,图片已经是一张 28×28 的灰度图了,接下来的操作和训练时的预处理必须保持一致:
# 归一化到 0~1 img_array = img_array.astype('float32') / 255.0 # 升维到 (1, 28, 28, 1) img_array = img_array.reshape(1, 28, 28, 1)第一个 reshape 容易漏。模型训练时输入是四维的,你喂一张二维图它肯定会报错。第二个容易错的地方是astype('float32')。如果没有这一步,数组类型是 uint8,除以 255 时会按整数除法计算,结果全是 0 或 1,图片信息会严重丢失。
4.4 真正的推理:从模型输出读到预测结果
# 预测 predictions = model.predict(img_array) # predictions 形状是 (1, 10),每一列是对应数字的概率 predicted_label = np.argmax(predictions[0]) confidence = np.max(predictions[0]) print(f"预测结果: {predicted_label}, 置信度: {confidence:.4f}")softmax 输出的 10 个值加起来等于 1,你可以把它理解成模型对每个数字的“信心分布”。argmax取最大值位置作为最终预测。
这里我想多说一句:不要只盯着 argmax 的结果,一定要看置信度。如果预测“3”的置信度只有 0.4,说明模型其实很犹豫,这张图可能真的画得不太标准。置信度 0.9 以上才算是模型比较有把握的判断。遇到置信度偏低的图,别急着怪模型,先看看图是否处理得当。
5. 实测:我的图片识别表现与完整排查思路
5.1 画图测试遇到的最典型问题
我把这个流程完整走了一遍,找了几种典型的图片输入,记录下测试结果:
| 我画的图 | 实际数字 | 预测结果 | 置信度 | 问题分析 |
|---|---|---|---|---|
| 线条较粗、居中 | 7 | 7 | 0.96 | 正常 |
| 线条很细、偏左 | 7 | 3 | 0.61 | 细笔画 + 偏移,特征被截断 |
| 用鼠标写得歪斜 | 2 | 7 | 0.52 | 形变过大,接近其他类别的模式 |
| 颜色没反转 | 5 | 3 | 0.33 | 白底黑字直接输入,模型看到的完全是噪点 |
第一行是理想情况,后面几行才是常态。尤其是最后一种“颜色没反转”的情况,模型还能给个 0.33 的置信度,说明它其实是在拿“噪声特征”硬凑。
5.2 从“预测错误”反推:是图的问题还是模型的问题
遇到预测不对时,建议先用一个方法快速定位:把预处理后的图保存下来看看。
# 保存预处理后的图片,确认处理后长什么样 processed = (img_array[0, :, :, 0] * 255).astype('uint8') Image.fromarray(processed).save('processed.png')打开processed.png,如果你自己都看不出这是个几,那模型当然也认不出。这时候问题不在模型,在你的原图质量或预处理流程。如果预处理后的图你一眼就能认出数字,但模型认错了,那才值得怀疑模型本身。
模型本身的问题一般是过拟合或欠拟合,这个可以通过训练曲线判断。如果是过拟合,除了加 Dropout,还可以做简单的数据增强——随机旋转、平移等。MNIST 数字的形变空间不大,旋转 10 度以内,平移 2 个像素内的增强就够用。
5.3 让测试更健壮的几个方法
这里分享几个我实际操作下来很有用的技巧,属于常规教程不会细讲的部分。
方法一:二值化 + 膨胀。你自己画的图可能带灰色抗锯齿边缘,直接缩到 28×28 会引入不少噪声。可以先用阈值把图片变成纯黑白的二值图,再做一次膨胀(让笔画变粗),模拟 MNIST 里数字笔画比较实的效果:
from PIL import ImageOps # 阈值二值化:大于 128 看成白,否则黑 img = img.point(lambda p: 255 if p > 128 else 0) # 膨胀加粗笔画 img = img.filter(ImageFilter.MaxFilter(3))MaxFilter(3)是一种简单的形态学膨胀操作,会让笔画边缘向外扩一圈。对细线条的图效果特别明显,能大幅提升识别率。
方法二:检查像素分布占比。预处理后可以算一下非零像素占整图的比例:
non_zero_ratio = np.count_nonzero(img_array[0, :, :, 0]) / (28*28)MNIST 正常数字的非零像素占比大概在 10% 到 30% 之间。如果你画的图处理后占比只有 2%,说明笔画太细了;如果超过 50%,说明笔画糊成一团了。这个指标能帮你快速判断图片是否“健康”。
方法三:批量测试模拟自己画的多种风格。你可以用代码批量生成不同粗细、不同偏移的测试图,看看模型在哪些情况下开始出错,这比一张张画高效得多。
6. 训练脚本的完整形态与后续扩展
完整把上面各段的代码串起来,一个可直接运行的训练脚本长这样:
import tensorflow as tf from tensorflow.keras import models, layers import numpy as np # 1. 加载数据 (x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data() # 2. 预处理 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 # 3. 构建 LeNet model = models.Sequential([ layers.Conv2D(6, (5, 5), activation='relu', padding='same', input_shape=(28, 28, 1)), layers.MaxPooling2D((2, 2)), layers.Conv2D(16, (5, 5), activation='relu', padding='valid'), layers.MaxPooling2D((2, 2)), layers.Flatten(), layers.Dense(120, activation='relu'), layers.Dense(84, activation='relu'), layers.Dense(10, activation='softmax') ]) # 4. 编译 model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy']) # 5. 训练 history = model.fit(x_train, y_train, batch_size=64, epochs=10, validation_data=(x_test, y_test)) # 6. 保存 model.save('lenet_mnist.h5')这个脚本把前面用 Dataset 的写法简化成了直接传 numpy 数组,Keras 也支持。两种方式都行,用 Dataset API 更正规,适合后续扩展;直接传数组更直观,适合快速验证。
跑完训练后想继续深入,有几个方向可以考虑。一是做一次模型结构对比,把 LeNet 换成更现代的 ResNet 或 MobileNet 的简化版,看看参数量和准确率的差异。二是尝试模型量化,把 h5 模型转成 TFLite,然后部署到手机或嵌入式设备上跑推理。三是做模型可视化,把第一层卷积核的权重画出来看看它学到了什么样的边缘检测器,再对中间层特征图可视化,观察不同层在提取什么信息。任何一个方向都能写成一篇新文章,核心基础就是你这篇已经跑通的 LeNet 项目。
最后分享一个我自己的习惯:做完这个项目后,我会专门保留几张自己手写的、模型识别错误的图片,作为后续改进模型的“私人测试集”。因为 MNIST 的官方测试集很规整,哪怕你的训练流程有小毛病,官方测试集上也不一定能暴露出来,但你自己画的那些歪歪扭扭的数字,反而能成为衡量模型真实泛化能力的标尺。多留几份这样的样本,对理解模型的边界特别有帮助。