ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于PaddlePaddle的手写数字识别与部署全流程实战

基于PaddlePaddle的手写数字识别与部署全流程实战 简介基于百度开源的PaddlePaddle框架实现的手写数字识别项目面向希望入门深度学习和计算机视觉的初学者。项目经助教测试代码可运行能够帮助读者快速复现从数据预处理、CNN模型搭建到训练评估的完整流程并掌握MNIST数据集上的图像分类思路项目结构大致划分为数据准备、模型定义、训练验证与预测输出等模块便于对照学习。资源共12个文件包含Python训练与测试脚本、项目配置XML文件、PNG示例图片、GZ数据压缩包及MD说明文档整体约19.25MB目录结构清晰便于按需查看。已有318人浏览学习运行脚本可直接复现训练效果不需额外修改复杂配置。通过阅读说明文档和运行脚本可进一步理解卷积层、池化层、Softmax分类器及优化器在实际项目中的配合方式并了解如何处理图像数据、划分训练测试集、保存与加载模型等实用技巧。1. 基于 paddle 的手写数字识别为什么值得从零搭一遍拿到一个名为「基于 paddle 的手写数字识别.zip」的项目包先别急着解压跑训练。手写数字识别是 MNIST 任务的标准解法但用 PaddlePaddle 来做和用 PyTorch、TensorFlow 的路径有很大差别——飞桨的数据管道、高层 API 和模型保存方式都有自己的约定直接套别的框架的习惯会踩不少坑。这个标题背后其实藏着一个完整链路数据加载、模型组网、训练调参、推理验证最后才是把整个项目打包成 zip 交付。对于刚接触飞桨的工程师或者需要给离线环境交付一个可复现识别任务的团队来说沿着这条链路走一遍比只看文档有效率得多。这篇文章就按这个顺序把能做起来的方案讲清楚代码直接可用参数含义也拆开说明。2. 手写数字识别任务拆解与 PaddlePaddle 环境准备2.1 先明确识别任务的输入输出再选模型结构手写数字识别的输入是一张灰度图尺寸通常归一化到 28×28像素值范围在 0 到 255 之间。输出是 0 到 9 这十个类别上的概率分布取最大值所在的索引作为预测数字。这是个典型的多分类任务图像尺寸小、类别少、背景相对干净用轻量卷积网络就能达到很高准确率不需要上 ResNet 这类重量级结构。常见做法是用 LeNet-5 的变体。LeNet-5 是 1998 年提出的卷积网络结构包含两个卷积层和三个全连接层参数量小在 MNIST 上表现非常好。PaddlePaddle 的paddle.nn模块里提供了Conv2D、MaxPool2D、Linear等基础组件手写一个 LeNet-5 只需要十几行代码。如果输入图像不是 28×28而是扫描件里裁剪出来的数字块需要先做尺寸归一化因为全连接层的输入维度是固定的。模型结构确定后损失函数用交叉熵优化器用 Adam 或 Momentum。Adam 收敛快Momentum 在某些情况下泛化更好。对于 MNIST 这种简单任务两者差别不大但 Adam 对学习率的敏感度低适合快速验证。2.2 PaddlePaddle 安装与版本确认避免 CUDA 不匹配装飞桨之前先确认两件事Python 版本和 CUDA 版本。PaddlePaddle 2.5 以上版本对 Python 3.8 到 3.12 都有支持但 GPU 版本对 CUDA 的版本要求比较严格。这里给出一套可直接执行的安装命令适用于 Linux 和 Windows 的 GPU 环境# 创建虚拟环境避免污染系统 Python python3 -m venv paddle_env source paddle_env/bin/activate # Windows 下执行 paddle_env\Scripts\activate # 安装 CPU 版本本地调试用 pip install paddlepaddle2.6.1 # 安装 GPU 版本需要先确认 nvidia-smi 显示的 CUDA 版本 # CUDA 11.8 对应安装命令 # pip install paddlepaddle-gpu2.6.1 -i https://www.paddlepaddle.org.cn/packages/stable/cu118/参数说明paddlepaddle是 CPU 版本paddlepaddle-gpu是 GPU 版本cu118表示 CUDA 11.8 的预编译包。如果你的 CUDA 是 12.x需要去飞桨官网查对应的安装源地址。安装完成后用下面的命令验证python -c import paddle; paddle.utils.run_check()如果输出PaddlePaddle is installed successfully说明环境没问题。如果报 CUDA 相关错误优先检查驱动版本和 CUDA 工具包是否匹配再考虑重装。2.3 MNIST 数据集加载与预处理paddle.vision 一条龙PaddlePaddle 在paddle.vision.datasets里内置了 MNIST 数据集不需要手动下载和解析二进制文件。加载代码如下import paddle from paddle.vision.datasets import MNIST from paddle.vision.transforms import Compose, Normalize, Transpose # 定义预处理归一化 调整通道维度顺序 transform Compose([ Normalize(mean[127.5], std[127.5], data_formatCHW), Transpose((1, 0, 2)) # 如果数据是 HWC 且模型期望 CHW需要转置 ]) # 加载训练集和测试集 train_dataset MNIST(modetrain, transformtransform, downloadTrue) test_dataset MNIST(modetest, transformtransform, downloadTrue) print(f训练集样本数: {len(train_dataset)}) print(f测试集样本数: {len(test_dataset)})代码逻辑说明Normalize把像素值从 [0, 255] 映射到 [-1, 1]mean和std都是 127.5 是因为 255 的一半是 127.5。data_formatCHW告诉飞桨数据是通道在前还是高宽在前。MNIST 原始数据是 HWC 还是 CHW 取决于版本加Transpose是为了保险。downloadTrue会在第一次运行时自动下载数据集到~/.cache/paddle/dataset目录。参数说明modetrain和modetest分别加载训练集和测试集训练集有 60000 张测试集有 10000 张。transform参数接收一个 Compose 对象对数据做预处理。注意这里的 transform 会在每次取数据时执行所以不适合放太复杂的操作。3. LeNet-5 模型组网与训练循环paddle 高层 API 还是自写循环3.1 用 paddle.nn 手写 LeNet-5理解每层的维度变化飞桨有两种组网方式一种是paddle.nn.Sequential快速堆叠另一种是继承paddle.nn.Layer自定义前向逻辑。手写数字识别用 Sequential 就够了但为了讲清楚维度变化这里用继承方式写import paddle import paddle.nn as nn class LeNet5(nn.Layer): def __init__(self, num_classes10): super(LeNet5, self).__init__() # 第一个卷积块1 通道输入灰度图6 个输出通道5x5 卷积核 self.conv1 nn.Conv2D(in_channels1, out_channels6, kernel_size5, stride1, padding2) self.pool1 nn.MaxPool2D(kernel_size2, stride2) # 第二个卷积块6 通道输入16 个输出通道 self.conv2 nn.Conv2D(in_channels6, out_channels16, kernel_size5, stride1) self.pool2 nn.MaxPool2D(kernel_size2, stride2) # 全连接层16 * 5 * 5 是池化后的特征图展平维度 self.fc1 nn.Linear(in_features16 * 5 * 5, out_features120) self.fc2 nn.Linear(in_features120, out_features84) self.fc3 nn.Linear(in_features84, out_featuresnum_classes) self.relu nn.ReLU() def forward(self, x): x self.pool1(self.relu(self.conv1(x))) x self.pool2(self.relu(self.conv2(x))) # 展平保留 batch 维度 x paddle.flatten(x, start_axis1) x self.relu(self.fc1(x)) x self.relu(self.fc2(x)) x self.fc3(x) return x # 实例化模型 model LeNet5(num_classes10)代码逻辑说明输入 x 的形状是[batch_size, 1, 28, 28]经过第一层卷积后变成[batch_size, 6, 28, 28]因为 padding2 保持尺寸不变池化后变成[batch_size, 6, 14, 14]。第二层卷积不设 padding5×5 卷积核对 14×14 输入做卷积后变成 10×10池化后变 5×5。所以全连接层的输入维度是 16×5×5400。参数说明第一层卷积的padding2是为了保持特征图尺寸不变这样后续全连接层的输入维度好计算。kernel_size5是经典 LeNet 配置也可以换成 3×3但感受野会小一些对 MNIST 这种大笔画数字影响不大。3.2 训练循环paddle.io.DataLoader 与自定义训练函数飞桨的paddle.io.DataLoader负责把数据集按 batch 打包支持多进程加载。训练循环可以自己写也可以直接用paddle.Model高层 API。为了能看清梯度更新过程手写循环更直观import paddle import paddle.nn.functional as F from paddle.io import DataLoader # 超参数配置 BATCH_SIZE 64 LEARNING_RATE 0.001 EPOCHS 10 # 数据加载器shuffleTrue 打乱训练顺序 train_loader DataLoader(train_dataset, batch_sizeBATCH_SIZE, shuffleTrue, num_workers0) test_loader DataLoader(test_dataset, batch_sizeBATCH_SIZE, shuffleFalse, num_workers0) # 优化器Adamweight_decay 是 L2 正则化 optimizer paddle.optimizer.Adam(parametersmodel.parameters(), learning_rateLEARNING_RATE, weight_decay1e-4) # 训练循环 for epoch in range(EPOCHS): model.train() total_loss 0.0 correct 0 total 0 for batch_id, (images, labels) in enumerate(train_loader): # 前向计算 outputs model(images) loss F.cross_entropy(outputs, labels) # 反向传播与参数更新 loss.backward() optimizer.step() optimizer.clear_grad() # 统计准确率 preds paddle.argmax(outputs, axis1) correct (preds labels).sum().item() total labels.shape[0] total_loss loss.item() # 每 200 个 batch 打印一次 if batch_id % 200 0: print(fEpoch [{epoch1}/{EPOCHS}], Batch [{batch_id}], Loss: {loss.item():.4f}) train_acc correct / total avg_loss total_loss / len(train_loader) print(fEpoch [{epoch1}/{EPOCHS}] 平均损失: {avg_loss:.4f}, 训练准确率: {train_acc:.4f})代码逻辑说明optimizer.clear_grad()必须在每次step()之后调用清空上一次反向传播累积的梯度否则梯度会累加。paddle.argmax(outputs, axis1)取每个样本概率最大的类别索引与标签比较计算准确率。num_workers0表示不启用多进程加载Windows 环境下多进程加载容易出问题设为 0 最稳。参数说明weight_decay1e-4是 L2 正则化系数防止过拟合。MNIST 数据集简单weight_decay设大一点如 1e-3有时反而会掉点。学习率0.001对于 Adam 是个比较保守的起点如果损失震荡严重可以降到 0.0005。3.3 验证逻辑与测试准确率模型收敛的判据训练完每个 epoch 后跑一遍测试集验证模型泛化能力。测试时不需要计算梯度用paddle.no_grad()包裹可以减少内存占用和计算开销def evaluate(model, test_loader): model.eval() correct 0 total 0 with paddle.no_grad(): for images, labels in test_loader: outputs model(images) preds paddle.argmax(outputs, axis1) correct (preds labels).sum().item() total labels.shape[0] return correct / total test_acc evaluate(model, test_loader) print(f测试集准确率: {test_acc:.4f})正常训练 10 个 epoch 后测试准确率应该在 98% 以上99% 也不稀奇。如果低于 97%优先检查数据预处理是否正确特别是归一化参数和通道顺序。model.eval()和model.train()的切换很重要虽然这个模型没有 Dropout 和 BatchNorm但养成习惯总是对的。4. 模型保存与推理部署从训练产物到可用的预测代码4.1 paddle.save 保存参数还是 paddle.jit.save 保存完整模型飞桨有两种模型保存方式选择取决于部署场景。paddle.save只保存参数适合在同一套代码里继续训练或推理paddle.jit.save会把模型结构和参数一起保存成推理模型适合脱离训练代码做部署。# 方式一只保存参数轻量适用于继续训练 paddle.save(model.state_dict(), mnist_lenet.pdparams) # 方式二保存完整推理模型部署用 # 需要先设置 input spec指定输入的形状和类型 paddle.jit.save( layermodel, pathmnist_lenet_infer, input_spec[paddle.static.InputSpec(shape[None, 1, 28, 28], dtypefloat32, nameimage)] )代码逻辑说明paddle.jit.save会生成三个文件.pdmodel模型结构、.pdiparams参数、.pdiparams.info附加信息部署时只需要前两个。input_spec里的None表示 batch 维度可变推理时可以一次传入任意数量的图片。参数说明path是保存路径的前缀不需要加文件扩展名。dtypefloat32是飞桨推理的默认精度如果要做 INT8 量化推理需要额外处理。paddle.jit.save要求模型继承自paddle.nn.Layer并且forward方法只接受 Tensor 参数不能用 Python 原生类型作输入。4.2 推理代码加载模型对单张图片做预测推理时需要把图片从文件读进来做和训练时一样的预处理然后传给模型。下面这段代码完整覆盖了一张灰度图的预测流程import paddle import numpy as np from PIL import Image def preprocess_image(image_path): 读取图片并调整为 28x28归一化到 [-1, 1] img Image.open(image_path).convert(L) # 转灰度图 img img.resize((28, 28), Image.Resampling.LANCZOS) # 缩放 img_array np.array(img, dtypenp.float32) img_array (img_array - 127.5) / 127.5 # 归一化 # 调整形状(28, 28) - (1, 1, 28, 28)分别是 batch、通道、高、宽 img_array img_array[np.newaxis, np.newaxis, :, :] return paddle.to_tensor(img_array) # 加载推理模型 infer_model paddle.jit.load(mnist_lenet_infer) infer_model.eval() # 预测 img_tensor preprocess_image(test_digit.png) with paddle.no_grad(): result infer_model(img_tensor) pred paddle.argmax(result, axis1).item() prob paddle.nn.functional.softmax(result, axis1).numpy()[0] print(f预测数字: {pred}) print(f各数字概率: {prob})代码逻辑说明Image.Resampling.LANCZOS是 PIL 里效果最好的缩放算法适合缩小图片能保留边缘信息。convert(L)确保图片是单通道灰度图如果原图是彩色图不做这步的话通道数就是 3和模型输入不匹配。np.newaxis加维度是因为模型期望 4D 输入而直接读进来的是 2D。参数说明prob是所有 10 个类别的概率值加起来等于 1。如果最大概率对应的数字是 3 但第二大概率接近说明这张图存在歧义实际应用中可以把概率值打印出来辅助判断是否需要人工复核。4.3 打包成 zip 的目录结构含依赖说明与启动脚本项目交付时zip 包里不能只有代码和模型文件还要有依赖清单、说明文档和启动脚本否则接收方很难跑起来。一个标准的目录结构是这样的paddle_mnist/ ├── models/ │ ├── mnist_lenet_infer.pdmodel │ └── mnist_lenet_infer.pdiparams ├── inference/ │ ├── predict.py │ └── preprocess.py ├── train/ │ ├── train.py │ └── model.py ├── requirements.txt └── README.mdrequirements.txt内容paddlepaddle2.6.1 numpy1.21 Pillow9.0参数说明paddlepaddle版本要锁定飞桨的大版本升级会带来 API 变动比如 2.x 的paddle.jit.save在 3.x 里可能就废弃了。Pillow用于图片读取和预处理。README.md 里至少写清楚 Python 版本要求、安装命令和启动命令。5. 模型效果优化数据增强、超参数调整与常见报错排查5.1 数据增强提升泛化但不能破坏数字结构MNIST 数据集本身比较干净但实际场景中的手写数字可能有偏移、粗细不均、噪声等问题。数据增强能在不增加标注成本的前提下扩大样本多样性。飞桨的paddle.vision.transforms提供了RandomRotation、RandomAffine等操作使用时要注意增强幅度不能太大from paddle.vision.transforms import RandomRotation, RandomAffine, Compose, Normalize # 训练时用带增强的 transform train_transform Compose([ RandomRotation(degrees10), # 随机旋转 ±10 度 RandomAffine(degrees0, translate(0.1, 0.1)), # 随机平移不超过 10% Normalize(mean[127.5], std[127.5], data_formatCHW) ]) # 测试时只用归一化不做增强 test_transform Compose([ Normalize(mean[127.5], std[127.5], data_formatCHW) ])代码逻辑说明RandomRotation(degrees10)是随机旋转 10 度超过这个值数字 6 和 9 容易混淆。RandomAffine的translate(0.1, 0.1)表示水平和垂直方向最多平移图像宽高的 10%MNIST 数字本身就在中心平移太大会把数字移出有效区域。参数说明数据增强只加在训练集上测试集必须保持原始分布否则评估结果不客观。增强后的准确率提升可能在 0.3% 到 1% 之间如果基线已经 99%提升空间不大。5.2 超参数调整顺序先调学习率再调 batch size调整超参数时不要同时动多个变量否则无法定位是哪个改动带来的效果变化。我的习惯是先固定 batch size 为 64跑一遍学习率在 [0.001, 0.0005, 0.0001] 下的对比确定学习率后再跑 batch size 在 [32, 64, 128] 下的对比。下表总结了典型问题的排查方向现象可能原因调整方向损失不下降学习率过大或过小学习率尝试 0.0001 或 0.01训练准确率高但测试低过拟合增大 weight_decay加数据增强损失震荡严重batch size 太小增大 batch size 到 128测试集准确率停滞在 95%数据预处理错误检查归一化和通道顺序参数说明学习率决定参数更新的步长过大导致震荡过小导致收敛慢。batch size 影响梯度估计的稳定性越小越容易震荡。这些参数对训练时间的影响是线性的调参时先跑 3 个 epoch 看趋势不用等完整训练。5.3 常见报错维度不匹配、数据类型错误与保存加载不一致最常遇到的报错是维度不匹配提示信息类似Expected shape [*, 1, 28, 28], but received [*, 3, 28, 28]这是因为输入图片没有转成灰度图。处理方式是加一行img img.convert(L)。第二个常见问题是推理时用paddle.load加载.pdparams文件但模型类没有实例化。正确做法是用paddle.jit.load加载完整推理模型或者先建模型再model.set_state_dict(paddle.load(...))。第三个问题是数据类型不一致。训练时输入是paddle.float32但用 OpenCV 读图默认是uint8直接传给模型会报类型错误。解决办法是在预处理里显式转成float32。6. 一个实用技巧把模型集成到 PaddleOCR 风格的便携包中既然热词里反复出现「paddle ocr 便携打包版」和「项目打包」值得说明的是手写数字识别模型完全可以复用 PaddleOCR 项目里的推理流程设计做成一个不依赖完整飞桨开发环境的便携包。PaddleOCR 的推理脚本通常会解耦预处理、模型加载和后处理三个环节手写数字识别也可以这样做。具体做法是用PyInstaller把推理脚本打包成可执行文件把.pdmodel和.pdiparams文件放在同级目录下。PyInstaller 打包飞桨程序时有两个注意点一是需要手动添加飞桨的动态链接库否则运行时报找不到paddle_fluid相关动态库二是在 spec 文件里设置pathex指向飞桨安装目录。打包命令大致如下pyinstaller --onefile --additional-hooks-dir. --hidden-importpaddle --hidden-importpaddle.nn predict.py参数说明--onefile表示打包成单个可执行文件但模型权重文件不会打进去需要单独分发。--hidden-import强制 PyInstaller 把飞桨模块包含进来因为飞桨的很多子模块是动态导入的PyInstaller 的静态分析识别不全。验证打包是否成功用命令行跑一次./dist/predict test_digit.png如果输出预测数字和概率说明便携包没问题。这个验证步骤很重要很多打包完成但运行时报缺库的问题都能通过这一步暴露出来。本文还有配套的精品资源点击获取
返回列表