简介:基于百度开源的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 paddlepaddle==2.6.1 # 安装 GPU 版本(需要先确认 nvidia-smi 显示的 CUDA 版本) # CUDA 11.8 对应安装命令: # pip install paddlepaddle-gpu==2.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_format='CHW'), Transpose((1, 0, 2)) # 如果数据是 HWC 且模型期望 CHW,需要转置 ]) # 加载训练集和测试集 train_dataset = MNIST(mode='train', transform=transform, download=True) test_dataset = MNIST(mode='test', transform=transform, download=True) print(f"训练集样本数: {len(train_dataset)}") print(f"测试集样本数: {len(test_dataset)}")代码逻辑说明:Normalize把像素值从 [0, 255] 映射到 [-1, 1],mean和std都是 127.5 是因为 255 的一半是 127.5。data_format='CHW'告诉飞桨数据是通道在前还是高宽在前。MNIST 原始数据是 HWC 还是 CHW 取决于版本,加Transpose是为了保险。download=True会在第一次运行时自动下载数据集到~/.cache/paddle/dataset目录。
参数说明:mode='train'和mode='test'分别加载训练集和测试集,训练集有 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_classes=10): super(LeNet5, self).__init__() # 第一个卷积块:1 通道输入(灰度图),6 个输出通道,5x5 卷积核 self.conv1 = nn.Conv2D(in_channels=1, out_channels=6, kernel_size=5, stride=1, padding=2) self.pool1 = nn.MaxPool2D(kernel_size=2, stride=2) # 第二个卷积块:6 通道输入,16 个输出通道 self.conv2 = nn.Conv2D(in_channels=6, out_channels=16, kernel_size=5, stride=1) self.pool2 = nn.MaxPool2D(kernel_size=2, stride=2) # 全连接层:16 * 5 * 5 是池化后的特征图展平维度 self.fc1 = nn.Linear(in_features=16 * 5 * 5, out_features=120) self.fc2 = nn.Linear(in_features=120, out_features=84) self.fc3 = nn.Linear(in_features=84, out_features=num_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_axis=1) x = self.relu(self.fc1(x)) x = self.relu(self.fc2(x)) x = self.fc3(x) return x # 实例化模型 model = LeNet5(num_classes=10)代码逻辑说明:输入 x 的形状是[batch_size, 1, 28, 28],经过第一层卷积后变成[batch_size, 6, 28, 28](因为 padding=2 保持尺寸不变),池化后变成[batch_size, 6, 14, 14]。第二层卷积不设 padding,5×5 卷积核对 14×14 输入做卷积后变成 10×10,池化后变 5×5。所以全连接层的输入维度是 16×5×5=400。
参数说明:第一层卷积的padding=2是为了保持特征图尺寸不变,这样后续全连接层的输入维度好计算。kernel_size=5是经典 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 # 数据加载器,shuffle=True 打乱训练顺序 train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=0) test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=0) # 优化器:Adam,weight_decay 是 L2 正则化 optimizer = paddle.optimizer.Adam(parameters=model.parameters(), learning_rate=LEARNING_RATE, weight_decay=1e-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, axis=1) correct += (preds == labels).sum().item() total += labels.shape[0] total_loss += loss.item() # 每 200 个 batch 打印一次 if batch_id % 200 == 0: print(f"Epoch [{epoch+1}/{EPOCHS}], Batch [{batch_id}], Loss: {loss.item():.4f}") train_acc = correct / total avg_loss = total_loss / len(train_loader) print(f"Epoch [{epoch+1}/{EPOCHS}] 平均损失: {avg_loss:.4f}, 训练准确率: {train_acc:.4f}")代码逻辑说明:optimizer.clear_grad()必须在每次step()之后调用,清空上一次反向传播累积的梯度,否则梯度会累加。paddle.argmax(outputs, axis=1)取每个样本概率最大的类别索引,与标签比较计算准确率。num_workers=0表示不启用多进程加载,Windows 环境下多进程加载容易出问题,设为 0 最稳。
参数说明:weight_decay=1e-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, axis=1) 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( layer=model, path="mnist_lenet_infer", input_spec=[paddle.static.InputSpec(shape=[None, 1, 28, 28], dtype="float32", name="image")] )代码逻辑说明:paddle.jit.save会生成三个文件:.pdmodel(模型结构)、.pdiparams(参数)、.pdiparams.info(附加信息),部署时只需要前两个。input_spec里的None表示 batch 维度可变,推理时可以一次传入任意数量的图片。
参数说明:path是保存路径的前缀,不需要加文件扩展名。dtype="float32"是飞桨推理的默认精度,如果要做 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, dtype=np.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, axis=1).item() prob = paddle.nn.functional.softmax(result, axis=1).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内容:
paddlepaddle==2.6.1 numpy>=1.21 Pillow>=9.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(degrees=10), # 随机旋转 ±10 度 RandomAffine(degrees=0, translate=(0.1, 0.1)), # 随机平移不超过 10% Normalize(mean=[127.5], std=[127.5], data_format='CHW') ]) # 测试时只用归一化,不做增强 test_transform = Compose([ Normalize(mean=[127.5], std=[127.5], data_format='CHW') ])代码逻辑说明:RandomRotation(degrees=10)是随机旋转 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-import=paddle --hidden-import=paddle.nn predict.py参数说明:--onefile表示打包成单个可执行文件,但模型权重文件不会打进去,需要单独分发。--hidden-import强制 PyInstaller 把飞桨模块包含进来,因为飞桨的很多子模块是动态导入的,PyInstaller 的静态分析识别不全。
验证打包是否成功,用命令行跑一次:./dist/predict test_digit.png,如果输出预测数字和概率,说明便携包没问题。这个验证步骤很重要,很多打包完成但运行时报缺库的问题,都能通过这一步暴露出来。
本文还有配套的精品资源,点击获取