AoZai

汉字手写识别续章:从实验室到真实场景的单字模型升级

· 12 分钟阅读

序章

上一篇文章里,单字模型停在了 Engram Small——3925 个类、95.42% 的 Top-1,勉强收尾。说实话当时心里清楚,这个结论不够完整。

问题有几个。首先,HWDB1.1 只覆盖了 3925 个一级汉字,一些常见字比如”佚”根本不在里面——你在真实场景手写一个”佚名”,模型连这个字都没见过,怎么可能对?其次,外部验证一直没做,HWDB 训测同源,内部指标好看不代表真能用。最后,Engram 的 slots_per_class 设计参数冗余太大,Small 都要 2M,不优雅。

于是接着做了一轮升级。这次的目标很明确:扩大覆盖、精简结构、做外部验证。最终形成了一个从 48 维到 184 维的三档模型体系,主推的 184 版不到 2M 参数,Competition 外测 Top-1 做到了 95.8%。

这篇文章接着上一篇的时间线,把后续的探索和踩坑补上。


第一章:从 3925 到 7356——先把字认全

数据扩充

HWDB 数据集有三个子集:1.0、1.1、1.2。之前只用 1.1 是因为它覆盖了 GB2312 一级汉字,够”标准”。但实际写字的人不会管你这个字在不在一级字表里。“佚”、“佚”这种字在真实书写中并不罕见,但在 3925 类里就是没有。

合并 1.0 + 1.1 + 1.2,去重后得到 7356 个类别。这几乎是之前的两倍,很多低频但实用的字被补了进来。

类别翻倍,但模型不能翻倍——这是底线。

换个思路:从”做大 backbone”到”做稳 backbone”

早期有一个直觉:类别多了,backbone 就得大。但实际试下来,单纯堆 InvertedResidual 层数或通道数,收益衰减很快。真正的问题是 backbone、embedding、分类头和记忆机制能不能一起稳定工作,而不是各管各的。

于是重新审视了上一代的 HanziCupMediumSparse 结构。它的核心设计——3×3 stem、InvertedResidual 四阶段、SparseChannelGate、局部/全局双路融合——不是专门为汉字发明的,但恰好踩中了汉字识别的要点:局部笔画细节和全局字形结构缺一不可

把这套结构抽象成 CupBackbone,作为后续所有实验的统一底座:

class CupBackbone(nn.Module):
    def __init__(self, in_chans=1, embed_dim=184, dropout=0.16):
        # stem: 3x3 conv → 24ch → BN+SiLU → 3x3 stride2 → 32ch
        # stage1-4: InvertedResidual blocks, 逐步下采样到 144ch
        # local branch: 从 stage3 输出 72ch → proj → SparseChannelGate → AttentionPool
        # global branch: stage4 输出 → SparseChannelGate → GeMPool
        # fusion: concat → Linear 240 → BN+SiLU → Dropout → Linear embed_dim
        # fusion_gate: SparseChannelGate 再过滤一次

和上一代比,结构没大改,但把每个组件的位置和目的理得更清楚了。stem 负责初级的笔画方向检测,stage1-2 提取局部结构(笔画的起收转折),stage3-4 提取全局布局(部件关系、整体轮廓),gate 负责去冗余。


第二章:Arc 消融与 Engram 改造

ArcCupModel——强基线但不是终点

在 CupBackbone 上接了 ArcMargin 分类头,想看看纯 backbone 的能力上限。

收敛速度确实快——ArcFace 的类间间隔约束让 embedding 空间天然更结构化。但在轻量约束下,Top-1 的稳定性不如后续的 shared engram 方案。而且 ArcFace 的 scale/margin 超参对类别数很敏感,7356 类比 3925 类需要更仔细的调参。

结论:Arc 可以作为对照基线,但最终路线不选它。

EngramCupModel——记忆槽的思路是对的,但实现不够好

把上一代的 Engram 思路搬到 CupBackbone 上:每个类别有若干个可学习记忆槽(slots),输入特征做 query 对 slots 做注意力读出,读出结果与原始特征融合后分类。

效果确实比纯分类头好——top-k 指标提升,原型可解释性也更强。但问题同样明显:slots_per_class 稍微调大,参数就爆炸。7356 类 × 2 slots × 184 维 = 约 2.7M 参数,仅记忆槽就占了这么多。

思路有效,但需要瘦身。

Shared Engram——把分类权重和记忆原型合并

这是本轮最关键的改动。

观察 EngramCupModel 的结构会发现,classifier weight(7356 × embed_dim)和 engram slots(7356 × slots_per_class × embed_dim)本质上都在做同一件事:为每个类别维护一组可学习的嵌入向量。那为什么不干脆共享

class SharedEngramCupModel(nn.Module):
    def __init__(self, num_classes, embed_dim=184, dropout=0.16):
        # 核心改动:classifier weight 和 engram prototype 是同一组参数
        self.shared_weight = nn.Parameter(torch.randn(num_classes, embed_dim) * 0.02)
        self.classifier_scale = nn.Parameter(torch.tensor(22.0))
        self.engram_scale = nn.Parameter(torch.tensor(16.0))
        self.engram_mix_logit = nn.Parameter(...)  # 融合比例,可学习

    def classifier_logits(self, embedding):
        # 分类:embedding 对 shared prototypes 做 cosine similarity
        return F.linear(norm(embedding), self._prototypes()) * self.scale

    def read_engram(self, query):
        # 记忆读取:同一组 prototypes 做 attention readout
        proto_logits = query @ prototypes.t() * self.engram_scale
        proto_attn = softmax(proto_logits)
        readout = norm(proto_attn @ prototypes)
        return readout, proto_logits

slots_per_class 降到了 1——每个字只有一个原型向量,既是分类器的 class weight,也是 engram 的读出原型。参数量直接砍半。

训练用了复合 loss:LogitAdjustedCE(处理类别不平衡)+ shared engram loss + prototype loss + sparse loss。AdamW,LR=1e-3,Warmup 5 个 epoch 后 Cosine 衰减,EMA + AMP,batch size 1024。

这个设计后来成了单字模型的最终形态。


第三章:三档模型与外部验证

48 / 96 / 184

既然 SharedEngramCupModel 验证可行,接下来做了一个横向的容量对比——embed_dim 分别取 48、96、184,看精度和参数量的 trade-off:

模型embed_dim参数量内部 Top-1内部 Top-5
Small480.94M95.69%98.99%
Medium961.31M96.10%99.11%
Large1841.97M96.37%99.18%

三个模型都在 2M 以下——这在单字模型里算很轻了。184 的内部验证 Top-1 是 96.37%,比上一代的 HanziCupMediumSparse(1.34M / 96.33%)在参数量增加不到 50% 的情况下,类别数从 3925 翻到 7356。

Competition 外部验证——真正的试金石

HWDB 内部验证有个老问题:训练和验证来自同一个数据集,切分方式虽不同,但采集条件、书写风格、纸张背景高度一致。内部好看不见得外部能用。

于是拿 CASIA Competition offline character data(224,419 个样本)做了独立外部测试。和 HWDB 完全不重叠的采集批次。

模型Competition Top-1Competition Top-5
Small (48)94.91%98.84%
Medium (96)95.61%99.06%
Large (184)95.81%99.09%

外部测试比内部掉了不到 1 个百分点,这个泛化差距在可接受范围内。而且 184 > 96 > 48 的排序在外部也完全一致,说明容量提升确实在带来真实的泛化收益,而不只是在 HWDB 上过拟合。

GUI 实测中一个有意思的现象

在 GUI 手写实测时,出现了一个反直觉的观察:对于”佚名”这种两个字的极短输入,48 有时候体感比 184 还稳。

仔细看了一下预测分布,184 的 embedding 空间更紧凑,类间边界更锐利,但在极短上下文中容易对近形字”过度自信”。48 的 embedding 空间相对松散,对单字判断反而更保守——保守在短词场景下反而是好事。这个现象后来在端到端模型中反复出现,最终影响了部署时的路由策略。

不过从整体 Top-1 来看,184 仍然是毫无疑问的主推版。


第四章:这轮升级的收获

共享原型是性价比最高的设计决策

从 EngramCupModel 到 SharedEngramCupModel,本质上做了一件事:合并冗余的原型参数。classifier weight 和 engram slots 本来就是同一种东西——每个类别在高维空间中的一个代表点。硬分成两组参数不仅浪费,还让梯度更新变得不一致。合并后参数量砍半,精度没掉,训练还更稳定。

外部验证不能省

这一点怎么强调都不过分。HWDB 内部验证 96%+ 看着很舒服,但如果没有 Competition 外部测试,你不会知道模型是不是只学会了 HWDB 的采集偏差。好在这次外部只掉了不到 1%,但后面做端到端时,这个差距一度拉到 20 个点——那是另一个故事了。

三档模型各有用处

184 是主推,96 是备选,48 虽然在长行上不行,但在特定短词场景有奇效。不要急于淘汰”看起来最差”的模型——不同输入长度和难度分布下,最优模型可能不一样。


写在最后

这轮单字模型升级折腾了几个月。从合并三个 HWDB 子集到 7356 类,从 Arc 消融到 shared engram,从内部验证到 Competition 外测——每一步都在把模型往”能用”的方向推。

最大的感受是:单字模型的瓶颈不在 backbone 大小,在分类头和记忆机制的设计。一个不到 2M 参数、用 shared engram 的模型,能在 7356 类上做到 95.8% 的 Competition Top-1——这个结果放一年前,我是不太敢相信的。

单字阶段到这里正式收尾。接下来要做的,是把这个底座搬到行级识别上。


本系列研究借助了 AI 工具辅助思路梳理和代码调试。

Share: