简介:这是一份基于TensorFlow构建LeNet-5卷积神经网络的手写数字识别项目,网络结构包含卷积层、池化层、全连接层等典型模块,面向毕业设计、课程设计、工程实训和大作业等场景。资源内含MNIST数据集压缩文件、Python训练脚本、多轮迭代后的模型检查点,以及基于Flask的识别服务端与前端展示页面;下载后按README说明操作即可复现训练与识别流程,也能在前端传入图片进行实时识别,代码模块划分清楚,便于扩展新功能或参考其网络设计与调参思路。模型检查点提供了多个训练步数版本,方便对比不同迭代阶段的识别效果。资源包共31个文件,以py源码、模型检查点、gz数据包为主,另有README、演示PPTX、HTML/CSS/JS展示资源等,压缩包约70.44MB,目录结构清晰。目前已有81人学习下载,适合需要快速搭建手写数字识别系统或参照完整工程完成课设、毕设的开发者。
1. 手写数字识别为什么绕不开 LeNet-5 和 TensorFlow
手写数字识别看起来是个再简单不过的分类问题,但它在计算机视觉里的地位很特别:几乎所有做深度学习的人,第一次亲手把卷积神经网络跑通,都是在一个 28x28 的灰度图数据集上完成的。MNIST 就是那个绕不过去的数据集,LeNet-5 就是那个绕不过去的模型。它是 Yann LeCun 在 1998 年设计的结构,今天看来参数少得可怜,但在当时解决了银行支票数字识别的实际问题,也是后来 AlexNet、ResNet 这些模型的鼻祖。用 TensorFlow 复现 LeNet-5,不只是交一个课设或毕设,而是把卷积、池化、全连接、反向传播这一整套基本功完整走一遍。这篇文按我平时做项目能直接落地的顺序来讲:先看懂 LeNet-5 的每一层在干什么,再把数据管道搭好,接着训练调参,最后补几个能把准确率再往上推一截的技巧,适合要交作业的学生,也适合想快速把经典模型捡起来验证一下的工程师。
2. 逐层拆解 LeNet-5 的网络结构,再动手写 TensorFlow 代码
LeNet-5 这个名字里的 5 指的是它有 5 层带参数的层,分别是两个卷积层和三个全连接层。不过完整看它的前向流程,还要算上输入层和两个池化层,所以很多资料会把它描述成七层结构。这个细节在面试或答辩时经常被追问,先立住这个框架,后面代码就好写了。
2.1 LeNet-5 七层结构里每一层的输入输出尺寸
原始 LeNet-5 的输入是 32x32 的单通道灰度图,论文里用 32x32 而不是 MNIST 的 28x28,是因为希望在第一个卷积层之后得到足够大的特征图,同时 32x32 对当年算力也更友好。现代复现里大多数直接用 28x28,带来的差别是第一个卷积层不加 padding 时输出会小一圈。下面这张表是原始论文的参数,训 MNIST 时可以照抄,也可以做微调。
| 层名 | 类型 | 核大小/步长 | 输出尺寸 | 可训练参数量 |
|---|---|---|---|---|
| C1 | 卷积 | 5x5 / 1,6 个核 | 28x28x6 | 156 |
| S2 | 平均池化 | 2x2 / 2 | 14x14x6 | 12 |
| C3 | 卷积 | 5x5 / 1,16 个核 | 10x10x16 | 1516 |
| S4 | 平均池化 | 2x2 / 2 | 5x5x16 | 32 |
| C5 | 卷积 | 5x5 / 1,120 个核 | 1x1x120 | 48120 |
| F6 | 全连接 | - | 84 | 10164 |
| 输出 | 全连接/高斯连接 | - | 10 | 850 |
注意 S2 和 S4 用的是平均池化,不是现在更常用的最大池化。LeNet-5 里池化层每个特征图还带一个可训练的系数和偏置,这在 Keras 里直接用 AveragePooling2D 是复现不出来的,好在影响很小,绝大多数现代实现都简化为纯平均池化。参数量那列的数值按原始论文,使用 padding 或改动通道数后会有变化,不用背,能算就行。
2.2 用 Keras 把 LeNet-5 结构搭出来
用 TensorFlow 的 Keras 接口写 LeNet-5 非常直观,核心就是按层堆叠。为了贴近原始结构,我一般会在第一层加一个 ZeroPadding2D,把 28x28 补到 32x32,这样 C1 输出还是 28x28,和论文的尺寸推演对得上,答辩时也好讲。
import tensorflow as tf from tensorflow.keras import layers, models def build_lenet5(input_shape=(32, 32, 1), num_classes=10): model = models.Sequential([ # 论文输入是32x32,MNIST是28x28,先补边到32x32 layers.ZeroPadding2D(padding=2, input_shape=(28, 28, 1)), # C1: 6个5x5卷积核,sigmoid激活 layers.Conv2D(6, kernel_size=5, activation='tanh'), # S2: 平均池化 layers.AveragePooling2D(pool_size=2, strides=2), # C3: 16个5x5卷积核 layers.Conv2D(16, kernel_size=5, activation='tanh'), # S4 layers.AveragePooling2D(pool_size=2, strides=2), # C5: 120个5x5卷积核,此时特征图已是5x5,卷积后为1x1 layers.Conv2D(120, kernel_size=5, activation='tanh'), # 展平后接全连接 layers.Flatten(), layers.Dense(84, activation='tanh'), layers.Dense(num_classes, activation='softmax') ]) return model model = build_lenet5() model.summary()这段代码里有两个容易踩坑的地方。第一个是 ZeroPadding2D 的用法,padding=2 表示上下左右各补 2 行/列,28x28 变成 32x32,如果你只想用 28x28 输入训,去掉这一层也行,但后面的 C5 层尺寸推演就变成 C1 输出 24x24、S2 变成 12x12、C3 输出 8x8、S4 变成 4x4,C5 卷积核 5x5 就不能用了,要换成 Flatten 加 Dense,这就偏离了原始结构。第二个是激活函数,原始 LeNet-5 用的 tanh,不是 ReLU。用 ReLU 训练更快,但 tanh 在这个规模的数据上更容易把验证准确率稳定在 99% 附近,而且更贴近论文描述,课设答辩被问到时更好解释。
2.3 感受野与参数量的直观理解
这层不多展开,但有个数建议记住:LeNet-5 全部参数量大约 6 万,而 MNIST 训练集有 6 万张图。也就是说,这个模型的参数数量和数据量几乎是一比一,这说明它在当时设计得非常克制,也是它能在这类小图上不靠数据增强就收敛的原因。理解了这一点,就能明白为什么现在做手写数字识别不推荐直接上 ResNet——模型容量远大于问题复杂度时,反而需要更多 trick 去压制过拟合。
3. 准备 MNIST 数据:读取、预处理与 tf.data 管道搭建
LeNet-5 模型本身只是骨架,训练效果好不好,一半取决于数据管道。MNIST 虽然自带 6 万训练图和 1 万测试图,但如果直接拿原始像素喂给模型,准确率会卡在 98% 左右上不去。下面这套预处理是我做项目时的标配。
3.1 加载 MNIST 的几种方式与选型
TensorFlow 里拿 MNIST 最省事的方式是tf.keras.datasets.mnist.load_data(),它返回的是 numpy 数组,适合快速验证。如果要做科研或竞赛级别的实验,我一般会改用 TensorFlow Datasets 里的tfds.load('mnist'),好处是能直接拿到tf.data.Dataset对象,方便后面做 shuffle、map、batch。还遇到过一种情况是离线环境,两种方式都拉不下来数据,只能手动下载四个 gz 文件放到~/.keras/datasets目录下,这时用load_data也能识别。注意如果是 matlab 或纯 Python 环境,也常用fetch_openml('mnist_784'),字段含义略有不同,这里不展开。
import numpy as np import tensorflow as tf # 方式一:keras自带,返回numpy数组 (x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data() # 方式二:tfds,返回Dataset对象 import tensorflow_datasets as tfds ds_train, ds_info = tfds.load('mnist', split='train', as_supervised=True, with_info=True) print(ds_info.features['label'].num_classes)实际项目里我偏爱第二种,因为tf.data可以做到图模式下的高效预处理,训练时 CPU 不用等 GPU。但课设代码往往要求逻辑直白,用第一种的人更多,代码好写,答辩好讲,处理 6 万张图也慢不到哪去。
3.2 归一化、形状调整与 one-hot 编码
MNIST 每个像素是 0 到 255 的整数,直接喂给网络,初始梯度会被放大,训练不稳定。正确做法是归一化到 [0,1] 区间,更讲究一点可以用均值和标准差做标准化。归一化之后还要做两件事:加通道维度和标签 one-hot 编码。
# 归一化到[0,1],再增加通道维度 x_train = x_train.astype('float32') / 255.0 x_test = x_test.astype('float32') / 255.0 x_train = np.expand_dims(x_train, axis=-1) x_test = np.expand_dims(x_test, axis=-1) # one-hot编码,类别数10 y_train = tf.keras.utils.to_categorical(y_train, 10) y_test = tf.keras.utils.to_categorical(y_test, 10) # 注意:如果用了上一章的ZeroPadding2D且输入是28x28, # 这里不需要resize,padding在模型内完成参数说明:astype('float32')是为了避免 uint8 在除法时的截断问题;expand_dims(axis=-1)把形状从 (60000, 28, 28) 变成 (60000, 28, 28, 1),对应input_shape的最后一维;to_categorical的第二个参数必须写 10,不写的话它会自动根据最大值推断,万一测试集里缺了某个类别,输出维度会少一维。还有一个常见误操作是把归一化设置为(x - 127.5) / 127.5,这样范围变成 [-1,1],加速收敛但配合 tanh 激活时输入分布正好,配合 ReLU 时反而浪费一半表达空间,按模型激活函数来选。
3.3 用 tf.data 封装训练集与数据增强
如果只用原始数据训练,LeNet-5 大约 10 个 epoch 就能到 99% 上下,但会过拟合在训练集上。最常见的补救是在训练集上做数据增强:随机旋转 10 度以内、随机平移 2 像素、轻微缩放,这些变换对数字识别是合理的,翻转不行——6 和 9 会混淆。
def augment(image, label): # 随机旋转,角度在[-10, 10]度之间 image = tf.image.rot90(image, k=tf.random.uniform([], 0, 4, dtype=tf.int32)) # 旋转90度的k倍对数字识别有点太暴力,这里演示用, # 更稳妥的是用下面这行注释掉的仿射变换 # image = tfa.image.rotate(image, angles=tf.random.uniform([], -0.15, 0.15)) image = tf.image.random_shift(image, 0.1, 0.1) image = tf.image.random_contrast(image, 0.9, 1.1) return image, label train_ds = tf.data.Dataset.from_tensor_slices((x_train, y_train)) train_ds = train_ds.shuffle(60000).map(augment, num_parallel_calls=tf.data.AUTOTUNE).batch(128).prefetch(tf.data.AUTOTUNE) test_ds = tf.data.Dataset.from_tensor_slices((x_test, y_test)).batch(128)这里shuffle的 buffer 设成整个训练集大小,保证随机性彻底;prefetch让数据加载和模型训练重叠;map里的增强操作在 CPU 上并行执行,不会卡 GPU。random_shift的 0.1 表示水平和垂直方向最多移动 10% 的宽高,即约 2.8 像素,和古老的仿射扰动思路一致。
数据准备这块容易被忽视的一个点是验证集的构造。MNIST 官方把测试集当作最后的评估集,所以训练过程中应该再从训练集切出一部分做验证,用来调学习率和早停,不然你看着测试集调参,最后报告那个 99.5% 其实是有水分的。常见做法是训练集末尾拿 5000 张做 validation。
4. 训练 LeNet-5:损失函数、优化器与调参路线
模型和数据准备好以后,最无聊也最容易出错的就是训练配置。LeNet-5 是个六十年前的架构,但在 MNIST 上训练仍然有很多细节值得展开讲,尤其是学习率、batch size 和过拟合控制这三者的关系。
4.1 训练配置怎么选:从 adam 到 sgd+momentum
很多课设代码默认用 Adam,learning_rate 取 0.001,batch size 取 128,能跑出好结果,但答辩时如果被问一句"为什么用 Adam 不用 SGD",很多人会愣住。LeNet-5 原始论文用的是 SGD,加 momentum,learning_rate 从 0.01 开始,每若干 epoch 衰减。实际对比下来,在小数据集上 SGD 加 momentum 的收敛曲线更稳定,最终准确率也略微占优,Adam 的优势在于不用太调学习率。
| 优化器 | 学习率 | 收敛速度 | 最终准确率 | 适用场景 |
|---|---|---|---|---|
| Adam | 0.001 | 快,前 3 轮就能到 97% | 99.0%~99.2% | 快速验证、调参 |
| SGD+momentum(0.9) | 0.01 起步,按 epoch 衰减 | 慢,但稳 | 99.2%~99.4% | 追求极致精度、比赛 |
| RMSprop | 0.001 | 中 | 98.8%~99.0% | 对学习率敏感度低 |
我的做法是,课设代码用 Adam,省事,但把优化器封装成一个参数,代码里写明如果追求精度可以换 SGD 加学习率衰减。下面这份代码把编译和回调都写完整,能直接跑。
model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss='categorical_crossentropy', metrics=['accuracy'] ) callbacks = [ tf.keras.callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=3), tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True), tf.keras.callbacks.ModelCheckpoint('lenet5_best.h5', monitor='val_accuracy', save_best_only=True) ] history = model.fit( train_ds, validation_data=test_ds, epochs=30, callbacks=callbacks )逻辑说明:ReduceLROnPlateau在验证损失连续 3 个 epoch 不下降时把学习率减半,这是比手写学习率衰减更省心的做法,因为它在真正需要时才衰减。EarlyStopping的patience=5表示容忍 5 轮不提升,防止在 99.2% 附近反复震荡浪费时间。restore_best_weights=True很重要,不设这个的话,停住时保留的是最后一个 epoch 的权重,往往不是最优的。
4.2 训练过程中的现象判读
新手容易盯着准确率看,其实训练时要同时看 loss 和 accuracy 两条曲线。LeNet-5 正常训练时会出现这些现象:第一个 epoch 结束准确率就冲到 95% 以上,这不是没收敛,而是 MNIST 太简单,模型第一步梯度下降就基本能找到方向;训练 loss 快速下降到 0.001 量级,但验证 loss 下降变慢,这时基本就是开始过拟合了。
从第 5 个 epoch 开始,训练准确率可能上到 99.8%,验证集在 99.2% 左右徘徊,这是 LeNet-5 在这类任务上的典型表现。过了这个点,模型多出来的表达力全花在记住训练集噪声上了。如果你用的是 tensorboard 而不是裸 print,可以同时盯着 validation 和 train 两套曲线看 gap,gap 持续拉大就是过拟合信号。
4.3 过拟合、学习率崩坏与数据泄漏三个坑
过拟合的表现是训练集 100%、验证集 99.1% 上不去。解决办法按优先级排:降低模型容量(把 C5 的 120 改成 80,或加 dropout)、加重数据增强、加 L2 正则。LeNet-5 在 MNIST 上的过拟合一般不算严重,dropout 加在全连接层 F6 之前效果最明显,加在卷积层反而破坏特征提取。
学习率崩坏有两种:设太大会发现 loss 变成 nan,或准确率在 10% 附近不动;设太小会看到 loss 一个 epoch 降不到 0.1。前者把学习率降一个数量级,后者升一个数量级。ResNet 大行其道的今天还有个容易踩的坑:把模型换成 ResNet 后忘记调数据增强策略,减慢了训练速度却拿不到更好效果,本质是任务太小。
数据泄漏这个问题在 MNIST 场景下不常发生,但如果有人把测试集做了归一化统计再丢回训练集,准确率就不可信了。我用一个简单规则避免:归一化的均值和标准差只从训练集计算,测试集直接用同一组系数,不要重新算。
5. 模型评估与导出:用手写样本验证的完整流程
准确率报 99% 在毕设里不够有说服力,评审老师更想看到的是模型在真实手写样本上的表现,以及错误样本长什么样。这部分我一般会额外做两件事,一是可视化卷积核学到的东西,二是做一个预测脚本可以传入任意图片。
5.1 本地手写图片的预测脚本
import cv2 import numpy as np def preprocess_image(path): img = cv2.imread(path, cv2.IMREAD_GRAYSCALE) # 反色,白底黑字转黑底白字 img = 255 - img # 缩放到28x28,注意保持比例 img = cv2.resize(img, (28, 28), interpolation=cv2.INTER_AREA) img = img.astype('float32') / 255.0 img = np.expand_dims(img, axis=[0, -1]) return img def predict_digit(path, model): x = preprocess_image(path) probs = model.predict(x, verbose=0)[0] return int(np.argmax(probs)), float(np.max(probs)) model = tf.keras.models.load_model('lenet5_best.h5') digit, conf = predict_digit('my_digit.png', model) print(f'预测结果: {digit}, 置信度: {conf:.4f}')这里三个参数值得注意:cv2.INTER_AREA做缩小插值,在缩图时比默认的 INTER_LINEAR 更平滑,能减少锯齿对识别的影响;反色操作必须做通,模型训练数据是黑底白字,手机拍的照片是白底黑字,颜色反了准确率会暴跌;补边留白也是个常见问题,直接用cv2.resize拉伸 28x28 会改变数字长宽比例,严谨的做法是先找到数字连通域,裁剪到数字外接框,再按长宽比放到 28x28 的白色画布中央。投射到 LeNet-5 的 32x32 输入时,同样要经过 ZeroPadding2D,所以预处理好的 28x28 直接进模型即可。
5.2 错误样本分析与置信度校准
测试集跑完后,用model.predict(test_ds)拿到全部概率,筛选出预测错误和置信度低于 0.8 的样本,打印出来看。常见的失败模式有两种:一种是手写 7 和 1 混淆,因为某些人写 7 时中间没有横杠,模型区分依据不足;另一种是 4 和 9,在潦草书写下形状高度相似。分析错误样本时,要看卷积层第一层的输出特征图,能直观看到模型在哪些边缘和角点响应强烈。
另外一个实用技巧是置信度校准,softmax 的概率在分类正确时普遍偏高,99% 置信度的样本可能是对的,但 60% 置信度的样本也有一半概率是对的。如果项目要求输出可信区间,建议加一个温度缩放,训练完在验证集上搜索让交叉熵最小的温度参数,这比直接读 softmax 概率可靠。
5.3 模型导出与端侧部署准备
课设通常止步于.h5文件,但如果想加分,可以顺手导出一个 TensorFlow Lite 模型,做成一个 Android 或浏览器 demo。导出只需要两行代码:
converter = tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations = [tf.lite.Optimize.DEFAULT] tflite_model = converter.convert() with open('lenet5_mnist.tflite', 'wb') as f: f.write(tflite_model)Optimize.DEFAULT会做训练后量化,权重从 float32 变 float16 或 int8,模型体积从 100KB 量级压到 25KB 左右,精度损失不超过 0.1%。注意量化时如果用了represantative_dataset校准数据可以做到 int8,没有它只能做到 float16,在移动端上差别不大。如果部署端是 WebAssembly,直接用 TensorFlow.js 加载模型权重也行,但.h5要转成tfjs_layers_model.json格式。
在做完上面这些步骤之后,LeNet-5 这个经典结构就可以作为完整模板反复使用,后面换到 fashion-mnist 或 CIFAR-10,只需要改输入通道数和最后一层节点数,整个训练与评估流程不需要再推倒重来。
本文还有配套的精品资源,点击获取