资讯动态

TensorFlow 2.0实现LeNet手写数字识别:从训练到预测完整实战

发布时间:2026/10/13 19:38:46 来源:尧图企业网站定制
最近一直在帮朋友调图像识别的入门项目聊到最后发现大家真正想要的不只是把一段示例代码跑通而是想亲手训练一个网络再把自己写的数字丢进去看它认不认得出来。LeNet MNIST TensorFlow 2.0 这三样东西组合在一起恰好能在一个晚上把“训练模型”和“预测自己的图片”这条完整闭环走通。这篇文章就基于我完整的实操过程把每一环节的思路、代码、参数选择以及预测自己图片时那些文档里不会明说的细节都整理出来适合刚接触深度学习、想交付自己第一个图像识别项目的同学。说实话MNIST 这一个月经式入门数据集放在 2025 年看数据量不算大但它足够让一个新手理解 CNN 的核心流程卷积层怎么提取特征池化层怎么压缩尺寸全连接层怎么把特征映射成类别概率。很多做算法岗位多年的朋友也承认自己最早建立模型直觉就是从复现 LeNet 开始的。所以我这篇不是简单贴代码而是把为什么这么做、遇到问题怎么排查也讲了你可以直接照着做也可以拿去做教学材料。1. 项目整体思路与方案选择1.1 为什么选 LeNet MNIST 作为入门组合选择这个组合不是因为它新恰恰是因为它“经典而且稳”。MNIST 数据集包含 6 万张训练图片和 1 万张测试图片每张都是 28x28 的灰度图数字从 0 到 9 共 10 类。6 万张图片听起来不小但对一张 28x28 的小图来说单张只有 784 个像素点即便用普通 CPU 跑几个 epoch也能在半小时内完成一轮训练。这比动辄几百 MB 的 ImageNet 子集要友好太多也比那些需要预训练权重的迁移学习任务更容易让新手理解模型的内部行为。LeNet-5 是卷积神经网络的开山之作它的结构非常紧凑两层卷积、两层池化、三层全连接总参数量不过几万。现代网络动辄几十上百层参数上千万新手很难搞清楚每一层到底改变了什么。但 LeNet 每一层的输出尺寸变化你都能手算出来甚至可以用纸笔画一遍数据流的形状变化。这种“可控感”是入门阶段最需要的东西。我觉得这个组合还有一个隐藏优势后续可以无缝迁移到其他图像分类任务。你只要换掉数据加载和输入尺寸LeNet 的结构可以继续复用到简单 OCR、验证码识别、小规模工件表面瑕疵分类等等。先打好这个底子后面看更复杂的模型才不会一头雾水。1.2 技术栈与版本选型说明项目标题写的是 TensorFlow 2.0所以我默认用 TensorFlow 2.x 版本的 tf.keras 接口来写。之前有个朋友非要用 TF 1.x 的 placeholder 和 session绕了几十行代码才搭出训练流程最后换到 2.x 一身轻松。我的建议是如果电脑没有历史包袱直接装 2.x 之后的小版本比如 2.10 左右代码风格全部走 Keras 的 Sequential 或函数式 API易读性高调试也方便。版本对比可以简单列一下方案优点缺点适合场景TensorFlow 1.x Session历史资料多代码模板化写起来繁琐维护老代码TensorFlow 2.x tf.keras简洁直观动态图便于调试部分老教程不兼容新项目、学习、快速迭代独立 Keras 包轻量与 TF 底层结合不如 tf.keras 紧密偏好纯 Keras 生态的人选 tf.keras 还有一个很实际的原因数据集加载、模型保存、预处理工具全部集成在 TensorFlow 生态内不需要额外装一堆包也不容易出现版本错配。后面加载模型做推理时直接调用tf.keras.models.load_model就行。Python 版本建议用 3.7 到 3.9太新的版本有时候会遇到预编译 wheel 不匹配的情况太老的版本又跟不上依赖库的更新。如果你用 Anaconda可以单独建一个虚拟环境避免把系统 Python 搞乱。这是我连续吃了几次环境冲突的亏之后养成的习惯。2. 环境准备与数据预处理2.1 安装依赖与验证环境创建一个干净的 Python 环境然后安装 TensorFlow。在命令行执行pip install tensorflow2.10如果你想用 GPU 加速还需要安装对应 CUDA 和 cuDNN。这里有个经验GPU 版本虽然训练速度快但环境配置时间往往远超训练时间本身。如果只是跑 MNISTCPU 完全够用第一次做实验不要跟自己过不去。等把流程跑通了再考虑 GPU 也不迟。安装完成之后可以快速验证环境import tensorflow as tf print(tf.__version__) print(tf.keras.__version__) print(tf.config.list_physical_devices(GPU))能正常输出版本和硬件信息环境就算准备好了。到这里别急着写模型先想想数据流是什么样我对新手一贯的建议是先画一个简单流程原始图片 - 灰度图 - 归一化 - 模型 - 类别概率 - 数字标签。LZ在整个调试过程中反复对照这个流程会节省大量时间。2.2 MNIST 数据集的加载与标准化TensorFlow 已经把 MNIST 打包好了不需要手动下载文件直接调用from tensorflow.keras.datasets import mnist (x_train, y_train), (x_test, y_test) mnist.load_data()执行时如果网络不方便可能会卡在下载环节。这时候手动下载数据集放到~/.keras/datasets目录下也是可以的文件是公开的mnist.npz你只需要确认路径名称正确。说回数据本身x_train和x_test的形状分别是(60000, 28, 28)和(10000, 28, 28)像素值范围是 0 到 255其中 0 表示纯黑背景255 表示白色的笔迹。后面预测自己的图片时这个“黑底白字”的背景关系特别关键很多人就在这里翻车。标准化处理非常简单x_train x_train.astype(float32) / 255.0 x_test x_test.astype(float32) / 255.0这样做的好处是把像素值压缩到 0 到 1 之间避免数值太大导致梯度爆炸也能让模型收敛更稳定。如果用均值方差标准化效果也差不多但 MNIST 像素分布比较简单直接除 255 就够了。2.3 调整输入形状与构建数据管道卷积神经网络要求输入包含通道维度灰度图只有一个通道。MNIST 原始数据是二维矩阵所以需要加一个维度x_train x_train.reshape((-1, 28, 28, 1)) x_test x_test.reshape((-1, 28, 28, 1))-1表示让程序自动推断样本数最终变成(60000, 28, 28, 1)。这一步看起来不起眼但如果你漏了它模型编译时就会报维度不匹配的错误。为了训练效率我会用tf.data构建一个简洁的数据管道batch_size 128 train_dataset tf.data.Dataset.from_tensor_slices((x_train, y_train)) train_dataset train_dataset.shuffle(10000).batch(batch_size).prefetch(tf.data.AUTOTUNE) test_dataset tf.data.Dataset.from_tensor_slices((x_test, y_test)) test_dataset test_dataset.batch(batch_size).prefetch(tf.data.AUTOTUNE)shuffle会把数据打乱避免模型学到样本顺序上的假规律batch把数据打包成一批一批喂给模型prefetch让 CPU 在 GPU/CPU 训练的同时提前准备下一批数据减少等待时间。如果你觉得这套写法有点绕也可以直接用model.fit(x_train, y_train, batch_size128)效果同样没问题。但了解数据管道对后续处理大数据集有帮助我还是建议花十分钟理解它。3. LeNet 模型搭建与核心参数解读3.1 LeNet-5 网络结构逐层拆解原始 LeNet-5 是为 32x32 输入设计的MNIST 是 28x28也能直接用。我先给出一份基于 Keras 描述的结构表格每一层的输出尺寸都很容易手算。层类型核/单元数输出尺寸说明输入Input-28x28x1灰度图单通道Conv1卷积6 个 5x5 核paddingvalid24x24x6提取边缘与纹理Pool1最大池化2x2步长212x12x6减小尺寸增强平移不变性Conv2卷积16 个 5x5 核paddingvalid8x8x16提取更高层特征Pool2最大池化2x2步长24x4x16压缩到 4x4 空间Flatten展平-256把特征图拉成向量Dense1全连接120 个单元120特征组合Dense2全连接84 个单元84特征组合Output全连接Softmax10 个单元10输出类别概率为什么第一层卷积要用 6 个核而不是 3 个或 10 个这是历史经验叠加实验的结果。6 个核意味着提取 6 种不同特征比如横线、竖线、斜线、角点等等特征图数量太少会欠拟合太多又会让参数量膨胀。第二层增加到 16 个核是因为在更高层特征更抽象需要用更多通道来组合表达。3.2 用 TensorFlow 2.0 Keras 实现 LeNet直接上代码使用 Sequential 模型model tf.keras.Sequential([ tf.keras.layers.Conv2D(6, (5, 5), activationrelu, input_shape(28, 28, 1), paddingvalid), tf.keras.layers.MaxPooling2D(pool_size(2, 2), strides2), tf.keras.layers.Conv2D(16, (5, 5), activationrelu, paddingvalid), tf.keras.layers.MaxPooling2D(pool_size(2, 2), strides2), tf.keras.layers.Flatten(), tf.keras.layers.Dense(120, activationrelu), tf.keras.layers.Dense(84, activationrelu), tf.keras.layers.Dense(10, activationsoftmax) ])原始 LeNet 用的是 tanh 激活函数我换成 ReLU。原因很直接tanh 在深层网络中容易饱和梯度消失风险比 ReLU 高ReLU 计算简单负数部分直接置零能加快收敛。MNIST 这种简单任务换 ReLU 很安全。可能有同学会问为什么最后用 softmax因为 softmax 能把 10 个神经元的输出变成一组和为 1 的概率比如[0.01, 0.02, 0.9, ...]这样我们不仅能得到“模型认为它是数字几”还能得到“模型有多确定”。这个置信度在测试自己图片时非常有用。3.3 损失函数、优化器与评估指标的选择模型编译这一步是很多新手容易搞混的地方核心有三个选择。损失函数方面如果标签是整数形式比如y_train [5, 0, 4, ...]就选sparse_categorical_crossentropy如果标签已经变成 one-hot 向量比如数字 5 是[0,0,0,0,0,1,0,0,0,0]就选categorical_crossentropy。MNIST 默认标签是整数所以我用前者。用错会直接报错这个很容易排查。优化器我选 Adam学习率默认 0.001。相比传统 SGDAdam 自带自适应学习率对新手友好不需要手动调整太多超参数。如果你想让训练更“古典”一点也可以试 SGD 加动量但对 MNIST 来说 Adam 足够。评估指标选accuracy。它计算的是分类正确的样本比例直观易懂。model.compile( optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy] )第一次跑这个项目的时候我建议你打印一下model.summary()看看每一层参数数量确认形状变化和预期一致。这一步能提前暴露很多维度错误避免训练到一半才发现问题。4. 模型训练与结果分析4.1 训练超参数设置与 fit 执行MNIST 这种规模常见配置是epochs10、batch_size128。epoch 表示把整个训练集过几遍batch_size 表示每批喂多少张图。为什么选 128 而不是 1 或 1024批次太小梯度更新太频繁训练不稳定批次太大内存压力大而且收敛速度不一定更快。128 是折中结果。执行训练history model.fit( train_dataset, epochs10, validation_datatest_dataset )fit执行后会打印每一轮的训练损失、训练准确率、验证损失、验证准确率。我第一次跑的时候第一轮准确率大概在 90% 左右到第五轮就超过 98%十轮结束基本稳定在 99% 附近。这个速度看上去很吓人但并不是模型有多聪明而是 MNIST 任务本身相对简单LeNet 对这个任务来说容量足够。4.2 分析训练曲线与过拟合判断为了看清楚训练过程我习惯把history里的损失曲线画出来import matplotlib.pyplot as plt plt.plot(history.history[loss], labeltrain_loss) plt.plot(history.history[val_loss], labelval_loss) plt.legend() plt.show()正常情况是训练损失和验证损失一起下降最终都接近 0。如果训练损失持续下降、验证损失却反弹上升说明模型开始过拟合记住训练集细节泛化能力变差。MNIST 因为数据分布干净十轮以内很少出现严重过拟合。如果你发现验证准确率停滞而训练准确率继续上升可以加早停回调或者增大数据增强不过入门阶段不用太较真。4.3 模型评估与保存训练完成后用测试集做一次完整评估test_loss, test_acc model.evaluate(test_dataset) print(Test accuracy:, test_acc)这个准确率通常在 98.5% 以上。如果你想看每一类的表现可以用sklearn.metrics.classification_report输出精确率、召回率但我个人觉得入门阶段没有太大必要。保存模型是关键一步因为后面测试自己的图片需要加载它。最方便的保存方式model.save(mnist_lenet.h5)也会生成mnist_lenet.h5文件里面包含模型结构、权重和编译信息。加载只需要一行loaded_model tf.keras.models.load_model(mnist_lenet.h5)如果你在生产环境部署推荐保存成 SavedModel 格式model.save(mnist_lenet_savedmodel)两种都能用h5更省事适合这个练习项目。5. 用自己的图片测试模型5.1 图片准备与格式要求模型训练用的 MNIST 图片有两个显著特点一是黑底白字二是数字基本位于图像中心且没有多余边框。你拿一张随手拍的白底黑字照片直接喂进去预测结果十有八九是错的这不等同于模型不好而是数据分布不一致。因此准备自己的测试图片时需要满足几个条件图片尽量是单数字数字主体大且清晰背景干净格式不限jpg、png 都行。可以用系统自带的画图软件写一个数字也可以拿手机拍手写数字。最让我意外的是很多人不知道训练集是黑底白字。MNIST 可视化后看起来是白底黑字但那只是画图库把 0 渲染成了白色背景、255 渲染成黑色文字实际上原始像素值里 0 是黑色背景、255 是白色笔迹。这个认知偏差导致了大量预测失败。5.2 图片预处理完整代码下面这段预处理函数是我实测过可以直接用的核心步骤是转灰度、缩放、反色、归一化。from PIL import Image import numpy as np def preprocess_image(image_path): # 1. 打开图片并转成灰度图 img Image.open(image_path).convert(L) # 2. 缩放到 28x28使用高质量重采样 img img.resize((28, 28), Image.Resampling.LANCZOS) # 3. 转成 numpy 数组形状为 (28, 28) img_arr np.array(img, dtypefloat32) # 4. 反色白底黑字 - 黑底白字 # 如果图片本身已经是黑底白字可以去掉这一行 img_arr 255.0 - img_arr # 5. 归一化到 0~1 img_arr img_arr / 255.0 # 6. 增加 batch 维度和通道维度 img_arr img_arr.reshape((1, 28, 28, 1)) return img_arr这里每一步都有它存在的理由。convert(L)把彩色图片变成灰度图消除颜色干扰resize把任意尺寸统一成 28x28这是模型输入要求的固定形状255.0 - img_arr做反色处理“白底黑字”这种最常见的场景最后reshape补上样本维度和通道维度。如果你的测试图片是手机拍的周围有大片空白或者背景杂乱建议先手动裁剪到只保留数字区域附近再做缩放。不要指望 LeNet 能像人眼一样自动忽略复杂背景它很笨只能看到 28x28 的灰度像素块。5.3 加载模型并完成预测预处理做好之后加载模型并预测img preprocess_image(my_digit.png) loaded_model tf.keras.models.load_model(mnist_lenet.h5) predictions loaded_model.predict(img) pred_class np.argmax(predictions[0]) confidence np.max(predictions[0]) print(预测数字:, pred_class) print(置信度:, confidence)predict返回一个形状为(1, 10)的数组argmax找到概率最大的下标这个下标就是预测数字。置信度可以看作模型对这个答案的把握程度一般 0.9 以上才算靠谱如果低于 0.5说明图片风格和训练集差异太大需要检查预处理流程。数字 0 对应下标 0数字 9 对应下标 9不用做额外映射。之前有个朋友用模型预测出 4但置信度只有 0.3后来发现是他拍的照片里手写了两个数字模型只能识别一个这种问题预处理阶段砍掉多余内容就能解决。6. 踩坑记录与排查技巧6.1 预测结果总是不对怎么办预测自己图片出错是最容易让人挫败的环节。我整理过几个高频原因按概率排序如下。首先是背景颜色反转。我在 5.1 里说过训练集是黑底白字而绝大多数人自己画的图是白底黑字忘了反色就会导致模型把背景当成数字、把数字当成背景。如果你发现预测结果持续偏向某几个类先检查是不是反色问题。其次是尺寸缩放导致数字变形。原图如果是细长形数字直接缩放到 28x28 会让宽度和高度比例失真。解决办法是先用画图软件把数字放到正方形画布中央再进行缩放。最理想的效果是缩放之后的数字宽度和训练集里的数字差不多。第三是像素值范围错误。如果忘了除以 255像素值 0-255 直接喂给模型数值范围与训练分布不一致输出概率就会乱掉。这种问题看起来不像 bug但影响非常大。第四是图片通道问题。如果是用 OpenCV 读取彩色图再转灰度没问题但如果忘了转灰度直接保留三通道输入形状变成 (28, 28, 3)而模型期望的是 (28, 28, 1)就会报维度错误或预测结果失控。所以尽量用 PIL代码简单。6.2 训练 loss 不下降的原因训练环节如果 loss 纹丝不动先别怀疑模型结构绝大多数情况是数据或超参数出了问题。常见之一是没有归一化。像素值 0-255 直接作为输入会让初始 loss 偏大Adam 虽然有自适应学习率但训练过程依然会比较挣扎。先除 255 看看效果。常见之二是标签与损失函数不匹配。如果用sparse_categorical_crossentropy标签必须是整数如果用categorical_crossentropy标签必须是 one-hot。如果你在from_tensor_slices之前对y_train做了to_categorical又在损失函数里用了 sparse 版本模型会直接报错这个错误信息其实比较直白。常见之三是学习率不合适。Adam 默认学习率 0.001基本不用动。如果你自己把学习率调到 0.1loss 可能震荡不降如果调到 0.00001收敛会非常慢。建议先从默认值开始。常见之四是一开始就跑复杂网络导致梯度消失。LeNet 很浅基本不会遇到这个问题如果你后续换成 ResNet 并随机初始化在深度较大时可能会难收敛。这也是初学先用 LeNet 的好处。6.3 常见问题速查表现象可能原因解决办法预测结果偏向同一类图片背景色反转执行255 - img_arr反色预测置信度极低图片中有多个数字或背景杂乱裁剪到单数字居中predict维度报错输入形状不是 (1,28,28,1)检查 reshape训练 loss 一直不降未归一化或学习率不当像素除以255恢复默认学习率加载 h5 文件报错TensorFlow 安装版本与保存版本不一致用相同环境保存和加载验证准确率高但自己图片差图片分布与 MNIST 不一致统一白底/黑底、居中、缩小边缘这张表不是用来背的而是等你真正遇到问题时回来对照。我经验里最诡异的一次是模型在测试集上准确率 99%但自己写的数字怎么预测都是错的最后发现是画图软件导出图片时自动加了透明背景转成灰度图后整张图变成了接近纯白的背景和很淡的灰色字迹。解决办法很简单先把透明通道合成到白色背景上再转灰度。6.4 一个提高成功率的小技巧如果你想让自己的图片更容易被识别可以在预处理时加入二值化img_arr np.where(img_arr 127, 255, 0)把像素强制变成纯黑或纯白去掉灰度过渡。MNIST 中的笔迹本身就是非常锐利的黑白边界二值化可以减少拍照时光线和阴影带来的干扰。但要注意二值化阈值不要设置太高否则细笔画会断掉。127 是一个合理起点实际可以调到 100 到 180 之间多试几次。另外用画图工具写数字时尽量模仿 MNIST 常见风格大一些、粗一些、居中。你可以把 MNIST 数据集里的图片可视化出来对比自己写的数字往往一眼就能看出差异。自己观察一圈基本就能猜到模型会因为什么而困惑。最后再分享一点个人体会这个项目的价值不只是得到一个 99% 的准确率而是第一次把数据、模型、训练、推理全链路走通。走通之后你会发现很多以前觉得遥远的应用比如验证码识别、票据数字提取、简单的质检分类本质上都是“图像 - 预处理 - 卷积网络 - 类别概率”这个套路的变体。保持对像素值、背景色、尺寸这些细节的敏感比背再多网络结构都有用。如果你训练完 LeNet 之后还有余力可以试着把结构改成两层卷积加三层全连接加深隐藏层宽度或者在测试图片里加入不同字体、不同颜色看看模型的极限在哪里。折腾几次之后你对深度学习的“体感”就真的建立起来了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑