news 2026/9/14 22:08:23

Paddle手写数字识别全流程:LeNet-5训练、模型导出与便携打包

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Paddle手写数字识别全流程:LeNet-5训练、模型导出与便携打包

简介:这是一份基于 PaddlePaddle 框架实现手写数字识别的完整项目包,面向深度学习初学者和希望快速上手百度飞桨的开发者,可帮助理解图像分类任务从数据预处理、CNN 模型搭建到训练评估的完整流程。压缩包共 12 个文件,大小约 19.25MB,核心包括 Train.py、Test.py 两个 Python 训练与测试脚本,mnist.json.gz 格式的 MNIST 数据文件,以及 README.md 项目说明文档;此外还包含少量 xml 配置、示例图片等辅助文件,便于直接运行和二次修改。已有 318 人学习下载。项目源码经过助教老师实际测试,正确性和可运行性有保障,下载后按 README 指引即可快速复现实验。内部目录结构清晰,涵盖模型脚本、数据集、可视化图片和说明文档,适合结合 CNN 中卷积、池化、Softmax 分类、优化器等知识点逐段理解,是一份兼具实战价值与教学意义的基础入门资料。

1. 手写数字识别:从数据集到 Paddle 选型

手写数字识别是很多人第一次把神经网络从训练跑到部署的起点,Paddle 恰好把这两段都留好了接口:用paddle.vision.datasets.MNIST一条命令拿数据,用paddle.jit.to_static把动态图模型导出成静态图,最后装进 zip 分发。真正麻烦的不是“识别”,而是训练完之后,怎么让一个没装飞桨的人也能双击跑起来。这篇内容直接围绕“基于 Paddle 的手写数字识别.zip”这种交付形态来写,覆盖数据准备、LeNet-5 训练、模型导出以及便携打包。合适人群是对飞桨已有基本概念、想完整走一遍训练到部署的开发者,也适合遇到打包报错后回来补课的工程人员。

2. 准备 Paddle 运行环境与 MNIST 手写数字识别数据集

2.1 安装 Paddle 并确认 CPU/GPU 可用性

先解决环境问题。手写数字识别对算力要求不高,CPU 上训练 LeNet-5 跑满 10 个 epoch 通常也就几分钟,GPU 只是让迭代更舒服。安装命令按官方源走:

python -m pip install paddlepaddle -i https://mirror.baidu.com/pypi/simple

安装后再确认版本和设备状态:

python -c "import paddle; print(paddle.__version__)" python -c "import paddle; print(paddle.device.is_compiled_with_cuda())"

第一段命令安装了 CPU 版本的 PaddlePaddle,对 MNIST 任务足够。第二段用来验证框架是否能正常导入,以及当前安装版本是否带 CUDA 编译。如果第二个输出False,说明你拿到的是 CPU 推理库,后续训练仍然能跑,只是卷积层会慢一些。需要注意,GPU 版本对应的是paddlepaddle-gpu,并且版本号要和本机 CUDA 版本匹配,否则运行时日志会提示找不到驱动库。

2.2 下载 MNIST 数据集到本地目录

Paddle 的视觉数据集模块内置了 MNIST,下载逻辑会按默认路径缓存到用户目录下。也可以手动指定root,方便项目打包时一并带上数据目录。加载时建议顺手做归一化,这样模型收敛更快。

import paddle from paddle.vision.datasets import MNIST from paddle.vision.transforms import Normalize transform = Normalize(mean=[0.0], std=[1.0], to_rgb=False) train_dataset = MNIST(mode='train', transform=transform, download=True) test_dataset = MNIST(mode='test', transform=transform, download=True) print(len(train_dataset), len(test_dataset)) sample, label = train_dataset[0] print(sample.shape, label)

上面的代码将数据归一化到 0 到 1 之间,因为 MNIST 原始像素值范围是 0 到 255,归一化能避免输入数值过大导致训练不稳定。to_rgb=False表示保持单通道灰度图,如果漏掉这个参数,部分 Paddle 版本会尝试将图像转成三通道 RGB,导致Linear层输入尺寸对不上。输出结果中sample.shape应该是(28, 28)或者(1, 28, 28),两种形态都要在进入模型前统一 reshape。

MNISt 数据集本身是六万张训练图和一万张测试图。如果要在本地跑起来,download=True会让程序自动从远端拉取数据。需要交付给内网环境时,可以先把缓存目录下载好,再把数据目录复制进项目包里,运行时设置download=False即可。

2.3 用 DataLoader 组织批次输入

paddle.io.DataLoader负责把数据集切成 batch,同时支持打乱和并行读取。训练 LeNet-5 常用 batch size 是 128 或 256。这里按 128 配置:

train_loader = paddle.io.DataLoader( train_dataset, batch_size=128, shuffle=True, num_workers=0, drop_last=True ) test_loader = paddle.io.DataLoader( test_dataset, batch_size=256, shuffle=False, num_workers=0 )

shuffle=True让每个 epoch 的训练顺序不同,避免模型记住固定顺序;drop_last=True丢弃最后不足一个 batch 的样本,防止 batch 数量不同影响 BatchNorm 层。num_workersif __name__ == "__main__":保护块之外使用容易出现多进程启动报错,所以在 Windows 上先设为 0,等代码稳定后再调大。

这里的 DataLoader 会在每次迭代时返回(image, label),其中 image 的 shape 是[batch_size, 28, 28][batch_size, 1, 28, 28],label 是整数数组。实际操作中最好丢一个打印进训练循环,确认尺寸是真的。

3. 用 Paddle 搭建并训练手写数字识别模型

3.1 使用 Paddle 定义 LeNet-5:输入输出尺寸

经典 LeNet-5 结构很适合 MNIST,输入是 28×28 灰度图。第一个卷积层用 6 个 5×5 卷积核,尺寸变为 24×24,池化后变成 12×12;第二个卷积层用 16 个 5×5 卷积核,尺寸变为 8×8,池化后变成 4×4。这样每个样本的最终特征图通道数是 16,空间尺寸 4×4,展平后是 256 维。

import paddle import paddle.nn as nn class LeNet5(nn.Layer): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2D(1, 6, kernel_size=5, stride=1, padding=0), nn.ReLU(), nn.MaxPool2D(kernel_size=2, stride=2), nn.Conv2D(6, 16, kernel_size=5, stride=1), nn.ReLU(), nn.MaxPool2D(kernel_size=2, stride=2) ) self.classifier = nn.Sequential( nn.Linear(256, 120), nn.ReLU(), nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, num_classes) ) def forward(self, x): x = self.features(x) x = paddle.flatten(x, 1) return self.classifier(x)

这里的输入x期望 shape 是[batch_size, 1, 28, 28]。很多新手直接把[batch_size, 28, 28]传进去,会得到维度不匹配的报错。分类层最后一层输出 10 类,对应数字 0 到 9。Paddle 的flatten指定了start_axis=1,所以不会把 batch 维展平,这一点和reshape(x, [x.shape[0], -1])等价。

也可以直接用paddle.vision.models.LeNet,内置模型权重初始化策略已经调好。不过自己手写一遍有助于后面改结构,比如调整卷积核数量、加上 Dropout 层时更清楚每一层做了什么。

3.2 训练循环:损失函数、优化器和参数设置

训练环节最稳定的搭配是 交叉熵损失 + Adam 优化器。手写数字分类是十类别任务,Paddle 的SparseCategoricalCrossentropy接收整数标签,省去 one-hot 转换。

import paddle model = LeNet5() optimizer = paddle.optimizer.Adam(learning_rate=0.001, parameters=model.parameters()) criterion = paddle.nn.SparseCategoricalCrossentropy() epochs = 10 for epoch in range(epochs): model.train() total_loss = 0.0 for images, labels in train_loader: images = paddle.reshape(images, [-1, 1, 28, 28]) logits = model(images) loss = criterion(logits, labels) loss.backward() optimizer.step() optimizer.clear_grad() total_loss += loss.item() print(f"epoch {epoch + 1}, avg loss {total_loss / len(train_loader):.4f}")

为什么每个 iter 都要调用optimizer.clear_grad()?Paddle 的动态图模式会累加梯度,不清理会导致参数更新方向和速度失真。paddle.reshape(images, [-1, 1, 28, 28])-1表示由数据量自动推导,后面的1, 28, 28是强行规定通道维和图像尺寸。

训练 10 个 epoch 后,MNIST 一般能到 99% 左右的准确率。如果 loss 不降或发散,先检查学习率。下面这张表给出常见参数的经验范围:

超参数常用取值影响
batch_size64 到 256太小收敛慢且抖动大,太大跑完一个 epoch 更慢
learning_rate0.001 到 0.0001太大会出现 loss 跳变,太小则收敛迟缓
epochs10 到 20MNIST 上 10 个 epoch 已够,过强训练易过拟合
optimizerAdam 或 SGD + momentumAdam 适合快速验证,SGD 最终精度可能更高

3.3 训练时容易忽视的两类坑

第一类坑是图像没有归一化。MNIST 原图是 0 到 255,如果直接输入卷积网络,权重要花更长时间去适应大数值输入。理想的做法是把像素缩放到 0 到 1 之间,甚至用均值和标准差做标准化。Paddle 的Normalize默认只做简单除法,但在某些旧版本里需要将std显式写成[1.0],否则会报除数为零的错误。

第二类坑是训练时打开了model.eval()Dropout层在eval状态会关闭,BatchNorm会使用全局统计量,而训练中应该使用本 batch 的统计量。LeNet-5 没有这些层时表现不明显,一旦改成带 BatchNorm 的卷积结构,有没有切换模式会直接影响精度。

还要留意 DatLoader 返回的images是 PIL 图像还是 numpy 数组。部分 Paddle 版本的 MNIST 数据集在transform=None时返回 PIL 对象,直接reshape会失败。给数据集传入transform后,返回的才是numpy.ndarray,这也是 2.2 节特意加Normalize的原因之一。

4. 评估、导出模型与离线推理

4.1 用测试集评估准确率

训练结束后,需要在测试集上做正式评估。测试集和训练集没有重叠,能反映模型对未见数据的泛化能力。

model.eval() correct = 0 total = 0 with paddle.no_grad(): for images, labels in test_loader: images = paddle.reshape(images, [-1, 1, 28, 28]) logits = model(images) preds = paddle.argmax(logits, axis=1) correct += (preds == labels).numpy().sum() total += labels.shape[0] print(f"test acc: {correct / total:.4f}")

model.eval()的作用是切换成推理模式,开关训练专用层;paddle.no_grad()避免构建反向计算图,节省显存和计算时间。paddle.argmax沿类别维度取最大值的下标,得到的就是预测数字。这个准确率通常高于 0.99,属于正常水平。如果低于 0.98,优先怀疑数据预处理部分出了问题,而不是网络结构不行。

4.2 使用 paddle.jit 导出静态图模型

训练得到的是动态图参数,不方便直接用推理引擎加载。Paddle 提供了paddle.jit.to_staticpaddle.jit.save把动态图模型转成静态图模型,导出后得到.pdmodel.pdiparams两个文件。这样后续加载可以脱离原始 Python 类定义。

class LeNet5Infer(nn.Layer): def __init__(self): super().__init__() self.model = LeNet5() def forward(self, x): logits = self.model(x) return paddle.nn.functional.softmax(logits, axis=1) model = LeNet5Infer() model.load_dict(paddle.load("mnist_lenet.pdparams")) model.eval() model = paddle.jit.to_static( model, input_spec=[ paddle.static.InputSpec(shape=[-1, 1, 28, 28], dtype="float32", name="image") ] ) paddle.jit.save(model, "inference_mnist")

导出时要把模型包一层带softmax的类,否则输出是 logits。虽然argmax对 logits 和 softmax 结果相同,但对外部署时直接拿到概率值更直观。input_spec指定输入张量的 shape,-1表示 batch 维可变。换成固定1会在后续批量推理时被锁死,常见做法是保留-1

导出成功后,当前目录会出现inference_mnist.pdmodelinference_mnist.pdiparams。这两个文件就是最终模型文件,后续打包只依赖它们。

4.3 对自定义手写图片做离线推理

从项目交付角度,真正有价值的部分是“喂一张图片进来,得到预测数字”。这里需要处理图像预处理和模型加载。

import numpy as np from PIL import Image import paddle model = paddle.jit.load("inference_mnist") model.eval() def preprocess_image(path): img = Image.open(path).convert("L").resize((28, 28)) arr = np.array(img, dtype=np.float32) / 255.0 arr = 1.0 - arr return paddle.to_tensor(arr.reshape(1, 1, 28, 28)) image_path = "custom_digit.png" with paddle.no_grad(): out = model(preprocess_image(image_path)) pred = int(paddle.argmax(out, axis=1).numpy()[0]) print("预测数字:", pred)

convert("L")把图片转成灰度图,resize((28, 28))是强行缩放到模型输入尺寸。这里的缩放会拉伸图像,实际手写数字长宽比并非正方形时,效果可能打折。更好的做法是先做边缘检测找到数字的外接框,再按比例缩放并填补到 28×28,这一步可以在后面的优化阶段加上。

1.0 - arr是非常关键的操作。MNIST 数据集的背景是黑色、数字是白色,而普通手机拍照或扫描得到的图片往往是白底黑字。不做反转,模型看到的图像语义完全反了,输出置信度会混乱。如果你确认图片本身就是黑底白字,则不需要这一步。

5. 把 Paddle 手写数字识别项目打包成便携 zip

5.1 先确定交付方式:源码包、依赖包还是单文件

打包前需要回答一个问题:用户机器里有没有 Paddle 环境?如果对方是开发者,交付源码 zip,要求他安装 Paddle 即可;如果对方是业务人员,则要把 Paddle 推理所需运行库一起打进去。

常见做法有三种:

  • 只交付.py源码和模型文件,对方自行安装依赖。
  • 做虚拟环境目录,把site-packages里的 Paddle 相关库复制出来,连同启动脚本组成一个可解压运行的文件夹。
  • 用 PyInstaller 把 Python 解释器、依赖库、模型文件和入口脚本打进一个可执行文件,再压成 zip。

对于“基于 Paddle 的手写数字识别.zip”这种命名,用户更倾向于解压即用。因此采用 PyInstaller 打包方式最接近目标。尤其处理过 PaddleOCR 便携打包版的人,对 PyInstaller 的思路应该很熟悉,手写数字识别项目本质上是同样流程,只是少了 OCR 那部分文字检测和方向分类模型。

5.2 用 PyInstaller 打包 Paddle 应用的常用参数

PyInstaller 的模式分单文件-F和目录-D两种。Paddle 动态库体积大、.so文件数量多,-F模式启动时把所有文件释放到临时目录,速度慢且容易被杀毒软件拦截。更稳的方式是用-D生成目录,再压缩成 zip 分发。

pip install pyinstaller pyinstaller -D -w --name mnist_digit \ --add-data "inference_mnist.pdmodel;." \ --add-data "inference_mnist.pdiparams;." \ --hidden-import paddle \ --hidden-import paddle.inference \ entry.py

命令说明:-D生成一个文件夹而不是单个可执行文件;-w在 Windows 上隐藏控制台窗口,如果要从终端看输出则去掉;--add-data把模型文件复制到解算后的当前目录,Windows 上分隔符是分号,Linux 和 macOS 是冒号。--hidden-import用于兜底哪些没有显式 import 但推理时会用到的模块。

这里用entry.py作为入口,里面必须包含paddle.jit.load和对图像预处理、预测的调用逻辑。PyInstaller 从入口开始静态分析 import 关系,所以entry.py里要明确写出import paddlefrom paddle.vision.datasets import MNIST等,不能只放在函数内部。

5.3 复制底层动态库并压缩成 zip

PyInstaller 模块自动搜集运行时依赖,但 PaddlePaddle 的底层.dll或者.so有时不能正确识别。打包完成后,如果运行报libiomp5md.dll找不到或paddle_fluid.dll找不到,需要手动从 Python 安装目录复制这些文件到打包输出根目录。

检查 Python 安装路径可以用一段命令查看:

python -c "import paddle; print(paddle.__file__)"

进入paddle库所在目录后,把libspaddle/libs下的动态库一并复制到dist/mnist_digit/下。大部分情况下,Paddle 的动态库依赖会被 PyInstaller 通过打包机制收集进.pyd所在位置,真正缺的是个别第三方运行时库。

复制完成后,用脚本或压缩软件把整个目录压成 zip。压缩时不要带上 Python 的缓存目录,比如__pycache__.pyc文件,这些对整个项目没有实际作用,只增加体积。下面是一段精简压缩目录的 Python 脚本:

import os import zipfile def zip_dir(target_dir, output_zip): with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zf: for root, dirs, files in os.walk(target_dir): dirs[:] = [d for d in dirs if d != "__pycache__"] for file in files: full_path = os.path.join(root, file) arcname = os.path.relpath(full_path, target_dir) zf.write(full_path, arcname) zip_dir("dist/mnist_digit", "基于paddle的手写数字识别.zip")

这段代码会自动过滤掉__pycache__目录,并把dist/mnist_digit下所有内容写进一个 zip。arcname的作用是保留文件夹内部的相对结构,用户解压后就能直接运行mnist_digit.exe

5.4 交付前必须验证的 3 个场景

打包完成后,不要只在开发机测试。把 zip 复制到一台没有安装 Paddle 的机器上,最好是一台全新虚拟机,然后依次检查:

  • 双击启动程序,能否正常弹出窗口。
  • 用一张白底黑字的手写数字图片做推理,输出结果是否正确。
  • 断网环境下运行,不检查 Paddle 版本、不下载任何资源。

如果第一步就报错,优先查看entry.py里是否使用了相对路径加载模型。解压目录不同,当前工作目录也不同,模型路径最好用下面这段代码定位:

import sys import os base_dir = getattr(sys, "_MEIPASS", os.path.abspath(".")) model_path = os.path.join(base_dir, "inference_mnist") model = paddle.jit.load(model_path)

sys._MEIPASS是 PyInstaller 在单文件模式下指向临时解压目录的变量,-D模式下不存在,因此回退到程序所在目录。这种写法兼容两种模式,也是 PaddleOCR 便携打包版里常见的路径处理手法。

6. 调参、自检与体积控制的几个实用技巧

6.1 在 zip 包里加一个自检脚本

用户拿到 zip 后不一定跑完整流程,最好内置一个self_check.py,一键验证模型文件、依赖库和推理输出是否正常。

import paddle from paddle.vision.datasets import MNIST model = paddle.jit.load("inference_mnist") model.eval() test_dataset = MNIST(mode="test", download=False) image, label = test_dataset[0] image_tensor = paddle.to_tensor(image.reshape(1, 1, 28, 28).astype("float32")) result = paddle.argmax(model(image_tensor), axis=1).item() print("自检完成,模型预测为", result, "真实标签为", label) assert int(result) == int(label), "自检失败,模型精度异常"

这段代码把 MNIST 测试集第一张图片作为固定样本,模型预测必须和数据集标签一致才算通过。测试集中的第一张样本在 Paddle 原始数据集里基本是一个手写数字 7,训练良好的 LeNet-5 不会在这个简单样本上翻车。如果自检失败,优先检查模型文件和像素归一化是否匹配。

6.2 用混淆矩阵定位易错数字

准确率达到 99% 后,剩下的错误往往集中在形状相近的数字上,比如 3 和 8、4 和 9、7 和 1。单纯看准确率看不到这些问题,可以在测试集上统计混淆矩阵:

import numpy as np confusion = np.zeros((10, 10), dtype=np.int32) with paddle.no_grad(): for images, labels in test_loader: images = paddle.reshape(images, [-1, 1, 28, 28]) preds = paddle.argmax(model(images), axis=1).numpy() for true_label, pred_label in zip(labels.numpy(), preds): confusion[true_label, pred_label] += 1 error_pairs = [] for i in range(10): for j in range(10): if i != j and confusion[i][j] > 0: error_pairs.append((i, j, confusion[i][j])) error_pairs.sort(key=lambda x: x[2], reverse=True) print(error_pairs[:5])

如果看到明显的 4 到 9、3 到 8 的错误,说明模型对局部细节不够敏感。常见做法是增加这些类别的训练样本,或者对训练集做随机旋转和缩放增强。

6.3 减小 zip 体积的简单办法

Paddle 打包后的体积通常不会太小,主要来自动态库。一个直接有效的做法是把模型压缩成半精度存储。MNIST 分类任务对精度损失不敏感,模型参数换成 FP16 后准确率几乎不变,文件体积减少一半。Paddle 提供paddle.save时直接传入numpy.float16类型的权重时可以缩小.pdiparams,但推理时要转换成 float32 才能加载。做法是在训练后导出模型前,手动遍历state_dict并转换数值类型。

state = model.state_dict() for key, value in state.items(): value = value.astype("float16") state[key] = value paddle.save(state, "mnist_lenet_fp16.pdparams")

这个策略适合发布模型而不是训练模型。FP16 参数体积小,但大的 Paddle 运行库才是 zip 体积的主要来源,想要进一步压缩就需要把不需用的 OCR、NLP 模型从依赖中排除。本篇文章场景相对轻量,动态库压缩空间仍存在,但还是要以你的项目实际体积为准。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 22:04:44

地界范围图批量导出实战:模板化出图全流程解析

干了这么多年测绘和GIS内业,我最怕的不是外业跑杆,而是项目收尾时那一堆出图任务。尤其是在土地确权、土地整治、造林工程这类项目里,每个地块都要配一张地界范围图,少则几十张,多则上千张。以前在CAD或者ArcGIS里一张…

作者头像 李华
网站建设 2026/9/14 22:04:42

OpenGL体渲染实战:nii医学数据到Qt/PyQt5集成全指南

提到 OpenGL,很多老图形程序员会心一笑,很多新手则一头雾水。作为一门拥有跨平台影响力的图形 API,OpenGL 从 90 年代活到今天,依然是医学可视化、CAD、仿真、Qt 桌面应用里最常见的技术底座。我在做医学影像渲染和桌面工具时和它…

作者头像 李华
网站建设 2026/9/14 22:03:02

SpringBoot 3.x整合Swagger实现API文档自动化

1. SpringBoot 3.x整合Swagger的必要性在现代Web应用开发中,API文档的维护一直是个痛点。传统的手写文档方式存在更新不及时、格式不统一等问题。Swagger作为一套开源的API文档工具链,通过注解方式自动生成可视化文档,完美解决了这些问题。Sp…

作者头像 李华