简介:基于飞桨(PaddlePaddle)的手写数字识别项目,面向深度学习和计算机视觉初学者,帮助快速上手图像分类任务。项目以MNIST数据集为例,完整涵盖数据预处理、卷积神经网络(CNN)构建、模型训练与评估等关键环节,具备清晰的代码结构和运行说明,便于理解与实践。压缩包共12个文件,包括Python训练与测试脚本、图片样本、项目配置文件以及README文档等,整体大小约19.25MB,文件类型覆盖py、xml、png、md、gz等,适合作为入门实践参考。目前已有318人学习,是新手了解飞桨与CNN的实用资料。项目提供完整的训练与测试代码及说明文档,读者通过实际运行可直接观察模型效果,同时可结合代码理解卷积、池化、全连接层的具体作用,并学习优化器选择、超参数调整等实用技巧,为后续更复杂的深度学习任务打下基础。
1. 手写数字识别不只是“Hello World”,Paddle 项目的真实复杂度
不要以为手写数字识别只是个入门玩具。在 PaddlePaddle 里跑通 MNIST,你需要同时处理数据加载、网络构建、训练循环、验证评估和模型导出,每一步都有值得抠的细节。这个项目压缩包里有 Train.py、Test.py、mnist.json.gz 和 README.md,代码被助教验证过,但如果你只是双击运行然后看个准确率,会错过很多真正影响结果的东西。适合想从头理清 Paddle 数据流的人,也适合准备迁移到 OCR 这类真实场景的开发者。下面从数据组织开始拆。
2. 从 MNIST 到 Paddle:数据组织与预处理方案
在 PaddlePaddle 里,MNIST 不是简单的图片文件,而是一套可以被 DataLoader 直接消费的张量集合。项目中的 mnist.json.gz 是经过序列化后的 MNIST 变体,里面是 JSON 格式的压缩数据,这种格式与paddle.vision.datasets.MNIST默认的 pickle 缓存不同,需要自己解析或者转换。理解这两条路径,才能看懂 Train.py 里到底在读什么。
2.1 两种加载方式:直接 API 与手动解析
2.1.1 使用 paddle.vision.datasets.MNIST
最省事的方式是让 Paddle 自己下载并缓存数据:
import paddle from paddle.vision.datasets import MNIST from paddle.vision.transforms import Normalize transform = Normalize(mean=[0.5], std=[0.5]) train_dataset = MNIST(mode='train', transform=transform) test_dataset = MNIST(mode='test', transform=transform)这里Normalize把 0 到 255 的像素值线性映射到 -1 到 1。MNIST 原始数据是 28x28 的灰度图,通道数为 1,Paddle 在返回时会自动补成[N, 1, 28, 28]或[28, 28],取决于你使用的 API 版本。如果你的 Paddle 版本较新,训练时通常需要手动unsqueeze加通道维,或者在网络第一层使用paddle.nn.Conv2D时传入data_format='NCHW'。这个变换的均值 0.5 和标准差 0.5 不是统计出来的,而是固定取像素范围的中点和半宽,目的是让输入落在激活函数梯度敏感的区间。
2.1.2 从 mnist.json.gz 解析
项目自带的 mnist.json.gz 是另一种格式,常见做法是用gzip和json手动读取:
import gzip import json import numpy as np with gzip.open('mnist.json.gz', 'rb') as f: data = json.loads(f.read().decode('utf-8')) train_images = np.array(data['train_images'], dtype='float32') / 255.0 train_labels = np.array(data['train_labels'], dtype='int64') test_images = np.array(data['test_images'], dtype='float32') / 255.0 test_labels = np.array(data['test_labels'], dtype='int64') # 将[N, 28*28]转为[N, 1, 28, 28] train_images = train_images.reshape(-1, 1, 28, 28) test_images = test_images.reshape(-1, 1, 28, 28)这段代码把 JSON 里的字典拆成了四个 numpy 数组。注意train_images原本可能是 784 长度的扁平向量,所以必须reshape成四维张量才能输入Conv2D。dtype设为float32是因为 Paddle 默认的浮点类型是 float32,如果你用 float64,参数数量和显存占用会翻倍,而且很多算子没有 float64 的 CPU 实现。标签保持int64,这是paddle.nn.CrossEntropyLoss要求的索引类型。我倾向于如果项目带了 json.gz,就优先用它,因为不依赖网络下载,而且可以自己控制预处理顺序。对比两种方式,API 加载省事但需要联网;JSON 解析适合离线环境,也能顺便看清数据内部结构。无论哪条路,都要注意训练集和测试集的归一化参数必须统一,不能在测试时用另一种均值。
2.2 数据增强的边界:MNIST 该不该用随机变换
很多人在 MNIST 上尝试随机旋转、平移,结果发现准确率反而下降。原因是 MNIST 本身是居中、归一化良好的手写数字,背景非常干净。像RandomCrop、RandomRotation这类增强手段会破坏数字的笔画结构,导致模型需要更大的容量才能拟合。我一般只在训练时加入轻微的RandomAffine,旋转角度控制在 ±10 度以内,平移不超过 2 像素,并且只在模型出现过拟合时才开。这里需要区分“数据增强”和“数据规范化”。规范化是必须的,增强是可选的。在后续真实 OCR 场景中,文档图片往往有倾斜、光照不均的问题,增强才变得必要。所以在 MNIST 项目里,如果 README 没有特别说明增强策略,默认只做 Normalize 即可。
2.3 常见坑:数据维度和 dtype 不一致
第一个坑是 train 和 test 的 shape 不一致。有些旧代码从 npz 文件里读出来的测试集是(N, 28, 28),训练集是(N, 784),不统一就会在训练时报matmul shape mismatch。第二个坑是标签类型是 float。如果你用paddle.to_tensor(labels, dtype='float32')交给 CrossEntropyLoss,Paddle 会直接报错。解决方式是统一用dtype='int64'。第三个坑是数据归一化时,如果先减均值再除以 std,但 mean 和 std 是 Python 标量而非数组,在 Paddle 张量上会触发广播,行为正确,但可读性差。我建议将mean和std显式定义为np.float32,避免意外变成 float64。
提示:如果你的 GPU 显存很小,可以直接用
paddle.io.DataLoader的num_workers=0避免多进程内存复制。
3. CNN 模型设计:从 LeNet 到 Paddle 的层间参数对比
MNIST 分类的经典基线是全连接网络,但全连接把 28x28 的像素拉成 784 维向量,丢失了空间邻域结构。卷积神经网络用局部感受野和权值共享,将参数数量从几百万降到几万,同时使特征具有平移不变性。这个项目里最可能采用的网络就是 LeNet-5 的变体,它由两个卷积层、两个池化层和三个全连接层组成。理解每一层输出尺寸的变化,就能随意改动网络结构而不出错。
3.1 为什么选 CNN 而不是全连接
直接使用paddle.nn.Linear(784, 10)也能达到 95% 左右的准确率,但需要更大的数据集和更多的 epoch 才能收敛。CNN 的卷积核在浅层提取边缘、角点,在深层组合成数字部件,这种层次化特征与人类视觉系统类似。对于 28x28 的小图,LeNet-5 这样的浅层网络已经足够,不必上 ResNet。ResNet 的残差结构在 ImageNet 上有效,但 MNIST 分类并不需要学习上百层的特征,盲目加深反而增加过拟合风险。
3.2 一个可复现的 Paddle CNN 实现
3.2.1 网络结构参数表
下面是一个常见的 LeNet 变体结构,输入形状为[N, 1, 28, 28]:
| 层名称 | 类型 | 核大小/步长 | 输出通道 | 输出尺寸 | 参数量(约) |
|---|---|---|---|---|---|
| conv1 | Conv2D | 5x5 / 1 | 6 | [N,6,24,24] | 1.5K |
| pool1 | MaxPool2D | 2x2 / 2 | 6 | [N,6,12,12] | 0 |
| conv2 | Conv2D | 5x5 / 1 | 16 | [N,16,8,8] | 2.4K |
| pool2 | MaxPool2D | 2x2 / 2 | 16 | [N,16,4,4] | 0 |
| fc1 | Linear | - | 120 | [N,120] | 30.7K |
| fc2 | Linear | - | 84 | [N,84] | 10.2K |
| fc3 | Linear | - | 10 | [N,10] | 0.8K |
参数量总计约 4.6 万。注意第一层卷积后尺寸从 28 变成 24,说明没有 padding;池化层采用 2x2 窗口、步长 2,尺寸减半。如果你想把尺寸保持为 28,可以在paddle.nn.Conv2D里设置padding=2。
3.2.2 模型代码
import paddle import paddle.nn as nn class LeNet(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, padding=0), nn.ReLU(), nn.MaxPool2D(kernel_size=2, stride=2), ) self.classifier = nn.Sequential( nn.Linear(16 * 4 * 4, 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, start_axis=1) x = self.classifier(x) return x这里paddle.flatten的start_axis=1表示从通道维开始展平,保留 batch 维。特征提取器的输出是[N,16,4,4],展平后变成[N,256],再进入全连接。注意nn.Sequential在 Paddle 中按顺序执行,中间不能插入paddle.flatten这样的函数,所以我在 forward 里单独调用了展平。如果你习惯把整个网络写在forward里,也可以不依赖Sequential。
3.3 卷积核、池化与 Dropout 的选择逻辑
5x5 卷积核是 LeNet 的默认选择,在 28x28 小图上有足够的感受野。如果改用 3x3,输出尺寸变为 26x26,池化后 13x13,第二层卷积后 11x11,池化后 5x5,特征图尺寸偏大,全连接输入维度变成 1655=400,参数量略增。3x3 的优势是可以在同样的层数下堆叠更多非线性,但对 MNIST 来说收益不明显。池化层我这里选MaxPool2D,它保留最强烈的响应,适合边缘和角点检测。平均池化会更平滑,但容易钝化笔画信息。Dropout 通常放在全连接层之间,而不是卷积层后,因为卷积层参数量小,过拟合风险低。如果要加 Dropout,放在fc2之后效果较好,丢弃率设为 0.5。
4. 训练流程与超参数调优:Train.py 中的关键细节
模型结构确定后,训练脚本才是决定最终准确率的关键。Train.py 里通常包含学习率、batch size、epoch、优化器、损失函数等设置。这些超参数相互影响,初学者最常见的错误是凭感觉选学习率,导致 loss 不下降或震荡。下面先给出一个标准的训练循环,再解释每个参数的含义。
4.1 损失函数与优化器选择
手写数字识别是 10 类分类问题,标签是互斥的整数索引,所以损失函数必须用paddle.nn.CrossEntropyLoss。这个损失函数内部会先做 softmax,所以网络最后一层不需要额外激活。如果你看到有人用了 Softmax 再加上 CrossEntropyLoss,那就是重复计算,数值上会不稳定。优化器我建议先用 SGD 加 momentum,学习率 0.01,momentum 0.9。Adam 收敛快,但最终精度往往比不过调优后的 SGD。MNIST 数据规模小,SGD 的随机噪声反而能帮助跳出局部极小点。
4.2 训练循环实现
4.2.1 手动训练循环
import paddle from paddle.io import DataLoader train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False) model = LeNet() criterion = paddle.nn.CrossEntropyLoss() optimizer = paddle.optimizer.Momentum( learning_rate=0.01, momentum=0.9, parameters=model.parameters() ) epochs = 10 for epoch in range(epochs): model.train() train_loss = 0.0 total_correct = 0 total_count = 0 for images, labels in train_loader: logits = model(images) loss = criterion(logits, labels) loss.backward() optimizer.step() optimizer.clear_grad() preds = paddle.argmax(logits, axis=1) total_correct += (preds == labels).numpy().sum() total_count += labels.shape[0] train_loss += loss.numpy()[0] accuracy = total_correct / total_count print(f'Epoch {epoch+1}, loss: {train_loss/len(train_loader):.4f}, train_acc: {accuracy:.4f}')optimizer.clear_grad()必须放在step()之后,否则下一次迭代的梯度会累积。Paddle 2.x 中也可以调用optimizer.clear_gradients(),但clear_grad是更通用的写法。这里的准确率是训练集上的 batch 正确率,不是最终评估指标。注意preds == labels返回的是 bool 数组,在 numpy 里 sum 会转成整数,但如果你在 Paddle 张量上直接.sum(),必须用cast('float32')后再取值。
4.2.2 使用 paddle.Model 高级 API
更简洁的方式是用paddle.Model封装,适合快速验证:
model = LeNet() model = paddle.Model(model) model.prepare( optimizer=paddle.optimizer.Adam(learning_rate=0.001, parameters=model.parameters()), loss=paddle.nn.CrossEntropyLoss(), metrics=paddle.metric.Accuracy() ) model.fit(train_dataset, test_dataset, epochs=10, batch_size=64, verbose=1)这里有个细节:model.parameters()在 prepare 之前必须先初始化模型,否则参数为空。另外model.fit会自动做数据集的类别平衡处理吗?不会。如果你的分类不均衡,需要自定义生成器。对于 MNIST 这种均衡数据集,直接用即可。
4.3 超参数表与影响
| 超参数 | 常见范围 | 影响 | 典型调试策略 |
|---|---|---|---|
| learning_rate | 0.001 ~ 0.1 | 过大 loss 震荡,过小收敛慢 | 先看前 100 步 loss 曲线 |
| batch_size | 32 ~ 128 | 影响梯度的稳定性和训练速度 | 小 batch 适合 GPU 显存小的场景 |
| epochs | 5 ~ 20 | 过多容易过拟合 | 用验证集早停 |
| momentum | 0.9 ~ 0.99 | 加速收敛,抑制震荡 | 配合 SGD 使用 |
| weight_decay | 1e-4 ~ 5e-4 | 正则化,防止权重过大 | 过拟合时开启 |
一个实用的经验是从 batch_size=64、learning_rate=0.01、epochs=10 开始,观察 loss 是否在前几个 batch 内明显下降。如果 loss 不变,可能是学习率太小或者数据归一化错误;如果 loss 变成 NaN,通常是学习率太大,或者输入有 NaN 值。
4.4 过拟合判断与早停
在训练集上准确率超过 99% 而测试集只有 97%,说明过拟合。MNIST 数据量足够大,模型也不深,通常不容易过拟合。但如果数据增强过度或者网络设计太宽,也会出现过拟合。我一般会在每个 epoch 结束后计算验证集准确率,保存验证集最优的模型参数。
best_acc = 0.0 wait = 0 for epoch in range(epochs): # 训练循环... val_acc = evaluate(test_loader, model) if val_acc > best_acc: paddle.save(model.state_dict(), 'best_model.pdparams') best_acc = val_acc wait = 0 else: wait += 1 if wait >= 3: break这个早停策略不需要额外库。注意保存的是state_dict而不是整个模型,这样加载时可以自由改变网络结构,只要参数名称一致。
5. 模型评估、导出与数字识别进阶验证
测试集上的准确率不足以说明模型上线后的表现,还需要看每一类的召回率和容易混淆的样本对。Test.py 里如果只打印了整体 accuracy,我建议你把它扩展成混淆矩阵分析。这一章给出评估脚本的关键片段,并介绍如何把训练好的模型导出为推理格式。
5.1 Test.py 的评估指标与混淆矩阵
import numpy as np from sklearn.metrics import confusion_matrix, classification_report def evaluate(model, test_loader): model.eval() all_preds = [] all_labels = [] with paddle.no_grad(): for images, labels in test_loader: logits = model(images) preds = paddle.argmax(logits, axis=1).numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) report = classification_report(all_labels, all_preds, digits=4) return cm, reportpaddle.no_grad()在推理时关闭梯度计算,减少显存占用。对于 MNIST 测试集 1 万张,batch 可以设大一点,比如 512。混淆矩阵的对角线是每类的正确预测数,非对角线最大项通常对应“4”和“9”、“3”和“8”这类结构相近的数字。如果某一类的召回率明显低于其他类,可以针对性增加该类别的训练样本,或者对混淆最严重的类别做数据增强。
5.2 保存与加载:从动态图到推理部署
Paddle 2.x 默认是动态图模式,保存模型可以只保存参数,也可以保存完整模型。用于继续训练时用state_dict,用于部署时用paddle.jit.save转换为静态图。
# 保存静态图模型 paddle.jit.save(model, 'inference/inference_model', input_spec=[paddle.static.InputSpec( shape=[None, 1, 28, 28], dtype='float32')])input_spec里的None表示 batch 维度可变。生成的文件包括.pdmodel和.pdiparams,前者是计算图,后者是权重。加载时可以用paddle.jit.load,然后调用它的forward方法。注意静态图模式下,输入张量的形状必须严格匹配,batch 维度之外,通道数、高度、宽度不能随意改。
5.3 提高识别鲁棒性的一个具体技巧:多模型集成
单个 LeNet 在 MNIST 上的准确率大约 99.2%,想要再往上推,可以训练几个不同初始种子或不同结构的模型,在推理时对 logits 求平均。这个技巧在深度学习竞赛里很常用,但很多人忘了集成时每个模型的输出要先做温度缩放,否则某个模型的置信度过高会主导投票。
def ensemble_predict(models, images): logits_sum = None for model in models: logits = model(images) if logits_sum is None: logits_sum = logits else: logits_sum += logits return paddle.argmax(logits_sum, axis=1)这里直接平均 logits,相当于对每个类别的得分取平均。如果模型数量较少(2~3 个),也能稳定提升 0.1~0.3 个百分点。集成后模型的显存占用会线性增加,在边缘设备上可能不可行。对于手写数字识别这种小模型,集成仍是一个可行的方案。
5.4 一个指向真实场景的验证方法
如果你手边有自己拍的手写数字照片,直接用训练好的模型预测会失败,因为照片的背景、光照、笔画粗细与 MNIST 差异太大。正确的做法是把照片裁成数字区域,缩放到 28x28,再反色和归一化。
import cv2 import numpy as np def preprocess_photo(img_path): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) _, thresh = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV) contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) x, y, w, h = cv2.boundingRect(contours[0]) digit = thresh[y:y+h, x:x+w] digit = cv2.resize(digit, (28, 28), interpolation=cv2.INTER_AREA) digit = digit.astype('float32') / 255.0 return digit.reshape(1, 1, 28, 28)THRESH_BINARY_INV把白底黑字变成黑底白字,和 MNIST 保持一致。如果照片里包含多个数字,需要按轮廓的 x 坐标排序再逐个切分。这样预处理后的图片送入模型,效果才会接近训练集分布。
本文还有配套的精品资源,点击获取