公式识别实战:ResNet与Transformer从训练到部署全流程解析 📅 发布时间:2026/9/14 23:49:53 👁 浏览次数: 简介一份基于ResNetTransformer的公式识别项目源码与期末大作业报告适合计算机视觉、深度学习方向的本科生或研究生用于课程设计、期末大作业或毕业设计参考。项目源码已经本地编译验证可运行难度适中代码结构完整清晰配合高分报告能帮助理解公式识别任务中ResNet特征提取与Transformer序列解码的联合建模方法涵盖从公式图像输入到识别结果输出的关键流程。压缩包大小3.72MB内含项目源码与报告文档便于直接查阅和二次开发。该资源已有243人学习下载内容经助教老师审定质量有保障。通过源码可学习完整的数据处理、模型训练与测试评估流程报告则提供详细的实验设计、结果分析与项目总结是完成类似任务或入门公式识别方向的实用参考。1. 公式识别任务落地为什么是 ResNet Transformer公式识别在 OCR 里属于“结构复杂、符号稠密、语义强依赖”的一类和普通文字识别最大的区别在于同样的字符序列在不同层级结构下含义完全不同比如\frac{1}{2}和1/2渲染出来是两种东西。用传统 CRNN CTC 做公式识别最大的痛点是它只能输出线性序列无法建模括号嵌套、分子分母等结构化关系识别结果经常出现“字符全对、结构全错”的情况。近几年公式识别的主流方案基本收敛到编码器-解码器框架其中编码器用 CNN 提取视觉特征解码器用 Transformer 或 LSTM 逐步生成 LaTeX 序列。ResNet Transformer 的组合之所以是工程上的首选是因为 ResNet 的残差结构在深层网络上梯度更稳Transformer 的自注意力机制能直接建模公式中相距很远的符号之间的依赖——比如一个左括号和它对应的右括号可能隔着十几个 token。这套资源适合两类人一类是做课程设计、期末大作业的在校学生另一类是刚接触 OCR 结构化识别、想快速跑通一个完整 baseline 的工程师。它把“图片输入 → LaTeX 序列输出”的全流程都串了起来本地编译能过附带的报告也可以直接看出项目组的实验思路和分析方法。2. 数据管道与公式序列 token 化先把 LaTeX 变成模型能学的东西2.1 公式识别怎么抽象成一个序列生成问题在进入代码之前先要把数据流看明白。公式识别的输入是一张渲染好的公式图片比如x^2 y^2 z^2被渲染成 PNG 或者从论文截图里裁出来输出是一个 LaTeX 字符串模型要能把x^{2}y^{2}z^{2}这个 token 序列逐个生成出来。整个任务被建模成条件概率分布P(y|x)最大化的问题其中x是图像特征序列y是目标 LaTeX 序列P(y|x) Π P(y_t | y_t, x)这个式子意味着解码器在生成第t个 token 时看到的是整张图像的特征编码结果和前面已经生成的t-1个 token。这和语音识别、手写识别建模方式一致但公式识别对序列内部结构的要求更高。2.2 ResNet 编码器的输出如何喂给 TransformerResNet 在这里的角色是视觉特征提取器。输入单通道灰度图经过 ResNet 的卷积层和残差块后得到的是形如(Batch, 512, H, W)的特征图。这个特征图不能直接送进 Transformer因为 Transformer 期望的输入是序列而不是空间特征图。常见的做法有两种一种是把空间维度压平把 H 和 W 相乘得到序列长度同时做一次线性映射把 512 维的通道数降成 Transformer 的d_model维度另一种是像 ViT 那样把特征图切成 patch 再做线性投影但公式识别里符号比较密集patch 切割容易把符号切断所以基于 CNN 特征图压平的方式更常见。这个压平后的序列还会加上位置编码因为 Transformer 的注意力机制本身不感知 token 顺序。代码里常见的是使用可学习的位置编码# 假设 resnet 输出特征图形状为 (batch, 512, 16, 64) # 压平空间维得到 (batch, 1024, 512) flat_features cnn_features.flatten(2).permute(0, 2, 1) # 线性映射到 d_model 维 embedded self.linear_proj(flat_features) # 512 - d_model # 添加可学习位置编码 seq_len embedded.size(1) pos_embed self.positional_encoding[:, :seq_len, :] embedded embedded pos_embedflatten把 H 和 W 两个维度合并成序列长度permute调整维度顺序让序列维度排在第二维。linear_proj是输入投影层本质是一个不带偏置的线性层。位置编码是nn.Embedding需要在初始化时生成一个能覆盖最长序列长度的编码表。2.3 数据增强让模型见过更多“长歪”的公式图公式识别最容易遇到的问题是训练集和真实场景图片之间的分布偏移——训练集里公式图都是居中黑体白底实际拍下来的公式图可能是倾斜的、带噪点的、对比度不足的。针对这个问题常见的数据增强策略包括随机旋转、透视变换、亮度抖动和高斯模糊。代码里一般会用imgaug或albumentations写一个增强流水线比如import albumentations as A def get_train_augmentations(): return A.Compose([ A.Rotate(limit5, border_mode0, value255), A.RandomBrightnessContrast(brightness_limit0.2, contrast_limit0.2), A.GaussNoise(var_limit(10.0, 50.0)), A.Affine(scale(0.9, 1.1), translate_percent0.05), ])注意border_mode0配合value255旋转后填充的是白边而不是黑边这一点很关键。公式图片默认背景是白色如果填充黑色会在边缘制造出大量假像素干扰 ResNet 的低层特征提取。对训练来说小幅度的旋转和缩放比大幅度的仿射变换更可靠公式字符一旦发生明显畸变LaTeX 语义就变了。2.4 LaTeX 序列的 token 化细节公式识别的输出是 LaTeX 字符串但模型训练时不能直接用字符串做交叉熵必须把字符串切分为 token。切分策略有两种按字符切分和按词切分。按字符切分简单但序列长度会拉得很长Transformer 的计算复杂度是O(n²)长序列训练非常慢按词切分则把\frac、\sqrt这样的命令整体当作一个 token序列长度大幅缩短也符合 LaTeX 的语法单元语义。项目中 token 词典的构建逻辑大致如下# 从训练集所有 LaTeX 标注中收集 token def build_token_dict(all_labels, max_vocab_size5000): counter Counter() for label in all_labels: tokens tokenize_latex(label) # \frac{x}{y} - [\\frac, {, x, }, {, y, }] counter.update(tokens) vocab [pad, sos, eos, unk] [t for t, _ in counter.most_common(max_vocab_size - 4)] token2idx {t: i for i, t in enumerate(vocab)} idx2token {i: t for t, i in token2idx.items()} return token2idx, idx2tokenpad用于 batch 内对齐sos和eos标记序列的起止unk兜底。tokenize_latex函数的实现要考虑把\frac这种反斜杠开头的命令视为一个整体同时把大括号等单独拆开。这个处理直接决定词典大小和模型的学习难度如果直接用字符串 split\frac{1}{2}会被拆成[\\frac{1}{2}]这样一个不存在的 token词典里全是乱码。3. 训练回路搭建从数据加载到损失函数的选择3.1 数据加载器图片和标注如何对齐PyTorch 的Dataset类里每个样本返回一个三元组图像张量、目标序列的 token id 列表、序列的实际长度。图像需要统一缩放到固定尺寸比如(64, 256)高度 64、宽度 256。公式图长宽比差异很大直接拉伸会变形常见做法是保持宽高比缩放到高度 64然后右侧填充白色。def preprocess_image(img, height64, max_width256): w, h img.size ratio height / h new_w int(w * ratio) new_h height if new_w max_width: new_w max_width img img.resize((new_w, new_h), Image.LANCZOS) # 右侧填充白色到 max_width canvas Image.new(L, (max_width, height), color255) canvas.paste(img, (0, 0)) return np.array(canvas) / 255.0Image.LANCZOS是重采样滤波器适合图片缩小场景能保留较多高频边缘信息。像素值归一化到[0, 1]而不是[-1, 1]是因为 ResNet 的预训练权重同步是在 ImageNet 的mean[0.485, 0.456, 0.406]、std[0.229, 0.224, 0.225]下做的后续还需要用这套统计量做标准化。3.2 训练循环teacher forcing 与 masked self-attentionTransformer 解码器训练时使用的是 teacher forcing也就是在预测第t个 token 时不把上一步预测出来的 token 喂回去而是直接把 ground truth 的前t-1个 token 作为输入。这能加速收敛但也带来了训练和推理之间的暴露偏差问题——推理时模型会看到自己生成的错误 token。解码器端的 masked self-attention 是训练中一定要检查的地方。nn.TransformerDecoderLayer里自注意力部分需要传入一个上三角为-inf的 mask确保位置i只能看到位置i的信息否则模型在预测时把答案也看进去了训练 loss 会异常偏低但推理效果一塌糊涂。def generate_square_subsequent_mask(sz): mask torch.triu(torch.ones(sz, sz) * float(-inf), diagonal1) return masktorch.triu保留上三角对角线维度置为 1 开始置-inf这样 query 位置i与 key 位置j其中j i的注意力分数会被 Softmax 压成 0。3.3 损失函数与标签平滑训练使用交叉熵损失但直接对 target 计算CrossEntropyLoss时ignore_index要设置为pad的 id避免填充位置贡献 loss。criterion nn.CrossEntropyLoss(ignore_indextoken2idx[pad], label_smoothing0.1)标签平滑是个值得留意的细节。公式识别有些 token 是视觉上非常相近但语义不同的比如\sum和\Sigma、(和\{模型很容易在几个相似候选之间震荡。label smoothing 把 one-hot 概率分布换成一部分均匀分布让模型不追求绝对置信度能在相似 token 之间保留一定的容错空间。0.1是常用值太大容易欠拟合太小没效果。3.4 优化器与学习率调度Transformer 类模型对学习率很敏感。Adam 默认的学习率1e-3在 ResNet Transformer 上通常偏大训练前期经常出现 loss 不降反升或者直接 NaN。项目里常见的做法是使用 warmup 余弦退火optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-5) scheduler torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr1e-3, steps_per_epochlen(train_loader), epochsepochs, pct_start0.1, )OneCycleLR先让学习率从较低值线性升到max_lr再按余弦曲线降到接近 0。pct_start0.1表示前面 10% 的训练步数用于 warmup。实践中这条策略比固定学习率稳定很多尤其在 batch size 比较大时能显著降低前期发散的概率。训练过程中还需要监控两个值token 维度的准确率和完整的序列准确率。token 准确率高不代表公式识别对因为公式渲染成字符串后任何一个 token 错了整个公式就是错的。4. 推理解码与评估beam search 和 ExpRate 的全接触4.1 推理阶段为什么不用贪心解码训练时用 teacher forcing推理时没有 ground truth每一步的输入是上一步的输出。贪心解码每一步取概率最高的 token看起来合理但公式识别场景会出现“一步错步步错”的情况——前一个 token 选错了后面所有条件概率都建立在错误的历史上。beam search 保留 top-k 条候选序列每一步扩展 k 条路径最终选择整体概率最高的序列。代码实现通常维护一个候选集逐步扩展并剪枝def beam_search_decode(model, image_feature, token2idx, idx2token, beam_size5, max_len64): sos_id token2idx[sos] eos_id token2idx[eos] # 每条候选序列 token、log 概率、是否结束 sequences [[[sos_id], 0.0, False]] for _ in range(max_len): all_candidates [] for seq, score, ended in sequences: if ended: all_candidates.append((seq, score, ended)) continue # 取最后一步隐藏状态预测下一个 token 分布 logits model.decode_step(image_feature, seq) probs torch.log_softmax(logits[-1], dim-1) top_k_probs, top_k_idx torch.topk(probs, beam_size) for i in range(beam_size): new_seq seq [top_k_idx[i].item()] new_score score top_k_probs[i].item() ended top_k_idx[i].item() eos_id all_candidates.append((new_seq, new_score, ended)) # 按 log 概率排序保留 beam_size 条 sequences sorted(all_candidates, keylambda x: x[1], reverseTrue)[:beam_size] if all(s[2] for s in sequences): break best_seq max(sequences, keylambda x: x[1])[0] return [idx2token[t] for t in best_seq if t not in (sos_id, eos_id)]每一步对每条活跃序列预测下一个 token 的 log 概率然后topk取概率最高的beam_size个候选。beam_size5是标准设置增大到 10 能小幅提升准确率但推理时间增加明显。max_len64是输出序列的上限超过之后强制截断。公式越复杂需要生成的非终态 token 越多最大长度不够时会截断输出所以这个参数要看训练集里最长标注的长度来定。4.2 评估指标ExpRate 是唯一可信的指标公式识别领域标准的评估指标是 ExpRate也就是完全匹配的序列占比。只有预测的 LaTeX 序列和 ground truth 逐 token 完全一致才算这一个样本正确。除 ExpRate 之外常见的辅助指标还有指标计算方式用途ExpRate预测序列与标注完全一致的样本比例主指标直接反映可用性Token Accuracy预测 token 序列中正确 token 占比定位错误类型符号错还是结构错Edit Distance预测与标注的编辑距离均值衡量错误的严重程度距离越小越好评估脚本里一个容易忽略的点是序列规范化。同一公式有不同写法比如\dfrac{1}{2}和\frac{1}{2}在语义上完全一致但模型输出与标注在 token 层面不一致。标准做法是评估前做规范化def normalize_latex(pred, target): pred pred.replace(\\dfrac, \\frac) pred pred.replace(\\tfrac, \\frac) target target.replace(\\dfrac, \\frac) target target.replace(\\tfrac, \\frac) pred pred.strip() target target.strip() return pred, target规范化规则需要和训练集的标注风格保持一致。如果训练集里\frac和\dfrac混用最好先统一再训练。4.3 推理速度与 batch 解码逐个样本循环 beam search 在 CPU 上还能接受GPU 上就浪费了。model.decode_step如果每次只跑一个样本显存利用率很低。常见做法是把 batch 内不同长度的序列padding到同一长度一次前向算出所有候选的 logits再在 batch 维度上做topk。不过 beam search 的状态管理会复杂一些每条序列的 beam 路径都不同需要维护一个beam_ptr数组指向当前路径的父序列。在课程项目规模下单样本解码通常够用。推理性能瓶颈往往不在解码器而在 ResNet 的前向计算。测试时可以用torch.no_grad()包裹前向过程并把模型切成eval模式关闭 dropoutmodel.eval() with torch.no_grad(): feature resnet_encoder(image) output transformer_decoder(feature, target, tgt_mask)model.eval()影响的是 dropout 和 batch norm 的统计量这在 ResNet 编码器上尤其重要——ResNet 里每个 residual block 都带 BatchNorm测试时如果没有切到 evalBatchNorm 会使用当前 batch 的均值和方差结果会随着 batch 内容波动导致同一张图每次推理结果都不一样。5. 大作业报告的写法与常见训练陷阱排雷5.1 报告怎么组织才能撑起“高分”报告的作用是展示工程能力和实验分析能力。结构和要点如下第一章写任务背景和数据集核心是讲清楚公式识别为什么不能直接套用传统 OCR 方法引用精度对比表。第二章写模型设计要画出 ResNet 编码器的层数配置表以及 Transformer 的层数、头数、d_model 等超参数表。第三章是实验部分消融实验是重点——对比 ResNet18 和 ResNet34、对比有和没有位置编码、对比 beam size 1 和 beam size 5 的 ExpRate。第四章写失败案例找两三张模型识别错的公式图分析是符号混淆、结构错误还是训练数据覆盖不足。附录放主要代码和运行环境。两个容易被扣分的点一是实验只报一个最终准确率不做消融看起来像给定结论凑数据二是没有错误分析看不出作者对模型行为有理解。这两个部分宁可多写哪怕对比结果不如预期也是有效信息。5.2 常见训练陷阱与对应排查手段训练不收敛是最常见的问题。先看 loss 曲线——如果 loss 在前几百步没有明显下降问题多半出在数据侧。公式图片的像素分布如果严重偏向白色ResNet 的初始特征会饱和。检查方法很简单打印出一个 batch 的image.min()和image.max()确认归一化后数据范围是[0, 1]而不是[0, 255]。第二个高发问题是 token 词典构建错误。比如\sqrt被拆成了\\sqr和t两个 token模型训练时可能不报错但推理时永远生成不了正确的命令。排查方式是随机抽几个训练样本把 token id 序列还原成字符串确认\frac是一个完整 token。第三个问题是显存不足。Transformer 解码器的显存占用随序列长度二次增长公式比较长的时候很容易 OOM。常用的降显存方案是开启梯度累积和混合精度PyTorch 自带的torch.cuda.amp可以稳定减少显存占用scaler torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): output model(images, targets) loss criterion(output.view(-1, vocab_size), targets.view(-1)) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度把部分算子切成 FP16能省下约 40% 的显存ResNet 和 Transformer 在 FP16 下训练稳定性较好。如果 loss 出现 NaN检查一下学习率是否过大或者在GradScaler初始化时加上init_scale1024缓解。5.3 部署时的一个实用技巧ONNX 导出与 CPU 推理课程项目做到最后通常还要补一个“模型部署”的展示环节。ResNet Transformer 导出 ONNX 在 CPU 环境下推理一次大约耗时 300~800ms取决于公式长度和 beam size。导出时要注意动态轴torch.onnx.export( model, (dummy_image, dummy_target), formula_recognition.onnx, input_names[images, targets], output_names[logits], dynamic_axes{images: {0: batch}, targets: {0: batch, 1: seq_len}}, )dynamic_axes里把 batch 维和序列长度维标记成动态这样导出的模型能接受任意 batch size 和序列长度的输入。但tgt_mask的维度随序列长度变化ONNX 导出时需要把它作为输入参数传入或者在导出前固定max_len。更简单的做法是固定最大解码长度牺牲一点灵活性换取部署链路简单。实际项目里我倾向于先用 ONNX Runtime 在 CPU 上验证一套推理 demo再把模型接入前后端接口这样验收时可以直接演示比只交一个训练脚本要立体得多。本文还有配套的精品资源点击获取