AoZai

单字模型终章:八轮实验,从参数重分配到天花板探索

· 17 分钟阅读

序章

上一篇文章里,单字模型停在了 SharedEngramCupModel,主力配置 1.97M 参数,内部 Top-1 96.37%,Competition 外测 95.81%。这个结果作为第一阶段的收尾还算扎实,但两个问题一直悬着。

第一,同样的参数量,能不能通过调整参数分配进一步提点? SharedEngramCupModel 的 embed_dim 选了 184,视觉骨干相对偏窄,但这个分配比例是不是最优的,没有系统验证过。第二,准确率的上限到底在哪? 96.37% 离 97% 还差一截,是模型不够好,还是 7356 类手写汉字的上限就在这里?

这两个问题驱动了后续八轮实验。实验环境保持一致:HWDB 1.0 + 1.1 + 1.2 合并,7356 类,训验 9:1,AdamW + Cosine 调度 + EMA + AMP,batch size 1024。


第一章:从参数重分配到骨干重设计

参数重分配——边际收益太小

最先试的是参数分配方向。SharedEngramCupModel 的 embed_dim=184 让分类头占了较大参数比例,如果把这部分预算挪给视觉骨干——更宽的卷积通道、更小的 embed_dim——能不能在同等参数量下做得更好?

试了一组 embed_dim 从 48 到 140 的配置,总参数量控制在 1.3M ~ 2.0M 之间。最好的是 embed_dim=140,1.975M 参数,Top-1 96.58%,比之前的 96.37% 高了 0.21 个百分点。但这个提升在重复实验的方差范围内,不构成路线切换的理由。参数重分配方向到此为止。

不过这个过程里注意到一件事:embed_dim 的变动对精度的影响并不大(48 维的 96.34% 和 140 维的 96.58% 只差了 0.24%),说明视觉骨干本身的表达能力才是瓶颈,分类头的维度已经够用了。

从倒残差切换到标准残差

既然问题在骨干,那就要重新看骨干的结构。之前的 CupBackbone 用的是 InvertedResidual(MobileNetV2 风格的 1×1 expand → depthwise 3×3 → 1×1 squeeze)。这个设计在 ImageNet 上效率很高,但手写汉字的情况不太一样——笔画是稀疏的、有方向性的,不需要先膨胀到高维再压缩。更自然的做法是标准残差:3×3 → 3×3,不膨胀。

于是重新设计了视觉骨干,用标准 ResidualBlock 替换所有 InvertedResidual,配合 SE 通道注意力和 SiLU 激活:

class ResidualBlock(nn.Module):
    def __init__(self, in_ch, out_ch, stride=1, use_se=True):
        super().__init__()
        self.use_res = stride == 1 and in_ch == out_ch
        self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_ch)
        self.conv2 = nn.Conv2d(out_ch, out_ch, 3, stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_ch)
        self.se = SEBlock(out_ch) if use_se else nn.Identity()
        self.shortcut = nn.Sequential(
            nn.Conv2d(in_ch, out_ch, 1, stride=stride, bias=False),
            nn.BatchNorm2d(out_ch),
        ) if not self.use_res else nn.Identity()
        self.act = nn.SiLU(inplace=True)

    def forward(self, x):
        out = self.act(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out = self.se(out)
        out = out + self.shortcut(x)
        return self.act(out)

SE 在这里不是装饰——实际观察中,不同通道确实倾向于学习不同类型的笔画特征,SE 让模型自动决定哪些通道更重要。

按骨干容量分了五档:flash、small、base、pro、max。

配置embed_dim参数量Top-1Top-5
flash480.736M95.32%98.94%
small641.116M96.18%99.15%
base961.841M96.54%99.21%
pro1282.342M96.79%99.28%
max1403.104M96.90%99.28%

base 在 1.84M 参数下做到了 96.54%,比之前的 SharedEngramCupModel(1.97M / 96.37%)参数更少、精度略高。max 达到了 96.90%,逼近 97%。

标准残差骨干成为后续所有实验的默认底模。不是说这个结构有多新颖——标准残差是最经典不过的东西——而是说在 2M 以下参数量区间,标准残差可能就是汉字分类的最优骨干选择。倒残差的”先膨胀再压缩”适合密集纹理,但汉字笔画是稀疏信号,直接的低维卷积反而更高效。


第二章:三个没走通的方向

底模确立之后,并行试了三个方向。坦率讲,这一段是效率最低的时期,三个方向都没有产出可用的结果。

第一个方向是换用 ConvNeXtV2 骨干——depthwise 7×7 大核卷积、LayerNorm、GELU、GRN、drop path,都是现代 ConvNet 的思路。训练只跑了几轮就停了,3 个 epoch 时 max 配置(2.93M)只有 95.68%,远不如标准残差骨干同期的表现。大核 depthwise 卷积在 64×64 的低分辨率下感受野优势有限,GRN 和 drop path 这些正则化反而拖慢了初期收敛。方向上可能没错,但在当时的参数预算下跑不动。

第二个方向是多深度特征融合。汉字识别的关键信息分布在不同层级——浅层有笔画方向,中层有部件结构,深层有整体布局——所以从每个 stage 分别池化,用一个可学习的 path gate 融合多深度证据。结果有点尴尬:小模型(flash/small)加多路径反而变差,大模型(pro/max)微幅优于标准残差同配置,但考虑到额外参数的开销,性价比不如直接扩大单路径骨干。

第三个方向是对多深度融合的训练改进——弱化浅层辅助 loss、温度退火、强制均匀 gate——试图让多路径训练更稳定。实验没有跑完,多深度融合路线到此终止。事后看,64×64 的低分辨率下不同深度的特征差异本来就不够大,强行区分”浅层证据”和”深层证据”的收益被分辨率本身限制了。

这三个实验虽然是失败的,但各自排除了一个选项:ConvNeXtV2 在小参数量下不行(后面会重新捡起来),多深度融合在低分辨率下性价比不高,改进训练方式救不回方向本身的瓶颈。


第三章:冻结重排——不改骨干的稳定提升

前三轮实验都在改骨干,效果都不理想。于是换了一个完全不同的角度:不改骨干,不改训练,只在推理时对 top-8 候选做一次重排。

做法很轻:冻结已经训练好的标准残差 checkpoint,对每个输入取 base 模型的 top-8 候选,训练一个微型 MLP 重排器。输入特征包括冻结的 embedding、候选类原型向量、base 给出的 logit 和排名。推理时采用保守策略——只在重排器置信度超过验证集阈值时才替换 base 的 top-1 预测,宁可不动也不要改错。

基座基座 Top-1重排后deltaoracle@8
flash95.32%95.43%+0.11%99.19%
base96.54%96.57%+0.03%99.39%
max96.90%96.95%+0.05%99.43%

三个观察。

第一,oracle@8 很说明问题——所有基座的 top-8 候选里正确答案覆盖率都超过 99%。标准残差骨干的特征表达能力其实够了,正确答案几乎总在前 8 里,只是 softmax 从 7356 个类中直接选 top-1 会偶尔选偏。重排器的作用就是在这 8 个里把真正对的那个挑出来。

第二,delta 随基座精度提升而收窄。flash 涨了 0.11%,base 只涨了 0.03%。基座越好,它自己选错的情况就越少,重排能纠正的余地就越小。

第三,重排器的开销可以忽略不计——一个微型 MLP,参数量几 KB 级别,不增加推理延迟。它不改变骨干、不加重训练负担,就是在标准残差这条路线下,把能做到的分数往上又推了一点点。在与之前模型相同的架构方向下,重排后的结果是分数最高的。


第四章:另一条赛道——ConvNeXtV2 冲上 97%

放开限制

之前 ConvNeXtV2 的尝试在 2M 参数量下失败了。但 ConvNeXtV2 在 ImageNet 上的表现一直让我放不下——depthwise 大核卷积、GRN、LayerNorm2d、drop path 这套组合在视觉任务上确实比标准残差强,问题可能只是参数量不够。

于是开了另一条赛道:不再限制参数预算,用 ConvNeXtV2 风格骨干替换标准残差 CNN,保留 SharedEngram 分类头和多尺度池化设计。从 nano 到 xl,参数量从 4.6M 一路涨到 89M:

PROFILES = {
    "nano":  {"embed_dim": 128, "dims": (48, 96, 160, 256),   "depths": (2, 2, 4, 2)},
    "small": {"embed_dim": 192, "dims": (64, 128, 224, 352),   "depths": (2, 2, 6, 2)},
    "base":  {"embed_dim": 256, "dims": (80, 160, 320, 512),   "depths": (3, 3, 9, 3)},
    "large": {"embed_dim": 384, "dims": (96, 192, 384, 640),   "depths": (3, 3, 12, 4)},
    "xl":    {"embed_dim": 512, "dims": (128, 256, 512, 768),  "depths": (4, 4, 18, 4)},
}

dims 是四个 stage 的输出通道数,depths 是每个 stage 的 ConvNeXtV2 block 数量。

配置参数量Top-1Top-5
nano4.604M96.94%99.42%
small9.235M97.18%99.50%
base23.610M97.40%99.53%
large46.412M97.40%99.54%
xl89.202M97.45%99.57%

内部验证首次突破 97%,xl 的 97.45% 是所有单字实验中的最高分。

但这和前面的标准残差路线不能直接横向对比——骨干换了(标准残差 CNN → ConvNeXtV2),参数量差了近 50 倍(1.84M → 89M),训练配置也不同。这是一条单独的赛道,目的不是在标准残差路线上继续优化,而是回答另一个问题:不考虑参数量和部署,单字的准确率天花板到底在哪? 答案是 97.5% 附近。

一个值得注意的现象是 base 和 large 的对比。base(23.61M)和 large(46.41M)的 Top-1 完全持平,都是 97.40%。 参数翻了一倍,精度零提升。这说明 base 已经触及了这个任务语义空间的饱和点——7356 类 64×64 汉字分类的信息量就这么多,模型容量超过这个阈值后,额外参数无法被任务复杂度消费。

从整个配置梯度来看:nano(4.6M)和 small(9.2M)语义容量不足,精度还有上升空间。base(23.61M)刚好够用,是参数量与准确率的最佳平衡档位。large(46.4M)和 xl(89.2M)已经是参数冗余,再多参数也只是在 HWDB 的标注噪声上浪费算力。

这个发现还有一个实际意义:如果后续设计新的模型家族,对于这个任务规模,~23M 是一个值得参考的参数量上限目标,而不是盲目堆叠。


第五章:轻量化回压——MBConv 的失败

ConvNeXtV2 赛道虽然冲上了 97%,但 46M ~ 89M 的参数对普通设备来说完全不现实。自然想知道:能不能把 ConvNeXtV2 赛道的一些设计(多尺度池化、SharedEngram 头、训练流程)保留下来,但把重型骨干换成移动端友好的 MBConv(倒残差 + SE + 大核 depthwise context),在 0.6M ~ 3.6M 参数区间拿到接近的表现?

PROFILES = {
    "pico":  {"embed_dim": 64,  "dims": (24, 36, 56, 80),    "depths": (1, 2, 2, 1)},
    "nano":  {"embed_dim": 96,  "dims": (32, 52, 88, 136),   "depths": (1, 2, 4, 2)},
    "small": {"embed_dim": 128, "dims": (40, 64, 112, 176),   "depths": (2, 3, 5, 2)},
    "base":  {"embed_dim": 160, "dims": (48, 80, 144, 224),   "depths": (2, 3, 6, 2)},
}

dims 和 depths 的规模和 ConvNeXtV2 版本相比明显缩水,但保留了相同的多尺度池化和 SharedEngram 分类头设计。

配置参数量Top-1Top-5
pico0.629M94.79%98.76%
nano1.370M84.40%94.07%
small2.230M5.56%12.77%
base3.597M91.21%97.27%

翻车了。small 的 5.56% 说明训练直接崩溃——7356 类接近随机。nano 的 84.40% 和 base 的 91.21% 也远不如标准残差骨干的对应配置。唯一没崩的 pico(94.79%),放在标准残差路线里也只能排在 flash 之下。

这个结果和第二章提到的一个现象形成了呼应:标准残差骨干在 2M 以下参数区间运行良好,而 MBConv(倒残差的变体)在相同参数量下远不如标准残差。参数量多确实能在方式正确的前提下”大力出奇迹”(ConvNeXtV2 赛道证明了这一点),但把骨干换成移动友好型后,单纯堆参数并不能弥补骨干表达能力的差距。 对汉字手写分类这个特定任务来说,骨干的架构选择比参数数量重要得多。


写在最后

八轮实验做下来,单字模型的架构探索到此正式收尾。

标准残差骨干(1.84M / 96.54%)是 2M 以下参数区间的实用底模,后续的端到端模型都基于它迁移。在相同架构方向下,冻结重排给出最高的分数——几 KB 的重排 MLP 在几乎不增加任何开销的情况下提供了稳定的小幅提升。

另一条赛道上,ConvNeXtV2 骨干用 89M 参数冲上了 97.45%,证明了这个数据集上单字分类的天花板位置。但它和标准残差路线在骨干类型、参数量级、训练配置上都不一样,是一条独立的探索。

MBConv 的失败则从反面验证了一个简单结论:在方式正确的前提下,参数量多能带来更高精度,但骨干的选择比参数量本身更关键。对于 64×64 低分辨率手写汉字,标准残差仍然是目前找到的最优架构。

单字分类是整个识别系统的第一步。做完这一步,后续的方向是把这个底座搬到行级端到端模型上。


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

Share: