☰
ResNet+Transformer手写数学公式识别:从原理到工程实践
2026/10/26 11:22:54 网站建设 项目流程

简介:这份资源是面向深度学习学习者与科研入门者的手写数学公式识别Python项目源码,采用ResNet提取图像深层视觉特征、Transformer自注意力机制建模符号布局与结构关系,可解决传统规则模板方法识别精度不足的问题,适合作为课程大作业、毕业设计或相关课题的参考实现。压缩包共40个文件,约4.21MB,以19个py源码文件为核心,涵盖编码器、解码器、位置编码、数据模块、词表与训练测试脚本,另含8个pyc缓存、6个zbak备份、3个txt说明及cfg、yaml等配置,目录按model、datamodule等模块划分,结构清晰便于二次开发。目前已有152人学习。项目经严格调试可正常运行,并获导师认可,读者可借此理解CNN与Transformer的融合思路、公式识别流程与训练配置,积累模型搭建与排错经验,仅限学习交流,不可商用。

1. 手写数学公式识别:ResNet 与 Transformer 拼起来到底解决了什么

手写数学公式识别这件事,真正难的不是把图片里的符号一个个认出来,而是让模型理解二维空间里的结构关系。一个分式,分子在上、分母在下,中间一条横线;一个积分号,上下限的位置决定了它是定积分还是不定积分;一个根号,根号里面的内容靠一条延伸的横线来界定范围。这些结构信息在普通文本识别里根本不存在,但在数学公式里,丢了结构就等于全错。ResNet 加 Transformer 的组合,恰好对应了这类任务的两个核心需求:ResNet 负责从图像中提取稳定的视觉特征,Transformer 负责把这些特征按正确的顺序和层级关系解码成 LaTeX 序列。这套方案适合已经跑通过图像分类或文本识别基础流程、想往结构化识别方向走的开发者,也适合需要把手写公式批量转成可编辑格式的工程场景。

2. 为什么是 ResNet 做编码器、Transformer 做解码器

2.1 卷积特征提取与序列建模的分工逻辑

手写公式图像和自然场景文字有一个本质区别:公式的笔画密度高、符号间空间关系复杂、同一符号在不同位置可能代表完全不同的语义。ResNet 的残差结构在这里的价值不是“深”,而是“稳”。深层网络在训练时容易出现梯度消失,残差连接让梯度能直接回传到浅层,保证编码器在提取细粒度笔画特征时不会因为层数增加而退化。常见做法是用 ResNet-34 或 ResNet-50 的卷积部分作为骨干,去掉最后的全连接层,保留从 1/4 到 1/32 分辨率的特征图。

Transformer 做解码器的理由更直接:公式的 LaTeX 序列本质是一个带位置依赖的符号序列,而 Transformer 的自注意力机制可以在解码每一步时动态关注编码器输出的不同空间位置。比如解码到\frac时,模型需要同时关注分子区域和分母区域的特征;解码到^时,需要关注上标位置。这种动态对齐能力是固定窗口卷积或纯 RNN 解码器很难做到的。

2.2 编码器-解码器的最小可跑通结构

下面是一个基于 PyTorch 的最小结构定义,编码器用 torchvision 里的 ResNet,解码器用标准 Transformer Decoder Layer。这段代码可以直接作为项目骨架使用。

import torch import torch.nn as nn from torchvision.models import resnet34 class FormulaEncoder(nn.Module): def __init__(self, pretrained=True): super().__init__() backbone = resnet34(pretrained=pretrained) # 去掉 avgpool 和 fc,保留卷积特征图 self.features = nn.Sequential(*list(backbone.children())[:-2]) # 将 512 通道降到 256,减少解码器计算量 self.proj = nn.Conv2d(512, 256, kernel_size=1) def forward(self, x): # x: [B, 3, H, W] feat = self.features(x) # [B, 512, H/32, W/32] feat = self.proj(feat) # [B, 256, H/32, W/32] B, C, H, W = feat.shape # 展平成序列,供 Transformer 做 cross-attention feat = feat.flatten(2).transpose(1, 2) # [B, H*W, 256] return feat class FormulaDecoder(nn.Module): def __init__(self, vocab_size, d_model=256, nhead=8, num_layers=4): super().__init__() self.embed = nn.Embedding(vocab_size, d_model) self.pos_enc = nn.Parameter(torch.randn(1, 512, d_model) * 0.02) decoder_layer = nn.TransformerDecoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=1024, dropout=0.1, batch_first=True ) self.decoder = nn.TransformerDecoder(decoder_layer, num_layers=num_layers) self.fc_out = nn.Linear(d_model, vocab_size) def forward(self, tgt_ids, memory, tgt_mask=None): # tgt_ids: [B, L], memory: [B, S, d_model] x = self.embed(tgt_ids) + self.pos_enc[:, :tgt_ids.size(1), :] out = self.decoder(x, memory, tgt_mask=tgt_mask) return self.fc_out(out)

编码器部分的关键参数是proj的 1x1 卷积,把 512 通道压到 256,目的是让解码器的 cross-attention 计算量可控。如果显存充足,可以保留 512 通道,但 batch size 要相应减小。解码器的num_layers=4是一个经验值,公式识别任务的数据量通常不会特别大,层数太多容易过拟合。pos_enc用可学习位置编码而不是正弦编码,是因为公式序列长度相对固定,可学习编码在短序列上收敛更快。

注意:ResNet 的预训练权重来自 ImageNet,虽然和公式图像域不同,但底层边缘和纹理特征仍然有效。如果手写公式数据集超过 5 万张,可以考虑在编码器上做一轮轻量微调;如果只有几千张,冻结编码器前几层更稳妥。

3. 数据准备与 LaTeX 词表构建的实操细节

3.1 手写公式数据集的标注格式与清洗

常见的手写公式数据集标注格式是“图像文件名 + LaTeX 字符串”,比如img_001.png对应\frac{a}{b} + \sqrt{x}。拿到数据后第一件事不是直接训练,而是做词表统计和异常样本过滤。LaTeX 里有些命令出现频率极低,比如\iiiint或\substack,如果词表里保留这些低频 token,会导致输出层维度虚高、训练时梯度稀疏。

我一般会先跑一遍全量标注的 token 频率统计,把出现次数少于 5 次的命令合并成<UNK>,同时保留{、}、^、_这些结构符号。结构符号不能合并,因为它们决定了公式的语法正确性。

import re from collections import Counter def tokenize_latex(latex_str): # 按 LaTeX 命令、单字符、结构符号切分 pattern = r'(\\[a-zA-Z]+|\\.|[{}^_&])' tokens = re.findall(pattern, latex_str) # 把连续普通字符拆成单字符,避免词表过大 result = [] for t in tokens: if t.startswith('\\') or t in '{}^_&': result.append(t) else: result.extend(list(t)) return result def build_vocab(latex_list, min_freq=5): counter = Counter() for s in latex_list: counter.update(tokenize_latex(s)) vocab = {'<PAD>': 0, '<SOS>': 1, '<EOS>': 2, '<UNK>': 3} for token, freq in counter.most_common(): if freq >= min_freq and token not in vocab: vocab[token] = len(vocab) return vocab

tokenize_latex里的正则把\frac这类命令整体保留,同时把普通字母拆成单字符。这样做的好处是词表规模通常能控制在 300 到 500 之间,输出层不会太大。min_freq=5是经验阈值,数据量小的时候可以降到 3,但要注意<UNK>比例不要超过 2%。

3.2 图像预处理与数据增强的边界

手写公式图像预处理的坑比自然图像多。公式图像通常是灰度图或二值图,直接转 RGB 送进 ResNet 会浪费通道容量。常见做法是把灰度图复制三通道,或者在编码器第一层把conv1改成单通道输入。我倾向于复制三通道,因为可以直接用 torchvision 的预训练权重,不用改结构。

数据增强方面,随机旋转和随机裁剪要谨慎。公式对水平对齐敏感,旋转超过 5 度就可能让上下标关系变得模糊。比较安全的增强组合是:随机亮度对比度扰动、轻微弹性形变、随机擦除小区域。弹性形变对手写笔画抖动有很好的模拟效果,但参数要调小,alpha=10、sigma=3左右比较合适。

import cv2 import numpy as np import random def elastic_distort(image, alpha=10, sigma=3): random_state = np.random.RandomState(None) shape = image.shape[:2] dx = random_state.randn(*shape) * sigma dy = random_state.randn(*shape) * sigma x, y = np.meshgrid(np.arange(shape[1]), np.arange(shape[0])) indices = (y + dy).flatten(), (x + dx).flatten() distorted = cv2.remap(image, indices[1].reshape(shape).astype(np.float32), indices[0].reshape(shape).astype(np.float32), cv2.INTER_LINEAR) return distorted

alpha控制形变幅度,sigma控制平滑程度。实际用的时候先把图像缩放到统一高度(比如 64 或 128),再施加形变,否则大图上的形变会过于剧烈。增强后的图像要检查是否还能肉眼辨认公式结构,如果连人都认不出来,模型更学不会。

4. 训练配置、损失函数与解码策略的避坑记录

4.1 交叉熵损失加标签平滑的实际效果

公式识别本质是序列生成任务,标准做法是用交叉熵损失,但直接硬标签训练容易让模型对正确 token 过度自信,遇到模糊笔画时输出不稳定。标签平滑(label smoothing)把硬标签里的 1 换成 0.9,其余概率均分,能明显改善解码时的鲁棒性。PyTorch 的CrossEntropyLoss自带label_smoothing参数,设 0.1 即可。

criterion = nn.CrossEntropyLoss(ignore_index=vocab['<PAD>'], label_smoothing=0.1) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)

学习率用 3e-4 是 Transformer 类模型的常见起点,配合余弦退火能在 50 个 epoch 内稳定收敛。ignore_index必须设成<PAD>的索引,否则 padding 位置会贡献无效梯度。

4.2 教师强制与自回归解码的切换时机

训练时用 teacher forcing,把真实标签序列右移一位作为解码器输入。推理时没有真实标签,只能自回归逐 token 生成。这里有一个常见翻车点:训练时一直用 teacher forcing,推理时误差累积,生成到后面就崩了。缓解办法是在训练后期引入 scheduled sampling,以一定概率把模型自己的预测作为下一步输入。

def train_step(model, images, tgt_ids, criterion, optimizer, teacher_forcing_ratio=0.9): model.train() optimizer.zero_grad() memory = model.encoder(images) # 右移一位作为解码器输入 decoder_input = tgt_ids[:, :-1] targets = tgt_ids[:, 1:] # 构造因果掩码,防止看到未来 token L = decoder_input.size(1) tgt_mask = nn.Transformer.generate_square_subsequent_mask(L).to(images.device) logits = model.decoder(decoder_input, memory, tgt_mask=tgt_mask) loss = criterion(logits.reshape(-1, logits.size(-1)), targets.reshape(-1)) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() return loss.item()

generate_square_subsequent_mask生成上三角为负无穷的掩码,保证解码器在预测第 t 个 token 时只能看到前 t-1 个。clip_grad_norm_设 1.0 是防止梯度爆炸的保险,Transformer 在深层堆叠时梯度容易突然变大。

4.3 避坑记录:手写公式识别里最容易翻车的 4 个点

现象一:训练 loss 正常下降,但验证集生成的 LaTeX 编译报错。原因通常是词表里缺少\left、\right这类成对命令,或者模型学会了{但没学会}。解决办法是在词表构建阶段强制保留所有括号类 token,并在解码后加一个简单的括号匹配检查,不匹配就回退到 beam search 的次优结果。

现象二:模型对短公式识别很好,长公式一长就乱。原因是位置编码长度不够,或者解码器层数太浅导致长距离依赖建模不足。检查pos_enc的最大长度是否覆盖了数据集中最长的公式序列,如果最长序列有 200 个 token,位置编码至少要有 256 个位置。另外可以把解码器层数从 4 加到 6,但要注意过拟合。

现象三:ResNet 编码器输出特征图太大,显存爆了。输入图像如果是 256x256,ResNet-34 最后一层特征图是 8x8,展平后是 64 个位置,这个量级没问题。但如果输入是 512x512,特征图变成 16x16,展平后 256 个位置,cross-attention 的显存占用会翻好几倍。解决办法是在编码器后面加一个自适应池化,把特征图压到固定大小,或者直接缩小输入图像尺寸。

现象四:推理时生成重复 token,比如连续输出多个^。这是自回归解码的经典问题,通常是因为训练时 teacher forcing 比例太高,模型没有学会在不确定时输出<EOS>。可以在解码时加重复惩罚(repetition penalty),对已经生成过的 token 降低其 logit 值。另外检查训练数据里是否有大量重复结构的公式,如果有,模型会倾向于复制这种模式。

5. 推理加速与精度验证的进阶技巧

5.1 用束搜索替代贪心解码的收益与代价

贪心解码每一步只选概率最大的 token,速度快但容易陷入局部最优。束搜索(beam search)保留 top-k 个候选序列,最终选整体概率最高的那个。在手写公式识别里,束宽设 3 到 5 通常能提升 2 到 4 个百分点的精确匹配率,但推理时间会线性增加。

def beam_search_decode(model, image, vocab, beam_width=3, max_len=200): model.eval() inv_vocab = {v: k for k, v in vocab.items()} with torch.no_grad(): memory = model.encoder(image.unsqueeze(0)) beams = [([vocab['<SOS>']], 0.0)] for _ in range(max_len): new_beams = [] for tokens, score in beams: if tokens[-1] == vocab['<EOS>']: new_beams.append((tokens, score)) continue tgt = torch.tensor([tokens]).to(image.device) L = tgt.size(1) mask = nn.Transformer.generate_square_subsequent_mask(L).to(image.device) logits = model.decoder(tgt, memory, tgt_mask=mask) log_probs = torch.log_softmax(logits[0, -1], dim=-1) topk_probs, topk_ids = log_probs.topk(beam_width) for prob, tid in zip(topk_probs, topk_ids): new_beams.append((tokens + [tid.item()], score + prob.item())) beams = sorted(new_beams, key=lambda x: x[1], reverse=True)[:beam_width] if all(t[-1] == vocab['<EOS>'] for t, _ in beams): break best_tokens = beams[0][0] return ''.join(inv_vocab.get(t, '') for t in best_tokens if t not in [vocab['<SOS>'], vocab['<EOS>'], vocab['<PAD>']])

束搜索的代价是每次扩展都要重新跑一遍解码器,beam_width=3 时推理时间大约是贪心的 3 倍。如果线上服务对延迟敏感,可以先用贪心解码,只对置信度低的样本启用束搜索。

5.2 用精确匹配率和编辑距离双指标验证

公式识别的评估不能只看 loss。精确匹配率(Exact Match)要求生成的 LaTeX 和标注完全一致,包括空格和括号,这个指标很严格但最能反映实际可用性。编辑距离(Edit Distance)则衡量生成序列和标注序列的最小编辑操作数,能反映部分正确的程度。我一般两个都看:精确匹配率低于 60% 说明模型还没学好,编辑距离大于 5 说明结构错误较多。

验证时还要做一件事:把生成的 LaTeX 用 matplotlib 或 sympy 渲染出来,和原图做视觉对比。有些错误在文本层面看不出来,比如x^2和x^{2}在编辑距离上只差一个字符,但渲染结果一样;而\frac{a}{b}和\frac{a}{b+c}编辑距离不大,渲染出来却完全不同。视觉对比能抓住这类结构性错误。

5.3 我踩过的最大坑:忽略数据分布偏移

最后一个想说的教训是关于数据分布的。我最早做这个方案时,训练集用的是印刷体公式和少量手写样本混合,验证集也是同样分布,指标很好看。但实际部署到真实手写场景后,精确匹配率直接掉了 20 个百分点。原因是真实手写公式的笔画粗细、倾斜角度、符号间距和训练集差异太大。后来我在训练集里加入了大量真实手写样本,并且用弹性形变和随机擦除做了强增强,才把差距缩小到 5 个百分点以内。如果你准备做这个方向,建议一开始就把数据采集和标注的预算留够,模型结构反而不是最花时间的部分。希望帮到你。

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

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

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

立即咨询