ARTICLE DETAIL

资讯详情

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

手写汉字识别实战:基于PyTorch的DCN模型训练与避坑指南

手写汉字识别实战:基于PyTorch的DCN模型训练与避坑指南 简介面向手写汉字识别入门学习者与机器学习开发者的深度卷积网络DCN实现脚本。资源聚焦于利用卷积神经网络对汉字图像进行分类识别涵盖数据加载、图像预处理、DCN模型搭建、训练循环及评估等关键环节并针对汉字多样性、结构复杂与书写风格差异提供了可供调整的代码框架适合希望快速上手计算机视觉与汉字识别任务的读者参考。压缩包仅包含1个Python文件整体大小1KB结构精简便于直接阅读和修改。已有177人学习浏览适合用作课程设计、课题实验或技术验证的起点。虽然包体很小但代码逻辑完整能够展示从MNIST式数据组织到DCN训练的基础流程对于想了解手写汉字识别如何落地的新手是一份可供拆解和扩展的实用脚本。1. 手写汉字识别MNIST 跑通了不代表汉字能跑通手写汉字识别和手写数字识别之间隔着一道坎这道坎不是模型深几层就能跨过去的。MNIST 只有 10 类、图像结构简单而常用汉字动辄几千类笔画结构、相似字形、书写风格差异全堆在一起。这个 chinese_test.zip 压缩包就是围绕这件事拆的一套可跑通的完整案例一个 chinese_test.py 脚本配合整理好的手写汉字样本数据用深度卷积网络DCN完成从数据加载、模型定义、训练到评估的完整流程。它解决的痛点很直接当你不想从零搭数据管道、又需要一份能改能跑的基线代码时直接在这套代码上换数据、调参省掉大量前期工程时间。适合正在做 OCR 入门、毕业设计、课程项目或者刚接触深度卷积网络、想把手写数字识别经验迁移到汉字场景的从业者。2. 数据和预处理把 MNIST 的流程搬到汉字上关键在三个细节2.1 chinese_test.zip 里到底装了什么脚本怎么读数据拿到压缩包后先看数据组织方式。常见做法是把样本按类别分文件夹存放比如 data/train/ 下面每个文件夹对应一个汉字文件夹名就是标签data/test/ 同理。chinese_test.py 里通常用 torchvision.datasets.ImageFolder 或者手动 os.listdir 遍历来加载这样的目录结构。ImageFolder 的好处是自动把子文件夹名映射成整数标签省去手写标签编码的环节。from torchvision import datasets, transforms # 训练集读取 data/train 目录子文件夹名作为类别标签 transform transforms.Compose([ transforms.Grayscale(num_output_channels1), # 转灰度统一单通道 transforms.Resize((64, 64)), # 统一尺寸汉字需要比数字更大的画布 transforms.ToTensor(), # 转 Tensor像素值归一到 [0,1] transforms.Normalize((0.5,), (0.5,)) # 标准化到 [-1,1]加速收敛 ]) train_data datasets.ImageFolder(data/train, transformtransform) test_data datasets.ImageFolder(data/test, transformtransform) train_loader torch.utils.data.DataLoader(train_data, batch_size64, shuffleTrue, num_workers2) test_loader torch.utils.data.DataLoader(test_data, batch_size64, shuffleFalse)这段代码的逻辑是先定义一串预处理变换再把 ImageFolder 读进来的每一张图依次经过灰度化、缩放、张量化和标准化。灰度化是因为手写汉字识别和 MNIST 一样颜色信息不重要反而会增加计算量。Resize 到 64×64 而不是 MNIST 的 28×28是因为汉字笔画密度高28×28 会把细节完全抹掉这一点和手写数字识别有本质区别。标准化用的均值 0.5、标准差 0.5对所有像素做统一线性变换不影响笔画结构。2.2 标签映射表和样本均衡汉字场景最容易翻车的环节ImageFolder 会自动生成 class_to_idx 映射比如 {“的”: 0, “一”: 1, “是”: 2, ...}。这个映射在训练和推理时必须保持一致否则模型训练时学的类别顺序和推理时对不上。建议训练完直接把映射表存成 JSON 或者 pickle 文件推理时重新加载。手写汉字数据集里有一个隐蔽问题样本分布极不均衡。常用的字可能有几百个样本生僻字可能只有几十个。直接用原始分布训练模型会对高频字过拟合低频字几乎学不到。我一般会在 DataLoader 里加 WeightedRandomSampler让每个类别每个 epoch 被采样到的概率大致相同。import torch from torch.utils.data.sampler import WeightedRandomSampler from collections import Counter # 统计每个类别的样本数计算权重 labels [label for _, label in train_data.samples] count Counter(labels) weights 1.0 / torch.tensor([count[label] for label in labels], dtypetorch.float) sampler WeightedRandomSampler(weights, num_sampleslen(labels), replacementTrue) train_loader torch.utils.data.DataLoader(train_data, batch_size64, samplersampler)这里的原理是权重和样本数成反比样本少的类别权重高被抽中的概率大从而缓解类别不平衡。参数 replacementTrue 表示允许重复采样保证每个 epoch 都能凑够 num_samples 个样本。注意加了 sampler 之后就不能再设 shuffleTrue两者互斥。2.3 数据增强手写汉字的多样性必须靠增强来模拟手写汉字识别的难点在于同一汉字有大量书写变体笔画粗细不同、位置偏移、倾斜角度不同、甚至潦草程度不同。如果模型只在原始样本上训练遇到没见过的书写风格基本就废了。数据增强是解决这个问题的核心手段不是锦上添花是必需品。train_transform transforms.Compose([ transforms.Grayscale(num_output_channels1), transforms.Resize((64, 64)), transforms.RandomAffine(degrees15, translate(0.1, 0.1), scale(0.9, 1.1)), transforms.RandomPerspective(distortion_scale0.2, p0.5), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ])RandomAffine 的参数含义degrees15 表示随机旋转范围在正负 15 度之间这个幅度模拟手写时的歪斜translate(0.1, 0.1) 表示在水平和垂直方向各最多平移 10% 的像素距离模拟书写位置偏移scale(0.9, 1.1) 表示缩放范围在 0.9 到 1.1 倍之间模拟字的大小差异。RandomPerspective 的 distortion_scale0.2 模拟透视形变等于模拟纸张不平整或拍摄角度带来的变形。注意增强只加在训练集测试集保持原始变换不变否则评估结果不可信。3. DCN 模型设计为什么不能直接套 LeNet要加哪些关键组件3.1 深度卷积网络处理汉字的原理和数字识别有什么本质区别深度卷积网络DCN在图像任务里的核心能力是分层特征提取浅层卷积核学到的边缘、角点这类低阶特征深层卷积核在此基础上组合出笔画结构、部首布局这类高阶语义特征。手写数字识别用 LeNet 就能跑出 99% 以上的准确率因为数字只有 10 类、结构简单浅层特征就足够区分。但汉字的类别数多、结构复杂度高、大量相似字之间的差异极其细微比如“己”“已”“巳”这三个字只在竖弯钩的开口幅度上有区别。这种细粒度差异要求模型有更强的特征表达能力单纯加深网络层数或者加宽通道数不够还需要配合正则化手段防止过拟合。3.2 一个能直接跑的手写汉字 DCN 结构我一般会用类似 VGG 风格的堆叠结构多个卷积块串联每个卷积块由两个 3×3 卷积加 BN 加 ReLU 组成再接 2×2 最大池化下采样。通道数从 64 逐步翻倍到 256最后接全局平均池化和全连接层。import torch.nn as nn class HandwritingCNN(nn.Module): def __init__(self, num_classes): super().__init__() self.features nn.Sequential( # 卷积块 1 1 - 64 通道输出 32x32 nn.Conv2d(1, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 卷积块 2 64 - 128 通道输出 16x16 nn.Conv2d(128, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.Conv2d(128, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 卷积块 3 128 - 256 通道输出 8x8 nn.Conv2d(256, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), # 全局平均池化输出 1x1 nn.Flatten(), nn.Dropout(0.5), nn.Linear(256, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(512, num_classes) ) def forward(self, x): return self.classifier(self.features(x))每个组件的选择都有明确理由。卷积核固定 3×3 是因为两个 3×3 堆叠的感受野等价于一个 5×5但参数量更小、非线性更强。每个卷积后接 BN 层作用是稳定中间层的分布让模型能用更大的学习率训练而不发散。MaxPool2d(2) 把特征图尺寸减半通过逐步缩小空间分辨率换取通道维度的特征丰富度。AdaptiveAvgPool2d((1,1)) 把任意尺寸的输入特征图压成 1×1这样全连接层的输入维度固定为 256不会因为输入尺寸变化而报错。Dropout 0.5 和 0.3 分别作用于全连接层随机丢弃部分神经元强迫模型学到更鲁棒的特征而非依赖特定神经元的组合。3.3 BatchNorm 和 Dropout 的配合以及参数怎么调这里有个经验细节卷积层用 BN 做正则化全连接层用 Dropout 做正则化两者互补但不重叠。如果全连接层也加 BN效果通常不好——BN 依赖 batch 内的统计量而全连接层参数量大、容易过拟合Dropout 更直接有效。Dropout 比例是最常调的参数之一数据量少就调大0.5-0.6数据量大可以调小0.3-0.4不要低于 0.2否则正则化作用可以忽略。num_classes 这个参数直接决定最后一层输出的维度它必须和 ImageFolder 识别出的类别数一致也就是 len(train_data.classes)。如果这里写死了而数据集的类别数变了训练时 loss 会报维度不匹配的错这是个极其常见的翻车点。4. 训练与推理损失函数、优化器和模型保存的完整闭环4.1 损失函数和优化器的选择以及学习率的设置逻辑手写汉字识别是一个多分类任务损失函数用交叉熵CrossEntropyLoss这是分类问题的标准选择内部已经包含了 softmax所以模型最后一层不需要额外加 softmax 激活。优化器选择 Adam 或 SGD momentum。我的习惯是先试 Adam lr1e-3这是深度学习任务里一个相对安全的起点如果训练曲线不够平稳再换 SGD momentum0.9 lr1e-2配合学习率衰减。import torch.optim as optim from torch.optim.lr_scheduler import StepLR criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler StepLR(optimizer, step_size10, gamma0.1) for epoch in range(epochs): model.train() running_loss 0.0 for inputs, labels in train_loader: optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) scheduler.step() # 每个 epoch 结束后跑一次验证集 model.eval() correct 0 total 0 with torch.no_grad(): for inputs, labels in test_loader: outputs model(inputs) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() print(fEpoch {epoch1}, Loss: {running_loss/len(train_data):.4f}, Acc: {100*correct/total:.2f}%)这段循环里有几个关键点。optimizer.zero_grad() 必须放在 forward 之前作用是清空上一步的梯度否则梯度会在不同 batch 之间累加。scheduler.step() 放在每个 epoch 结束后按步长调整学习率。model.train() 和 model.eval() 的切换是必须的train 模式启用 Dropout 和 BN 的 batch 统计eval 模式关闭 Dropout 并使用 BN 的 running_mean、running_var不切换会导致预测结果波动很大。torch.no_grad() 告诉 PyTorch 验证阶段不需要计算梯度省显存也加速。4.2 学习率衰减策略和训练轮数的判断方法StepLR 的参数含义step_size10 表示每 10 个 epoch 学习率乘以 gamma 一次这里就是每 10 轮衰减到原来的 0.1。这样做的逻辑是训练前期用大学习率快速搜索参数空间后期用小学习率在局部最优附近精细调整。如果 loss 曲线在后期震荡不下降可以手动把学习率调到原来的 1/10 继续训这比从头改衰减策略更快。训练轮数怎么判断我一般盯两个指标验证集准确率是否还在上升、训练集 loss 是否持续下降。当验证集准确率连续 5-10 个 epoch 不再提升就停止训练这就是早停策略。也可以用 PyTorch 的 ReduceLROnPlateau 代替 StepLR它会在 loss 不降的时候自动降低学习率省去手动调 step_size 的麻烦但监控指标要设为验证集 loss 而不是训练集 loss。4.3 模型保存和加载的完整流程别把标准化的坑带进推理训练完成后需要保存三样东西模型权重、类别映射表、预处理方案。这三样在推理阶段缺一不可少了任何一个都会出问题。import json # 保存模型权重和类别映射 torch.save(model.state_dict(), cnn_handwriting.pth) with open(class_to_idx.json, w) as f: json.dump(train_data.class_to_idx, f) # 推理时加载 model HandwritingCNN(num_classeslen(train_data.classes)) model.load_state_dict(torch.load(cnn_handwriting.pth)) model.eval() # 单张图片推理 from PIL import Image img Image.open(test_sample.png).convert(L) img img.resize((64, 64)) img_tensor transform(img).unsqueeze(0) # 增加 batch 维度 with torch.no_grad(): output model(img_tensor) pred_idx torch.argmax(output, dim1).item() # 把整数标签映射回汉字 idx_to_class {v: k for k, v in json.load(open(class_to_idx.json)).items()} print(识别结果:, idx_to_class[pred_idx])这里有几个容易踩的坑。load_state_dict 前必须重新实例化一个结构完全相同的模型结构不一致会报 mismatch 错误。推理时 unsqueeze(0) 是把单张图片变成 batch_size1 的输入因为模型期望的输入是四维张量 (batch, channel, height, width)。推理阶段一定要加 model.eval()否则 BN 层会继续更新 running stats预测结果会有几率的偏差。预处理 transform 必须和训练时完全一致包括 Resize 尺寸、Normalize 的均值和标准差否则推理效果会有肉眼可见的下降。5. 避坑记录手写汉字识别最容易翻车的五个地方5.1 训练 loss 降得很快但验证集准确率卡在 30% 左右现象训练集 loss 稳定下降每个 epoch 都在变小但验证集准确率始终在 30% 上下波动上不去。原因这是典型的过拟合或类别不均衡问题。手写汉字类别数多如果每类样本量不大模型会把训练集里的书写风格背下来而不是学到泛化的笔画结构验证集上自然表现差。另一个常见原因是 2.2 节说的类别不均衡没有处理模型只学会了高频字。解决先加数据增强特别是 RandomAffine 的旋转和平移再加 Dropout 比例到 0.5 以上最后用 WeightedRandomSampler 做类别均衡。按这个顺序调整后大多数情况验证集准确率能显著提升。5.2 “己”“已”“巳”这类形近字永远分不清现象整体准确率不错但混淆矩阵里相似字之间互相认错怎么调参都改善不了。原因这些字的差异在笔画末端的闭合程度和开口方向普通卷积核很难捕捉这种细粒度差异。加上数据增强的参数太激进随机旋转 15 度可能把“已”变得像“己”反而增加了混淆。解决单独收集相似字数据做针对性增强。我一般会做一个相似字组表对组内的字使用更小的旋转角度或者用弹性形变模拟手写时的自然变化而不是全局加大增强强度。更有效的做法是在数据预处理阶段加笔画密度图作为额外通道让模型能感知笔画走向信息这一步能明显减少形近字错误。5.3 训练时 GPU 显存 OOM或者训练速度极慢现象batch_size 设成 64 直接 OOM或者一个 epoch 要跑很久完全没法迭代。原因输入图像改成 64×64 后显存占用比 MNIST 的 28×28 大了 5 倍多再加上模型通道数较大显存直接打满。另一个原因是全连接层的参数量过大——如果用了 Flatten 接全连接最后一层卷积输出的 feature map 经过 Flatten 后维度是 256×8×816384全连接层参数瞬间爆炸。解决一是减小 batch_size 到 16 或 32二是把全连接层改成 AdaptiveAvgPool2d 后再接全连接参数量从几千万降到几十万三是输入尺寸可以降到 48×48 试一下汉字识别用 48 也能跑出可接受的结果省 40% 的显存。5.4 数据集目录是中文路径PIL 和 PyTorch 读不到图片现象数据放在“C:\用户\张三\data\”这种路径下程序报错 FileNotFoundError但文件明明存在。原因Windows 下 Python 的默认编码对中文路径处理有兼容性问题PIL 和部分 PyTorch 的底层库在读取中文路径时会乱码或找不到文件。解决最稳妥的办法是把整个工程和数据放在纯英文路径下这是治本。如果数据路径不能改可以用 pathlib 代替字符串路径或者手动 os.chdir 切到英文目录再运行。不要尝试在代码里做编码转换补丁容易引入新问题。5.5 保存模型后推理结果和训练时完全对不上现象训练时验证集准确率 90%保存模型后单独加载推理同一个样本的结果变了而且明显是错的。原因推理代码里少了 model.eval()。在训练模式下Dropout 层随机失活和 BN 层使用当前 batch 统计量会导致输出不稳定。尤其是最后几个样本单独过网络时BN 计算的是这个 batch 的均值和方差和训练时累计的全局统计量完全不同输出自然就偏了。解决加载模型后马上调用 model.eval()这是铁律。在写推理脚本时我习惯在 load_state_dict 之后第一行就写 model.eval()避免后面忘记。6. 进阶用混淆矩阵定位薄弱点针对性提升识别准确率训练完只看整体准确率是不够的准确率从 30% 到 60% 容易从 85% 到 95% 靠的是精准定位模型在哪些字上犯错。混淆矩阵是把测试集所有预测结果和真实标签做交叉统计的矩阵矩阵的第 i 行第 j 列表示真实类别为 i 但被预测为 j 的样本数。对角线上的值越大越好非对角线上的亮点就是模型最容易混淆的类别对。from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt import numpy as np all_preds [] all_labels [] model.eval() with torch.no_grad(): for inputs, labels in test_loader: outputs model(inputs) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm confusion_matrix(all_labels, all_preds) np.save(confusion_matrix.npy, cm) # 找出错误最多的前 10 个类别 row_sums cm.sum(axis1) correct_per_class np.diag(cm) error_counts row_sums - correct_per_class top_error np.argsort(error_counts)[-10:][::-1] for cls_idx in top_error: # 找到这个类别被错误预测成哪些类别 wrong_preds np.argsort(cm[cls_idx])[::-1][:5] print(f类别 {idx_to_class[cls_idx]} 错误 {error_counts[cls_idx]} 次最常被误认为) for wrong_idx in wrong_preds: if cm[cls_idx][wrong_idx] 0 and wrong_idx ! cls_idx: print(f {idx_to_class[wrong_idx]}: {cm[cls_idx][wrong_idx]} 次)拿到这个错误分析结果后针对性的操作有三步第一步找到错误最多的几个类别去数据里翻这些类的真实样本观察是标注错误、本身就是潦草字、还是确实和另一个字长得几乎一样。第二步对错误集中的类别单独做数据增强比如扩大这类样本的旋转角度范围、增加笔画粗细变化或者从其他书写变体数据中补充这类字的样本。第三步如果某对字的混淆始终无法通过数据解决可以考虑在模型输出层之后加一个形近字修正模块专门用规则或小模型在这几个候选字之间再做一次判别。我刚接触手写汉字识别时犯过一个现在看起来很基础的错误只看整体准确率不拆结果分析结果模型在“手”“于”“干”三个字上错得离谱我却完全不知道还在一味加深网络层数、调增 Dropout 比例白白浪费了几天时间。从那以后我每次训完模型第一件事就是生成混淆矩阵先看错在哪、再决定怎么修准确率反而提升得更快。这个习惯也让我判断一个模型能否上生产线的标准从“准确率够不够高”变成了“错误是否集中在可解释的类别上”。希望这套流程和思路能帮你在手写汉字识别的实践中少走一些弯路。本文还有配套的精品资源点击获取
返回列表