简介:面向手写汉字识别入门学习者与机器学习开发者的深度卷积网络(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_channels=1), # 转灰度,统一单通道 transforms.Resize((64, 64)), # 统一尺寸,汉字需要比数字更大的画布 transforms.ToTensor(), # 转 Tensor,像素值归一到 [0,1] transforms.Normalize((0.5,), (0.5,)) # 标准化到 [-1,1],加速收敛 ]) train_data = datasets.ImageFolder('data/train', transform=transform) test_data = datasets.ImageFolder('data/test', transform=transform) train_loader = torch.utils.data.DataLoader(train_data, batch_size=64, shuffle=True, num_workers=2) test_loader = torch.utils.data.DataLoader(test_data, batch_size=64, shuffle=False)这段代码的逻辑是:先定义一串预处理变换,再把 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], dtype=torch.float) sampler = WeightedRandomSampler(weights, num_samples=len(labels), replacement=True) train_loader = torch.utils.data.DataLoader(train_data, batch_size=64, sampler=sampler)这里的原理是:权重和样本数成反比,样本少的类别权重高,被抽中的概率大,从而缓解类别不平衡。参数 replacement=True 表示允许重复采样,保证每个 epoch 都能凑够 num_samples 个样本。注意,加了 sampler 之后就不能再设 shuffle=True,两者互斥。
2.3 数据增强:手写汉字的多样性必须靠增强来模拟
手写汉字识别的难点在于同一汉字有大量书写变体:笔画粗细不同、位置偏移、倾斜角度不同、甚至潦草程度不同。如果模型只在原始样本上训练,遇到没见过的书写风格基本就废了。数据增强是解决这个问题的核心手段,不是锦上添花,是必需品。
train_transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((64, 64)), transforms.RandomAffine(degrees=15, translate=(0.1, 0.1), scale=(0.9, 1.1)), transforms.RandomPerspective(distortion_scale=0.2, p=0.5), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ])RandomAffine 的参数含义:degrees=15 表示随机旋转范围在正负 15 度之间,这个幅度模拟手写时的歪斜;translate=(0.1, 0.1) 表示在水平和垂直方向各最多平移 10% 的像素距离,模拟书写位置偏移;scale=(0.9, 1.1) 表示缩放范围在 0.9 到 1.1 倍之间,模拟字的大小差异。RandomPerspective 的 distortion_scale=0.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_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 卷积块 2: 64 -> 128 通道,输出 16x16 nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 卷积块 3: 128 -> 256 通道,输出 8x8 nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), # 全局平均池化,输出 1x1 nn.Flatten(), nn.Dropout(0.5), nn.Linear(256, 512), nn.ReLU(inplace=True), 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 lr=1e-3,这是深度学习任务里一个相对安全的起点;如果训练曲线不够平稳,再换 SGD momentum=0.9 lr=1e-2,配合学习率衰减。
import torch.optim as optim from torch.optim.lr_scheduler import StepLR criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = StepLR(optimizer, step_size=10, gamma=0.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(f"Epoch {epoch+1}, 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_size=10 表示每 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_classes=len(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, dim=1).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_size=1 的输入,因为模型期望的输入是四维张量 (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×8=16384,全连接层参数瞬间爆炸。
解决:一是减小 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(axis=1) 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 比例,白白浪费了几天时间。从那以后我每次训完模型,第一件事就是生成混淆矩阵,先看错在哪、再决定怎么修,准确率反而提升得更快。这个习惯也让我判断一个模型能否上生产线的标准从“准确率够不够高”变成了“错误是否集中在可解释的类别上”。希望这套流程和思路能帮你在手写汉字识别的实践中少走一些弯路。
本文还有配套的精品资源,点击获取