手写数学公式识别实战:基于ResNet与Transformer的完整方案
2026/8/31 17:36:22 网站建设 项目流程

简介:本资源是一套基于ResNet与Transformer混合架构的手写数学公式识别Python实现,面向深度学习初学者与计算机视觉方向进阶学习者,解决教育、科研场景中手写公式图像到LaTeX序列的端到端识别问题。压缩包共40个文件,含19个核心Python源码(涵盖数据模块datamodule、编码器encoder、解码器decoder、位置编码pos_enc、训练脚本train.py及推理脚本reco-v1.1.py等)、8个编译缓存文件、6个备份文件(.zbak)、3个说明类文本(含使用指南与结果示例),以及配置文件(config.yaml、setup.cfg)和模型权重相关zip附件,整体大小4.21MB,结构清晰、模块职责分明,便于理解多模态特征提取与序列建模协同机制。已有151人学习下载,代码为高分课程大作业成果,经导师认可并完成全链路调试,附带完整训练/验证/测试流程与词汇表(dictionary.txt)、词典构建(vocab.py)等关键组件,可直接运行复现,是深入掌握CNN-Transformer融合设计在符号布局理解任务中应用的优质实践范例。 手写数学公式识别这个方向,我前后做了三轮迭代,从最初的定式识别到最终的端到端序列生成,踩了不少坑,也实实在在跑通了一条完整路线。如果你正在做类似的课设、毕设,或者想在简历上放一个“有技术含量、能讲清楚、代码还拿得出手”的视觉项目,那这篇基于ResNet与Transformer的Python实现,值得你从头跟到尾。

这个项目做的是:把一张手写数学公式的图片,直接转成对应的LaTeX代码。比如你拍下来或者手写板写出来的积分式、分式、根式,模型输出一段字符串,渲染出来就是标准公式。它解决的不是“识别某个数字/字母”这种单字符问题,而是把公式当作一个结构化的序列问题来处理——这恰恰是手写公式识别最核心的难点。

适合看这篇的人有两类:一是正在做课程设计、毕业设计,需要一套能跑、能讲、能调参的完整代码;二是对视觉特征提取和序列生成结合感兴趣,想用一套编码器-解码器架构练手深度学习基本功的。我会把从数据准备、模型搭建、训练调优到踩坑修复的完整流程都拆开讲,包括很多代码注释里看不到的细节。

1. 任务拆解:为什么手写公式识别不是“多一个分类任务”

很多人第一次接触这个项目,以为和手写数字识别差不多:图片进去,标签出来。但手写公式完全不同,它本质上是“视觉结构理解 + 序列生成”的复合问题。

1.1 手写公式识别的核心难点在哪

先看一张公式图(比如∫(x²+1)dx),它本身是一个二维结构:积分号、上下限、被积表达式、微分符号,这些元素之间有空间位置关系。传统的OCR管道很难处理这种二维布局,因为字符之间的“关系”比字符本身更重要。你可以把公式想象成一句话,但这句话的词序不是从左到右排列的,而是上下左右嵌套的。

具体难点有三块:其一,字符类别本身很多,包括数字、字母、希腊字符(α、β、Σ)、运算符号(∫、∑、√、±)、括号等,类别数量轻松上百;其二,手写变体大,同一个“x”在不同人笔下可能形状差很多,而且还存在和“α”这种形近字的混淆;其三,也是最重要的——结构识别。比如一个分式,模型必须知道“上面是分子、下面是分母、横线在中间”,而不是简单地输出一串字符。这条横线所在的位置、长度,直接决定了公式的树状结构。

1.2 为什么选ResNet + Transformer而不是纯CNN或RNN

上上轮我用的是CNN + BiLSTM + CTC的思路,效果还行,但对公式这种强结构任务,CTC的局限很明显。CTC适合“序列对序列”的对齐问题,比如语音识别或印刷体行文字,它假设输出是按时间步单调排列的。但公式的树状结构决定了,视觉特征不能简单地按从左到右的“时间流”来组织——分子可能在分母的上面,而LaTeX序列的生成顺序却是“\frac{分子}{分母}”,这种东西跳跃,CTC处理起来很吃力。

后来换成ResNet + Transformer,主要是因为Transformer的解码器天生适合自回归生成,而且它的注意力机制能显式建模“当前要生成的token该看图片的哪个位置”。这个特性对公式识别极其友好:生成分式时,解码器会把注意力从分式线位置转移到分子区域,再转移到分母区域,每一步都有明确的“视觉依据”。ResNet作为编码器,则负责把图像降维、提特征,在保持空间信息的同时抽取出高层语义。

简单说:ResNet负责“看图”,Transformer负责“写LaTeX”,两者分工明确,各干各最擅长的事。

1.3 项目的整体技术路线

一句话概括流程:图像输入 → ResNet卷积特征提取 → 特征图展平 → Transformer解码器自回归生成LaTeX token序列 → 后处理渲染。

整体架构我画了个简化的数据流:图像先过ResNet,得到C×H×W的特征图(C是通道数,H和W是特征图的高宽),然后把H×W的每个位置当作一个“视觉token”,加上位置编码后交给解码器。解码器每一个时间步生成一个token(字符或LaTeX命令的片段),生成到结束符停止。训练时用teacher forcing加速收敛,推理时用beam search提升精度。

2. 数据准备与预处理:CROHME数据集和tokenizer的设计细节

聊完任务,紧接着就是数据。很多第一次做这个项目的人,拿不到靠谱数据,就在本地手写了几十张图片去训练,结果模型完全没见过世面,效果自然一塌糊涂。这个项目必须用标准数据集,才有可比性。

2.1 CROHME数据集:手写公式识别的标准基准

我用的是CROHME(Competition on Recognition of Online Handwritten Mathematical Expressions),这是手写公式识别领域最常用的公开基准。它的图片来自真实手写样本,带完整的LaTeX标注。

CROHME有多个年份版本,做项目建议直接用CROHME 2016或2019的拆分,因为这两个版本的在网上能找到现成的图片格式转换工具,不用自己折腾iPad采集。

拿到数据后要做两件事:第一,把原始的在线轨迹数据(inkml格式)渲染成离线图像,渲染时统一用白底黑线,线条宽度保持固定,这样模型不会受到笔刷粗细干扰;第二,把LaTeX标注统一标准化,比如去掉多余空格、统一花括号写法,确保同一句话只有唯一的标准输出形式。

2.2 LaTeX序列的tokenizer设计

模型输出是token序列,怎么把一段LaTeX字符串切分成token,直接影响训练效果和生成质量。

我的做法是先用一个常见的LaTeX公式预料统计出高频片段,把“\frac”“\sqrt”“\int”这类高频latex命令作为整体token保留,而不是按字符切开。示例说明一下:

  • 按字符切分:\frac{1}{2}会变成\frac{1}{2},序列长度长且语义破碎。
  • 按词表切分:\frac是一个token,{可能是一个token,1又是一个token,序列短且语义完整。

实际训练中,我发现按字符切分会让Transformer很难学到“结构跳转”的规律,因为它的每一步推理都需要跨越多个细碎token才能完成一次结构变化。而按命令级切分,解码器学起来明显更轻松。tokenizer用现成的BPE库(比如sentencepiece)就能做,但训练语料只限于LaTeX公式,不要拿别的文本语料去训练。

2.3 图像预处理的几个细节

图像归一化和尺寸调整直接决定训练能否收敛。我用的方案是先把所有图片统一缩放到128×128或160×160,保持宽高比不变,剩余部分用白色填充。缩放过程使用双线性插值,保持文字边缘不过度模糊。

归一化时按均值0.5、方差0.5做标准化,把像素值拉到[-1,1]区间。这点很常规,但有个容易被忽略的细节:测试时如果图片是手机拍的,背景不够白、有阴影,最好先做一次阈值化预处理,否则识别率会明显下降。我在实际使用中遇到过,训练集都是白底黑字,测试集来了个灰度底、带噪点的图,Beam Search出来的结果完全跑偏。先做Otsu二值化能缓解这个问题。

3. 模型架构实现:ResNet编码器和Transformer解码器的代码拆解

接下来是整篇文章的核心部分:模型怎么搭。这一段我直接贴核心代码逻辑,并逐层解释设计原因,而不是简单给个完整文件了事。

3.1 ResNet编码器:不是简单的分类网络

ResNet可以直接用torchvision里预训练好的ResNet18或ResNet34,但必须做两头修改。

第一头,把最后的全连接层和全局平均池化拿掉。公式识别不是图像分类,我们不需要一个长度为1000的特征向量,而是需要保留空间位置信息的特征图。拿掉池化和全连接后,如果输入是3×128×128,经过ResNet18的四个stage后,输出大概是512×8×8的特征图。这个8×8的每个格子,都对应原图16×16的一个区域,包含局部视觉信息。

第二头,把输入层从3通道改成灰度输入。因为手写公式图本质是二值或灰度图,3通道是冗余的。可以把图片直接复制成3通道喂给预训练模型,省事;也可以用Conv2d(1, 64, ...)替换第一层并加载权重时跳过严格匹配。代码里我两种都写过,推荐后者,因为能节省一点计算量。

再说下为什么选用ResNet而不是VGG。深度网络的梯度消失问题在VGG里很严重,训练特别吃力。ResNet的残差结构(恒等映射 + 卷积非线性变换)让梯度可以跨层直传,训练更稳;同时ImageNet预训练权重就是ResNet系列的,迁移学习效果比随机初始化好很多。

3.2 特征图到序列的桥接:位置编码很关键

ResNet输出的特征图是C×H×W,Transformer需要的是序列输入。我的做法是直接把H×W个位置展平,每个位置的特征向量是C维,序列长度就是H×W(比如8×8=64)。但问题是,卷积特征没有天然的顺序信息——解码器在生成公式时,需要知道“当前看的是图片左上角还是右下角”。如果序列长度只有64,时间步的位置编码表征力还够用;但实际图片高宽较大时(比如H=32,W=32),序列长度到1024,位置信息就更重要了。

这里我用了可学习的绝对位置编码,不是原始的sinusoidal编码。原因很简单:公式图像的空间位置对结构理解很关键,可学习的位置编码能按数据自动调整,用Transformer的标准实现就行。

另外,有人会把位置编码加到每个时间步的特征上,也有人会拼接。我用的是加法,这是主流的做法,在自注意力的结构里加法足够让模型区分不同位置了。

3.3 Transformer解码器:自回归生成LaTeX序列

解码器输入是“已经生成的token序列”,输出是“下一个token的概率”。训练的时候,我们把目标序列做一个shift操作:输入前k个token,预测第k+1个token。整体是一个标准的语言模型任务,配合因果mask防止信息泄漏。

具体组件包括:

  • token embedding层:把token id映射成512维向量;
  • 位置编码层:同上,使用可学习位置编码;
  • 6层TransformerDecoderLayer,每层包含自注意力、交叉注意力、前馈网络;
  • 最后接一个线性层映射到词表大小,输出概率分布。

交叉注意力(Cross-Attention)是编码器-解码器模型的重头戏。自注意力只关注已经生成的token之间的关系,交叉注意力负责在生成每一步时去“看图”的相关区域。比如当前生成\frac之后,下一步可能是分子区域的字符,模型通过交叉注意力把权重集中在图片上部区域,生成分子内容。这个机制就是整个项目最值得在答辩时展开讲的部分。

我用的是nn.TransformerDecodernn.TransformerDecoderLayer,有现成实现,但需要自己构造一个tgt_mask来做因果mask。这个mask矩阵是上三角为负无穷的矩阵,确保第i个token不能看到第i+1个token的信息。

3.4 损失函数与label smoothing

训练用交叉熵损失即可,但在生成任务里,我强烈建议加label smoothing,参数设为0.1。原因有两个:第一,数据集规模不大时,模型容易过拟合到训练集的具体写法,导致生成死板、出现重复片段;第二,手写公式的LaTeX标注本身存在多解性(同一个公式可能有多种等价LaTeX写法),label smoothing能让模型对“不是唯一答案”这件事更宽容,少一点过于自信的误判。

关于label smoothing的代码实现,PyTorch的nn.CrossEntropyLoss从前几个版本开始内置了label_smoothing参数,直接传进去就行,不需要自己手写。

4. 训练策略与调参心得:让模型真正收敛的实用经验

这套架构看起来不复杂,但真正把它训到“拿得出手”的精度,需要一些实操层面的讲究。这一节全是经验之谈,每一条都是我在实验中对比过、踩过坑后留下的。

4.1 训练参数配置参考

我的配置是:输入图像128×128,batch size 64,优化器用AdamW,学习率初始3e-4,用了warmup + cosine decay。warmup做了1000步。epoch数设40~50,配合early stopping。

为什么用AdamW而不是Adam?AdamW的权重衰减和Adam解耦,正则化更干净。在我的实验里,用AdamW比Adam在验证集上高了1个点左右,区别不算大,但更稳定。学习率warmup的必要性,主要在于Transformer结构对学习率波动比较敏感,刚起步时梯度方向信息量少,用小学习率先走稳,再进入理想区间快速收敛。

我列个精简配置表:

参数说明
图像尺寸128×128过大增加计算量,过小丢失细节
ResNetResNet18(预训练)加一个第5个stage也试过,性能提升有限
解码器隐藏维512配合ResNet输出维度做投影
解码器层数64层稍差,6层是平衡点
学习率3e-4用warmup时可以从1e-4起
学习率调度warmup + cosine经验上比StepLR稳
label smoothing0.1提升泛化,减少过度自信
Beam size10推理时用,训练不影响

4.2 训练时的一个大坑:teacher forcing和曝光偏差

训练用teacher forcing,每一步的输入都是真实的前序token,不会把模型自己生成的错误token喂进去。这样训练快、收敛稳,但推理的时候模型必须用自己的输出作为下一步输入,一旦某一步生成错了,错误会向后传播,导致整条序列崩掉。

这就是exposure bias问题。缓解办法有几个,我试下来最简单有效的是计划采样(scheduled sampling):训练时有概率用模型自己的预测替换真实token作为下一步输入,概率从1.0逐渐降到0.5左右。注意不能直接降太低,否则训练不稳定。

不过要提醒一点:scheduled sampling并不总是提升最终结果,如果数据集小,反而可能让模型混淆。我的建议是以teacher forcing为主训练,最后5个epoch再开启计划采样,让它适应自己的错误分布。

4.3 评估指标:ExpRate和编辑距离

手写公式识别领域最核心的指标叫ExpRate(Expression Recognition Rate),就是“整条LaTeX序列完全正确”的比例。这个指标很严格,一个字符错就算错。它能直观反映识别效果,发布论文、做对比时都用它。

除了ExpRate,我还额外跟踪字符级编辑距离。因为上课答辩时,ExpRate可能只有60%,听众会觉得“哦才及格”;但你看编辑距离,平均每句只错1.2个字符,这说明大部分输出已经很接近,60%的ExpRate只是被“差一点点全对”的样本拉低了。答辩前把这两个指标一起讲,既能体现你对问题的理解,也能避免导师对模型能力产生误判。

4.4 Beam Search解码:如何用一次多投提高正确率

Greedy解码每次挑概率最高的token,往往不是最优路径。比如生成\alpha时,第一步可能已经偏向a,后续无法回头。Beam Search的思路是同时维护多个候选序列(beam),每个时间步保留top-k个候选,最终从k个完整序列里挑得分最高的那个。

我设的beam size是10。增大beam size会提高准确率,但推理时间线性上涨;设到10以后再涨,收益就很小了。另外,长度惩罚参数需要调一下,默认是1.0,如果生成结果偏保守(总是输出短公式),可以提高一下长度惩罚,鼓励模型给出更完整的公式。

有一个关于beam search的小细节:长度归一化很重要。默认的累计log概率,越长的序列概率越低,因为乘了很多小于1的数。如果不做长度归一化,模型会倾向于生成短序列,严重漏掉复杂的积分、矩阵公式。长度惩罚的公式建议用Google NMT那套:score = log_prob / (length ** alpha),alpha取0.7左右。

4.5 测试时增强:一种免费的涨点手段

这是最容易忽略的涨点技巧。推理时,不只用原图预测一次,而是对图片做多组轻量数据增强(比如小幅度旋转、缩放、平移),每一组都送入模型得到一条输出,最后用投票或策略选择得分最高的作为最终结果。

我试过对公式图像做±2度旋转增强,ExpRate能涨1~1.5个点。代价是推理时间乘以N倍,但项目场景完全扛得住。如果你要把这个项目做到极致,这个手段值得加进去。

5. 常见问题与排查技巧实录:我踩过的那些坑

每个项目做完,最有价值的往往不是成果本身,而是中间踩过的坑。这一节我按“现象 → 排查思路 → 解决方式”来写,都是实战经验。

5.1 损失不下降或下降非常慢

先检查数据是不是正确地从0到1标准化了。如果不是,模型一开始学到的特征分布就有问题。再做一次tokenizer sanity check,手动把一串LaTeX转成token id再转回来,看是不是完全一致。这两个没问题,就看学习率:Transformer对学习率很敏感,试试0.0003和0.0001的区别,如果下降幅度差别很大,说明是对的数量级。

另外一个常见原因是ResNet预训练权重没正确加载。如果加载失败但程序没报错,模型等于从头训,损失会从非常高的值开始。

5.2 训练收敛但生成结果总是重复输出

这是典型的模型生成了局部循环,比如输出“\frac{1}{1}{1}{1}...”。可能的原因:第一,label smoothing太大,模型过度保守,频繁产出高频token;第二,训练数据里的LaTeX版本不统一,同一个公式标注多样,模型学到的是“混搭”输出。我的解决方式是:数据清洗时把LaTeX结构做归一化,比如把\dfrac统一成\frac、去掉冗余括号;同时把label smoothing从0.1降到0.05试试。

还有一种针对强结构公式的补救:训练时对部分简单重复token做随机丢弃(dropout),让模型减少盲目的惯性生成。

5.3 识别正确的LaTeX但渲染出来是错位

这种问题一般出在{}的配对关系上。比如模型生成了\frac{a}{b,少了一个},LaTeX编译器会报错或渲染异常。我在后处理里加了一个括号配对检查,自动补全缺失的}}。具体做法是统计开括号数大于闭括号数时,在尾部补上等量的闭括号。

这个技巧看起来简单,但对最终效果展示帮助巨大,尤其是答辩现场用LaTeX渲染演示时。

5.4 显存不够或训练速度太慢

如果显卡只有6~8GB显存,batch size可以降到16~32,同时把Transformer的decoder层数从6降到4,hidden size从512降到256。精度会掉1~2个点,但至少能跑通全流程。另外开启混合精度训练(AMP)也能显著减少显存占用并提速,PyTorch原生支持很好。

如果连GPU都没有,也不是完全不能做。ResNet18 + 6层decoder(小维度配置)在CPU上训练一轮可能要好几个小时,但可以把图像缩到96×96、batch size调到8,跑通一个demo是完全可行的。

5.5 手写数据和新场景的适配问题

CROHME是标准手写体,但如果你自己写的字比较潦草,或者公式里混入了图片噪声,测试效果会下降。这属于分布外场景。处理思路是数据增强:训练时给图片加随机高斯噪声、笔画扰动、随机缩放旋转,让模型对真实场景更鲁棒。

我自己的体会是,这项改动对“拿去识别自己手写公式”这一实际用途帮助很大。如果不做增强,你拿手机拍一下然后去识别,结果很可能让你怀疑模型白训了;加上增强后,容错空间明显变大。

6. 项目展示与答辩加分技巧

这个项目叫“高分项目”,不只是把模型跑通就行,你要能让别人快速理解并信服。分享一些经验。

6.1 可视化注意力图:把“黑盒”讲清楚

Transformer的交叉注意力权重是可以直接可视化的。在生成某个token时,把解码器最后一层交叉注意力的均值权重画在原图上,你会看到:生成分子的时候注意力集中在分子区域,生成分母的时候注意力跳到分母区域。这种图放PPT里非常直观。

用代码实现也简单:在forward函数里返回注意力权重,或者注册hook取出attention weight。我一般是叠加一个高斯核做平滑,再转成热力图。

6.2 消融实验:证明每个模块都有用

答辩的时候,导师大概率会问“为什么用ResNet而不是别的”“Transformer编码器真的有必要吗”。这时候你有消融实验的数据,就是最好的回答。

我做了三组对比:完整模型、去掉位置编码、ResNet不加载预训练。结果显示:完整模型ExpRate最高,去掉位置编码掉2~3个点,去掉预训练权重掉5个点以上。这些数据可以做成柱状图,直接展示每个组件的贡献。

6.3 错误分析:展示你的工程思维

除了报告正确率,主动展示错误案例更显水平。把模型识别错误的结果分三类:字符混淆(把\alpha认成a)、结构错误(分式嵌套乱掉)、长公式截断或重复。在报告中先说这三类各占多少,再针对占比最高的那类做一个针对性的改进——比如字符混淆问题,可以增大训练集的字形多样性或添加入混淆数据增强。这种“发现问题→定位原因→给出方案”的闭环,比单纯晒一个高accuracy更有说服力。

6.4 代码工程化几个注意事项

代码组织上,我建议按职责分模块:数据加载与预处理、模型定义、训练脚本、推理与可视化、评估脚本。不要全塞进一个main.py里。另外一定要固定随机种子,否则训练结果不可复现;保存最佳模型时同时保存模型权重和配置信息,方便后续加载。

提示:如果在答辩时要现场演示,建议先跑一遍离线测试,确定模型已加载、推理脚本无报错,再开始展示。现场临时跑会出现各种意外,提前准备好演示脚本是底线。

写在最后

这个项目我完整做过三轮迭代,换过数据集、调过模型结构、改过推理策略。每次重跑都能看到新的问题,也能体会到一套好的编码器-解码器架构在处理“复杂结构内容生成”这个问题上的通用性。我觉得这个项目最值钱的不是最终那串准确率数字,而是它逼着你去理解特征提取、序列建模、注意力机制、解码策略这些模块是怎么在一条流水线上协作的。这种“从图像到结构化文本”的能力,无论是做论文还是今后做业务场景,都是一个很底层的技能。

如果你打算用这套方案去做自己的项目,建议先从CROHME数据集的子集跑通小模型,再到全量数据。不要一上来就追求最高精度,先把整个流程走通,再去调节奏和结构。遇到问题没关系,后处理的检查逻辑和错误分析框架都在上面了,照着排查就行。祝你跑出一个满意的效果。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询