序章
在社团里,我一直对计算机视觉和文字识别很感兴趣。汉字识别是个有意思的方向——它不像英文 OCR 那样相对成熟,尤其是手写体,同一个字不同人写出来的差异非常大。正好有机会接触这个方向,我就开始了单字分类的探索。
数据集用的是 HWDB 1.1(中科院自动化所的手写汉字数据库),单字分类任务,训练集和验证集按 9:1 划分,评估指标看 Top-1 和 Top-5 准确率。
整个研究过程走了不少弯路——从超轻量原型出发,经历了扩容翻车、架构重设计、记忆机制探索,最终找到了一套在参数量和精度之间比较好平衡的方案。这篇文章是对整个过程的完整梳理。
第一章:原型验证与初次扩容
HanziTiny——先跑通再说
项目最开始只是一个 630 类的 demo 测试,用了一个极简的模型架构跑跑看。没想到效果出乎意料的好,这给了我信心。于是把类别扩展到 994 个常见汉字,模型结构保持不变,继续验证这条路是否可行。
这就是 HanziTiny(代码中 num_classes=630 就是最初 demo 的痕迹,后来改成了 994):stem 下采样 + 几层深度可分离卷积 + 全局平均池化 + 分类头。训练用了 AdamW 配合余弦退火调度,加上 RandomAffine 和 RandomErasing 做数据增强。
class HanziTiny(nn.Module):
def __init__(self, num_classes=630, in_chans=1):
super().__init__()
self.stem = nn.Sequential(
nn.Conv2d(in_chans, 32, 3, stride=2, padding=1, bias=False),
nn.BatchNorm2d(32),
nn.ReLU6(inplace=True)
)
self.features = nn.Sequential(
DSConv(32, 64, stride=1),
nn.MaxPool2d(2),
DSConv(64, 128, stride=1),
DSConv(128, 128, stride=1),
nn.MaxPool2d(2),
DSConv(128, 256, stride=1),
)
self.classifier = nn.Sequential(
nn.Linear(256, 128), nn.ReLU6(inplace=True),
nn.Dropout(0.1), nn.Linear(128, num_classes)
)
结果 Top-1 96.37%。说实话当时有点膨胀——994 类拿 96% 好像还挺容易的?
扩容翻车
既然小规模可行,直接上全量 3925 个汉字(覆盖 GB2312 一级汉字)。模型扩容到 HanziFullNet,增加更多卷积层和 neck 投影层,同时尝试加 ArcFace 做度量学习。
结果翻车了——不加 ArcFace Top-1 只有 77.44%。更离谱的是,开了 ArcFace 直接跌到 0.0458%。
事后复盘,除了 ArcFace 超参问题,还有一个被低估的因素:一级汉字中的形近字密度远超预期。630/994 类是从常用字中筛选的,天然避开了大量混淆字对。但全量 3925 类中,“己/已/巳”、“人/入/八”、“未/末”、“日/曰”这类高相似度字对大量存在,类间距离被严重压缩,对特征提取器的判别能力提出了更高要求。
ArcFace 方面则是初始化和超参没调好——当时直接用了默认的 scale=30、margin=0.3,没有做 gradual warmup,模型在训练初期就被 margin 卡死,梯度传不回去。
HanziMedium——稳扎稳打
扔掉 ArcFace,回到 softmax 路线,核心目标转向提升模型对形近字的判别能力。在训练策略上做了几项关键改进:
- Mixup:在输入空间做线性插值,强制模型学习更平滑的决策边界,尤其对形近字之间的过渡区域有正则化效果
- EMA(指数移动平均):对模型权重做滑动平均,推理时更稳定,减少了形近字上的预测抖动
- Warmup + Cosine 调度:前几个 epoch 线性升温,避免训练初期的大梯度震荡导致形近字分类面不稳定
- Dropout 加大到 0.2:更强的正则化,抑制对单一判别特征的过拟合
结果 Top-1 95.80%,形近字混淆有明显改善。但还有一个头痛的问题没有解决。
极简笔画字误判
在真实场景测试时发现:“一""二""三”这类字的误判特别严重。原因不难理解——这三个字的差别就一两笔,CNN 在多次下采样后几乎丢掉了这种细微差异。模型太关注全局轮廓,但笔画极少的字,轮廓信息根本不够用。
这就是下一步要解决的问题。
第二章:结构重设计——局部与全局双路融合
设计动机
分析下来,人眼识别汉字时同时用了两个维度的信息:局部(每笔的起收笔、转折角度)和全局(整体结构、部件布局)。但 CNN 在逐层下采样中丢失了局部细节。
于是想到:从 backbone 中间层(下采样还不算深的地方)抽一路 local feature,从深层抽一路 global feature,两路 concat 后 fusion。
HanziGlyphNet——先做上限版
class HanziGlyphNet(nn.Module):
def __init__(self, num_classes, embed_dim=320, dropout=0.2):
# stem + 4 stages (InvertedResidual blocks)
# local branch 从 stage2 输出接出
self.local_proj = nn.Sequential(
nn.Conv2d(128, 160, 1, bias=False),
nn.BatchNorm2d(160), nn.SiLU(inplace=True)
)
self.local_pool = AttentionPool2d(160, hidden_ch=128)
# global branch 从 stage4 输出接出
self.global_pool = GeMPool2d()
# 双路融合
self.fusion = nn.Sequential(
nn.Linear(160 + 256, 512, bias=False),
nn.BatchNorm1d(512), nn.SiLU(inplace=True),
nn.Dropout(dropout),
nn.Linear(512, embed_dim, bias=False),
nn.BatchNorm1d(embed_dim), nn.SiLU(inplace=True)
)
几个重要设计选择:
- AttentionPool2d 做局部特征聚合:普通平均池化对空间位置不加区分,但笔画的空间位置很重要,用注意力让模型自己学该关注 feature map 的哪些位置
- GeMPool2d 做全局池化:可学习的 p 值在 avg (p=1) 和 max (p→∞) 之间插值,比纯 avg 更灵活
- InvertedResidual:参考 MobileNetV2,1×1 expand → depthwise 3×3 → 1×1 squeeze,参数量小但表达力不错。激活从 ReLU6 换成了 SiLU,训练更稳定
结果:4.2M 参数,Top-1 96.82%,Top-5 99.35%。双路融合的思路对了。
HanziCup 系列——做减法
4.2M 还是有点大,接着做了一系列压缩实验:
| 模型 | 参数量 | Top-1 | 备注 |
|---|---|---|---|
| HanziGlyphNet | 4.21M | 96.82% | 上限版 |
| HanziCupSmall | 0.99M | 95.80% | 极致压缩 |
| HanziCupMedium | 1.63M | 96.06% | 均衡版 |
| HanziCupMediumSafer | 1.63M | 96.25% | +EMA |
| HanziCupMediumSparse | 1.34M | 96.33% | 主推版 |
Sparse Channel Gate
到 HanziCupMedium 时,我觉得 local 和 global branch 的输出通道还有冗余。于是加了 SparseChannelGate——对每个分支的 channel 维度做可学习门控,配合 L1 正则让不重要通道的权重趋向零:
self.local_gate = SparseChannelGate(96) # local branch 96 通道
self.global_gate = SparseChannelGate(144) # global branch 144 通道
相当于让模型自动做 channel pruning。HanziCupMediumSparse 在 1.34M 参数下拿到 96.33%,参数量-精度综合最优。
第三章:记忆与检索框架
形近字困境
双路融合解决了简单字误判,但形近字混淆仍然存在。“己/已/巳”、“人/入/八”这些字在特征空间天然很近,纯靠 CNN 前馈网络很难学到足够精细的决策边界。瓶颈不在模型容量,在分类头的表达能力。
换个思路:与其让分类头一次性记住 3925 个类的决策边界,不如给模型配一个”记忆库”。
Metric Memory——外挂记忆库
用 backbone 提取的特征做 query,去向量库里做最近邻检索,检索结果和原始特征融合后再分类:
class MemoryNetV2(nn.Module):
def forward(self, x):
embedding = self.forward_features(x)
# 分类权重也做归一化,logits = cosine similarity * scale
logits = F.linear(
self.dropout(embedding),
F.normalize(self.classifier.weight, dim=1)
) * self.scale.clamp(min=1.0)
return logits, embedding
核心是余弦相似度 + 可学习 scale。好处是训练后可以把分类权重直接当 memory index 用,用户纠正一个错字只需把正确特征追加进 memory,不用重训整个模型。
| 规模 | 参数量 | Top-1 | Top-5 |
|---|---|---|---|
| Small | 1.00M | 95.90% | 99.20% |
| Medium | 1.60M | 96.16% | 99.12% |
| Large | 2.88M | 96.19% | 99.30% |
Engram——内置记忆槽
Metric Memory 的问题是余弦分类和普通线性分类在 HWDB 上的差异不够大。受到 DeepSeek 论文中 engram 概念的启发,我尝试了另一个方向:给模型内置可学习的”记忆槽”。不过需要说明的是,这里只是一个简化版的实现,远不及 DeepSeek 原版 engram 的规模和效果。
class EngramMemoryNet(nn.Module):
def __init__(self, num_classes, slots_per_class=2, embedding_dim=128):
self.total_slots = num_classes * slots_per_class
# 每个类 slots_per_class 个可学习记忆向量
self.engram_slots = nn.Parameter(
torch.randn(self.total_slots, embedding_dim) * 0.02
)
def forward(self, x):
query = self.forward_features(x)
engram = self.read_engram(query) # query 对 slots 做交叉注意力
fused = self.fuse_query_and_engram(query, engram["readout"])
logits = self.compute_classifier_logits(fused)
return {"logits": logits, "embedding": query, ...}
关键设计:每个汉字有 2 个可学习记忆槽(总记忆数 = 3925×2 = 7850),输入特征做 query 对全部 slots 做注意力读出,读出记忆与原始特征加权融合(mix=0.45)后分类。
| 规模 | 参数量 | Top-1 | Top-5 |
|---|---|---|---|
| Small | 2.00M | 95.42% | 99.27% |
| Medium | 3.49M | 95.61% | 99.19% |
| Large | 5.89M | 95.93% | 99.14% |
值得一提的是,Engram 的参数量比预期大了不少——Small 就有 2M,Large 更是接近 6M。主要原因是 7850 个记忆槽每个都是 128 维的可学习向量,仅这一项就贡献了约 1M 参数。和 HanziCupMediumSparse(1.34M / 96.33%)相比,Engram Small 用了近 1.5 倍的参数才拿到 95.42%,效率上确实差了一截。
选哪个?
两个路线的准确率差距不大。决策更多靠工程考量:
| Metric Memory | Engram | |
|---|---|---|
| 参数量(Small) | 1.00M | 2.00M |
| 推理开销 | 低(仅余弦距离) | 略高(attention + slot readout) |
| 外部依赖 | 可选 Faiss | 无 |
| 在线更新 | 天然支持 | 需额外设计 |
| 部署复杂度 | 需维护向量库 | 纯模型内 |
最终选了 Engram Small 作为稳定版,不需要外部依赖,部署更简单。不过说实话,在 HWDB 这种干净数据集上,记忆路线的边际收益有限。它的价值可能要到真实场景(噪声、潦草字、开放集)才会体现。
第四章:复盘与思考
路线总结
从 994 类到 3925 类,从 96.37% 到 96.33%,表面看准确率没变,但模型从几百万参数精简到了 1.34M:
| 阶段 | 代表模型 | 类别数 | 参数量 | Top-1 |
|---|---|---|---|---|
| 原型验证 | HanziTiny | 994 | ~0.5M | 96.37% |
| 扩容尝试 | HanziMedium | 3925 | ~1.2M | 95.80% |
| 结构重设计 | HanziCupMediumSparse | 3925 | 1.34M | 96.33% |
| Metric Memory | MemoryNet Small | 3925 | 1.00M | 95.90% |
| Engram | Engram Small | 3925 | 2.00M | 95.42% |
类别数从 630 起步到最终覆盖全量 3925 类,准确率从 96% 到 96%,表面看变化不大,但模型的参数效率提升显著——HanziCupMediumSparse 在 1.34M 下拿到 96.33%,是综合最优解。记忆路线虽然准确率略低,但 Engram Large 在 5.89M 参数下做到了 95.93%,说明方向本身没问题,只是参数效率不如特征融合路线。
关键收获
局部+全局融合是性价比最高的改进。 从 HanziMedium 到 HanziCup 系列唯一的核心变化就是双路特征 + fusion,几乎没有增加计算量,但对极简笔画字改善明显。
稀疏正则化被低估了。 SparseChannelGate 不仅没掉点,反而在更少有效参数下拿到更高精度(96.06% → 96.33%)。稀疏性带来的隐式正则化可能去掉了特征中的噪声通道。
记忆机制在干净数据集上边际收益有限。 Engram 和 Metric Memory 都没有超越纯特征融合路线。不是方向错了,而是 HWDB 的数据分布太干净——训测差异不够大。记忆机制的价值要等真实场景才能体现。
ArcFace 翻车教会我的: 度量学习的 margin 不是越大越好,需要配合数据集难度、训练阶段和温度系数仔细调。从 softmax 平稳过渡到 ArcFace(gradual margin annealing)才是正确方式。
还没做好的
- 真实手写体泛化:HWDB 是实验室数据,快递单潦草字、医生处方字是完全不同的难度
- 笔画顺序信息:CNN 只看到静态图像,但笔顺本身就含有强先验,用图神经网络建模笔画关系或许能进一步提升形近字区分
后续方向
单字分类只是第一步。更完整的系统还需要多字端到端(CRNN/Transformer)、整页 OCR(检测+识别联合),以及开放集识别——对于没见过的新字,模型应该能说”不认识”而不是强行分类。
写在最后
这个系列到这里就告一段落了。从第一个 HanziTiny 原型到最后选定 Engram Small,整个过程花了近一个学年。虽然中间踩了不少坑——ArcFace 翻车、简单字误判反反复复、两条记忆路线做了半天发现边际收益有限——但每一步弄清楚了”为什么会这样”,就都值得。
感谢在研究过程中提供指导和帮助的老师与同学,也感谢社团提供的学习和实验环境。研究中借助了 AI 工具辅助提供思路和拓宽视野。
如果这篇文章对你有所帮助,那再好不过。