十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

使用PyTorch构建中文手写汉字识别CNN:HWDB数据集实战

使用PyTorch构建中文手写汉字识别CNN:HWDB数据集实战 简介面向计算机专业的期末大作业与毕业设计场景基于PyTorch框架的卷积神经网络中文手写汉字识别项目选用HWDB手写体数据库完成模型训练与效果评估。项目经导师指导并评审通过设计评分为九十八分所有源码均已在本地编译运行并通过严格调试可直接复现。整体难度适中贴近课程实践特别适合正在完成课程设计、毕业设计以及需要项目实战练习的深度学习初学者。资源压缩包共包含六个文件核心为四个Python源码文件分别负责数据预处理、数据集加载、卷积神经网络模型搭建与训练主流程另附说明文档和一张示例图片整体仅一百八十四KB结构清晰、便于阅读修改。目前该项目已有四百八十五人学习下载既可以作为期末大作业的完整参考也可用于算法复现和PyTorch实战入门的优质范例。1. 中文手写汉字识别为什么HWDB是绕不开的那个数据集中文手写汉字识别和MNIST那种英文数字识别完全不是一个量级的问题HWDB 1.0加1.1合并后大约有270万个手写汉字样本覆盖GB2312一级字库的3755个类别部分字体还带有连笔、顿笔和形变不同书写者的同一个字在结构比例上差异极大。拿LeNet-5直接迁移上去准确率很难超过85%这个数字远达不到课程项目“有真实应用价值”的标准。PyTorch在这个任务上的优势在于两点一是torchvision和timm生态可以直接复用成熟的数据增强和预训练权重二是动态图让“边训练边改模型结构”变成低成本操作——期末大作业里“把池化改成stride卷积看效果”这类对比实验写进报告是加分项。本篇文章从HWDB的二进制格式讲起依次拆解CNN结构选型、PyTorch数据管线、训练超参调优、防过拟合技巧和验证方法所有代码都以PyTorch 2.x为基准用conda创建独立环境即可复现。2. 卷积、池化、步长、核、填充汉字识别里的CNN结构图到底怎么画2.1 汉字是“局部笔画组成全局结构”这正好是卷积的归纳偏置卷积神经网络最核心的假设是“空间局部性”图像中相邻像素的相关性远比远处的像素强。汉字恰好满足这一点——一个“横”笔画只影响它落笔的那一行“竖钩”只影响对应的那一列而撇捺的倾斜方向也只在局部窗口内体现。卷积核在整张图上滑动每个位置用同一组权重提取局部模式这就是参数共享加平移等变性。对汉字识别来说这意味着一个5×5的卷积核可以学到“横”“竖”“撇”“捺”的局部纹理组合而多个卷积核叠加后高层特征自然对应“木字旁”“单人旁”这类偏旁部件。相比全连接网络卷积把参数量从图像尺寸的平方复杂度降下来也让模型不再依赖某一个全局位置的特征。2.2 特征图尺寸控制公式步长和填充的配合卷积输出的特征图尺寸由输入尺寸、核大小、步长stride和填充padding共同决定公式为输出尺寸 floor((输入尺寸 2 × padding - kernel_size) / stride) 1HWDB原始图像是灰度图尺寸不统一常见做法是先缩放到64×64再送入网络。64是一个比较好用的尺寸可被2连续整除6次方便做成多级下采样。若使用5×5卷积核、stride1、padding2尺寸不变stride2、padding2时特征图减半。池化层一般不参与可学习参数但作为下采样手段同样遵循这个尺寸约束。下表给出一个基线CNN针对64×64输入的尺寸变化期末报告里画网络结构图时可以直接套用这套数字层名输入尺寸卷积核/操作stridepadding输出尺寸输出通道conv164×643×31164×6432pool164×642×2 max2032×3232conv232×323×31132×3264pool232×322×2 max2016×1664conv316×163×31116×16128pool316×162×2 max208×8128fc8×8×128全局池化后展平——8192→3755—2.3 为什么弃用全连接直接展平全局平均池化的收益一个小细节许多手写识别网络在最后一个卷积层后接Flatten再接全连接层把8×8×128的图展平为8192维向量。这个操作的问题在于参数量集中爆发且全连接层对输入空间位置敏感轻微平移会导致激活值分布改变。常见做法是换成全局平均池化Global Average Pooling即对每个通道的8×8特征图取平均直接得到128维向量再映射到3755个类别。这样既能保留通道层面的语义信息又能让模型更关注“有没有这个部件”而不是“部件精确出现在哪个像素”对书写偏移有天然容忍度。注意全局平均池化并非万金油。如果手写图像存在严重的笔画断裂或尺度不一致GAP会抹掉局部细节。此时可考虑在GAP前加一层1×1卷积做通道加权或者直接使用Flatten加Dropout。3. 用PyTorch从零搭建汉字识别CNNHWDB的gnt解析与DataLoader3.1 解剖HWDB的gnt格式数据加载代码先写对这个HWDB的原始样本以.gnt为扩展名存储每个文件包含若干单字样本二进制布局为4字节的样本总长度uint32含这4字节本身、2字节的标签码uint16即GB2312区位码、2字节的图像宽度、2字节的图像高度、随后是宽×高个字节的灰度像素值0到255。读取时用struct.unpack按小端序解析即可不需要安装额外的库。import struct import numpy as np def read_gnt_file(gnt_path): samples [] with open(gnt_path, rb) as f: while True: packed_length f.read(4) if not packed_length: break length struct.unpack(I, packed_length)[0] # 当前样本剩余数据标签码2字节 宽2字节 高2字节 像素 label_code, width, height struct.unpack(HHH, f.read(6)) pixels np.frombuffer(f.read(width * height), dtypenp.uint8) pixels pixels.reshape(height, width).astype(np.float32) / 255.0 samples.append((label_code, pixels)) # length 含4字节length字段本身核对剩余长度 remaining length - 4 - 6 - width * height if remaining: f.seek(remaining, 1) return samples代码逻辑说明外层while循环逐样本读取packed_length为空说明到达文件尾。标签码是GB2312区位码比如0xD3C5对应“中”要通过gb2312编码转成字符。注意reshape(height, width)不能写反HWDB存储顺序是先宽后高而numpy数组第一维是高度。像素值归一化到[0, 1]是为了匹配PyTorch的损失函数数值范围也方便后续数据增强。3.2 自定义Dataset类把标签码映射到类别IDtorch.utils.data.Dataset要求实现__len__和__getitem__两个方法。这里有一个容易踩的坑3755个类别的ID必须固定且训练集和测试集共用同一份映射表否则验证时标签对不上。import os import torch from torch.utils.data import Dataset from torchvision import transforms class HWDBDataset(Dataset): def __init__(self, gnt_dir, label_to_idx, transformNone): self.samples [] self.label_to_idx label_to_idx self.transform transform for fname in sorted(os.listdir(gnt_dir)): if fname.endswith(.gnt): for label_code, pixels in read_gnt_file(os.path.join(gnt_dir, fname)): char chr(label_code) # 实际应通过gb2312解码 if char in label_to_idx: self.samples.append((pixels, label_to_idx[char])) def __len__(self): return len(self.samples) def __getitem__(self, idx): pixels, label self.samples[idx] img torch.from_numpy(pixels).unsqueeze(0).float() # (1, H, W) if self.transform: img self.transform(img) return img, torch.tensor(label, dtypetorch.long)参数说明label_to_idx在加载数据前由全部gnt文件的标签码统计生成推荐做法是先遍历一遍所有文件收集set(label_codes)再排序后编号保证每个类别的ID稳定。transform统一接收4维张量还是3维张量要提前约定好下面的增强代码就用(C, H, W)原始尺寸渐进缩放到64×64。3.3 搭建可复用的残差CNN模块手工设计一个能上分的CNN并不需要多么复杂的结构关键是控制每个stage的通道数和下采样节奏。这里给出一个适合64×64输入、可直接复制的残差模块import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, in_ch, out_ch, stride1): super().__init__() self.conv1 nn.Conv2d(in_ch, out_ch, 3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_ch) self.conv2 nn.Conv2d(out_ch, out_ch, 3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_ch) self.shortcut nn.Sequential() if stride ! 1 or in_ch ! out_ch: self.shortcut nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, stridestride, biasFalse), nn.BatchNorm2d(out_ch) ) def forward(self, x): out torch.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out self.shortcut(x) return torch.relu(out) class HWDB_CNN(nn.Module): def __init__(self, num_classes3755): super().__init__() self.stem nn.Sequential( nn.Conv2d(1, 32, 3, stride1, padding1, biasFalse), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue) ) self.stage1 nn.Sequential(ResidualBlock(32, 64, stride2), ResidualBlock(64, 64)) self.stage2 nn.Sequential(ResidualBlock(64, 128, stride2), ResidualBlock(128, 128)) self.stage3 nn.Sequential(ResidualBlock(128, 256, stride2), ResidualBlock(256, 256)) self.gap nn.AdaptiveAvgPool2d(1) self.fc nn.Linear(256, num_classes) def forward(self, x): x self.stem(x) x self.stage1(x) x self.stage2(x) x self.stage3(x) x self.gap(x).flatten(1) return self.fc(x)结构说明stem层用3×3卷积和BatchNorm提取初始特征三个stage通过stride2的残差块把空间尺寸从64降到32、16、8通道数从32翻到256最终用自适应平均池化把8×8压成1×1。残差连接解决梯度消失问题批量归一化加速收敛并缓解内部协变量偏移。注意所有卷积层都设置biasFalse因为BatchNorm后续会做平移额外偏置会冗余。3.4 DataLoader配置和显存控制的三个参数DataLoader中batch_size、num_workers、pin_memory三个参数对训练速度和显存占用影响最大。CPU核数允许时num_workers设为4到8Windows上设为2以上会偶发报错这是PyTorch在Windows端多进程的已知问题pin_memoryTrue在GPU训练时能减少数据拷贝时间。from torch.utils.data import DataLoader from torchvision import transforms train_transform transforms.Compose([ transforms.RandomAffine(degrees5, translate(0.05, 0.05)), transforms.Resize((64, 64)), transforms.Normalize(0.5, 0.25) ]) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue)drop_lastTrue表示最后不足一个batch的数据丢弃配合BatchNorm可以避免最后一个小batch的均值和方差抖动。Resize((64, 64))直接接受(H, W)元组注意不是[64, 64]列表但效果等价。4. 训练配置学习率策略、优化器和Loss权重怎么设4.1 3755类的分类任务交叉熵损失和Label Smoothing直接使用nn.CrossEntropyLoss()可以跑通但期末数据集不像ImageNet那么“干净”HWDB存在少量标注噪声和相近字形。交叉熵对“错误标注”非常敏感一个真实标签为“己”但图像实际写着“已”的样本会把梯度推向错误方向。Label Smoothing将one-hot标签改为软标签给非目标类别留出微小概率通常0.1相当于给模型增加正则化约束抑制过拟合。criterion nn.CrossEntropyLoss(label_smoothing0.1)参数说明label_smoothing0.1表示把真实类别的目标概率从1.0下调为0.9剩余0.1均匀分给其余3754个类别。使用后训练loss会略高但测试准确率通常更稳尤其适合期末大作业这种“单模型跑测试集”的评分场景。4.2 优化器选择与OneCycleLR训练策略AdamW收敛快但泛化性能不如SGDMomentum这是很多PyTorch文档反复验证过的结论。汉字识别类别数大、训练轮数通常只有20到30轮建议直接用SGDMomentum配合OneCycleLR让学习率先升后降效果稳定且容易复现。import torch.optim as optim from torch.optim.lr_scheduler import OneCycleLR optimizer optim.SGD(model.parameters(), lr0.05, momentum0.9, weight_decay1e-4) total_steps len(train_loader) * epochs scheduler OneCycleLR(optimizer, max_lr0.05, total_stepstotal_steps, pct_start0.3, div_factor10, final_div_factor100)pct_start0.3表示前30%的step学习率从0.005升到0.05后70%逐步降到0.0005。div_factor10控制初始学习率为max_lr / 10final_div_factor100控制最终学习率为max_lr / (10 × 100)。注意total_steps必须是训练数据经DataLoader切分后的步数漏掉drop_lastTrue会导致步数计算偏差。4.3 batch size、epoch数和输入尺寸的常见搭配以下配置针对单张消费级显卡8GB到12GB显存比较保险期末机器配置差异大建议从低档起步显存batch size输入尺寸模型参数量单epoch时间参考6GB6448×48约3.6M8分钟8GB9664×64约3.6M12分钟12GB12864×64约3.6M15分钟输入尺寸从64降到48理论上减少约43%的计算量汉字结构信息仍保留足够适合快速验证代码正确性最终提交结果时才使用64×64。参数量主要来自最后的fc层3755个类别输出要求256×3755的权重矩阵这是不可避免的。4.4 训练循环的完整骨架附带梯度裁剪手写汉字识别很少出现梯度爆炸但加了Label Smoothing后早期loss下降更慢个别batch可能出现异常大梯度。用clip_grad_norm_限制梯度的L2范数能防止一次异常样本毁掉整个训练过程。for epoch in range(epochs): model.train() total_loss 0.0 for imgs, labels in train_loader: imgs imgs.cuda() labels labels.cuda() optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() scheduler.step() total_loss loss.item() * imgs.size(0) avg_loss total_loss / len(train_loader.dataset) print(fEpoch {epoch1} | Loss {avg_loss:.4f})scheduler.step()在PyTorch 2.x版本中改进过OneCycleLR必须在每个batch的optimizer.step()之后调用不能放在epoch末尾否则学习率曲线形状会错位。clip_grad_norm_的max_norm5.0是经验值通常范围在1到10之间。5. 期末大作业拿高分的三个方向数据增强、类别不均衡与测试时增强5.1 数据增强让模型对连笔和歪斜鲁棒HWDB的书写风格差异大适当增强能显著提升泛化能力。常用增强组合包括随机仿射变换、随机擦除和缩放扰动。注意中文识别和ImageNet分类不同水平翻转不能直接使用——“人”翻转后是“入”“干”翻转后是“士”语义会完全改变。train_transform transforms.Compose([ transforms.RandomAffine(degrees8, translate(0.06, 0.06), scale(0.85, 1.15)), transforms.RandomErasing(p0.15, scale(0.02, 0.15)), transforms.Resize((64, 64)), transforms.Normalize(0.5, 0.25) ])RandomAffine的degrees8控制最多旋转8度这个范围符合日常手写习惯translate(0.06, 0.06)允许最多6%的平移扰动模拟字在方格中的位置变化scale(0.85, 1.15)缩放范围不应过大否则小字号汉字会丢失笔画细节。RandomErasing随机遮盖图像的一个矩形区域迫使模型不依赖某一小块局部模拟笔画被遮挡的情况。5.2 类别不均衡高频字和生僻字的Loss权重GB2312一级字库的3755个字在真实使用频率上差异巨大HWDB采样虽然尽量均匀但“的”“一”“是”等字天然样本更多。直接训练会导致低频字准确率偏低。两种常见处理方式一是按类别样本数的倒数设置CrossEntropyLoss的weight参数二是用WeightedRandomSampler让每个batch尽量包含低频字。推荐后者因为前者会改变整体loss量级需要重新调学习率。from torch.utils.data.sampler import WeightedRandomSampler samples_per_class {} for _, label in train_dataset.samples: samples_per_class[label] samples_per_class.get(label, 0) 1 class_weights 1.0 / torch.tensor([samples_per_class.get(i, 1) for i in range(3755)]) sample_weights [class_weights[label].item() for _, label in train_dataset.samples] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue)WeightedRandomSampler的num_samples设置为原样本总数replacementTrue表示允许同一个样本在一个epoch中被多次抽取。这样低频字出现的频率相对增加整体类别分布更均衡缺点是训练步数不变但重复样本多实际收敛速度会略慢。5.3 测试时增强换一分是一分的实用技巧测试时增强TTA不是训练技巧而是推理技巧对同一张测试图做轻微旋转或平移取多次预测概率的平均值作为最终输出。对3755类分类即使只提升0.3%的准确率在期末评分的榜单上也可能产生名次差别。def predict_with_tta(model, img, n_aug5): model.eval() probabilities [] with torch.no_grad(): for _ in range(n_aug): aug_img train_transform(img) # 复用训练时的增强流程 out torch.softmax(model(aug_img.unsqueeze(0).cuda()), dim1) probabilities.append(out) avg_prob torch.mean(torch.cat(probabilities, dim0), dim0) return torch.argmax(avg_prob).item()这里直接复用了训练时的RandomAffine但由于测试时没有真值增强幅度可以比训练时更小以免破坏原本清晰的字形。另一种做法是用Resize((64, 64))配合1到2个固定角度的小旋转效果更可控。6. 在测试集上验证混淆矩阵、top-k准确率与Debug清单6.1 3755类的评估要看top-5而不是只看top-1手写汉字识别中不少失败样本是“人”和“入”这类形近字。top-1准确率能看出模型的整体水平但top-5更能反映模型是否学到了核心特征。计算top-5的方式是取torch.topk输出概率的前5个下标判断真实标签是否在其中。def evaluate_topk(model, loader, k5): model.eval() correct 0 total 0 with torch.no_grad(): for imgs, labels in loader: imgs imgs.cuda() labels labels.cuda() outputs model(imgs) _, pred_topk outputs.topk(k, dim1) correct (pred_topk labels.unsqueeze(1)).any(dim1).sum().item() total labels.size(0) return correct / total当top-1在94%而top-5在99%时说明大部分错误来自极度相似的字形而不是模型整体退化。此时可考虑用形近字混淆矩阵做针对性后处理或者直接用上一节提到的TTA把概率重新分配。6.2 常见的四个工程坑和排查方向GPU训练时DataLoader的num_workers不稳定Windows下多进程启动容易报错直接在__main__保护块中调用freeze_support()可缓解或者改用num_workers0先验证代码再开多进程。输入尺寸不匹配HWDB样本原始宽高差异大Resize((64, 64))会拉伸字形如果想保持宽高比可改为Resize(64)后在RandomAffine中随机填充背景。显存不足将batch_size减半同时调低learning_rate的初始值。SGD的合适lr与batch size正相关batch减半后建议lr也乘以0.5。训练loss下降但验证不掉优先检查Label Smoothing是否设置过高再检查数据增强强度。RandomErasing的p0.15已经不小若效果不佳可先去掉该增强项。6.3 期末报告的验证图表和可复现性建议课程评分通常注重实验的完整性。建议在报告中固定随机种子后记录训练集和验证集的loss曲线、top-1和top-5的曲线并附上每个字符类别的小样例图或混淆矩阵热力图。在代码入口处使用torch.manual_seed(0)、np.random.seed(0)和random.seed(0)保证实验可复现CUDNN的torch.backends.cudnn.benchmarkTrue只加快卷积算法搜索不改变最终结果。将整个训练过程封装成bash脚本或Python命令用argparse接收数据路径和批次大小评分老师可以一键跑通这也是高分项目中不可忽视的工程细节。本文还有配套的精品资源点击获取
返回列表