
简介一套基于Python与神经网络模型的数学公式识别工程面向本科毕业设计、课程项目以及深度学习初学者实现从图像输入到公式输出的端到端流程。压缩包共76个文件、约44.5MB以35个Python源码模块为核心覆盖数据预处理、模型构建、训练、预测与评估链路另有4个Jupyter Notebook演示脚本、JSON配置与词汇表、GIF格式训练可视化动画、TXT格式公式样本数据以及docx手册和README文档结构清晰方便按需查阅。源码均已在本地编译可运行项目评审分达到95分以上难度适中并经过助教审定可直接作为毕业设计参考或进行二次开发。内容还包含可视化预测演示与架构示意图、注意力分布可视化脚本可辅助理解模型内部运行细节。附带手册对代码结构和实验步骤有较详细介绍适合需要完整数学公式识别方案的学习者已有145人学习是一份完成度高、便于落地的毕业设计参考资料。1. 数学公式识别不是 OCR为什么用 LaTeX 序列作为中间表示做过公式识别的人都有个共同感受同样的公式截图清晰度一变、字体一换识别结果立刻崩掉。传统 OCR 管线把图像切成单字符再逐个分类遇到根号、分式、上下标这种二维嵌套结构时非常吃力因为字符之间的空间关系本身就是数学语义的一部分。这个项目给出的解法很直接不识别“字符”而是识别“LaTeX 字符串”。它把一张公式截图看成一个序列生成任务——输入是图片的像素特征输出是\frac{a}{b}这样的 LaTeX 标记流之后交给渲染器就能还原成可编辑的公式。源码基于 LaTeX_OCR 结构改造包含完整的 encoder-decoder 训练框架、注意力可视化脚本和评估工具评审分 95 分以上适合做毕业设计、OCR 方向入门研究也适合想快速跑通一个 Seq2Seq 图像生成项目的开发者。下面按数据管道、模型架构、训练配置、调试技巧四个层面拆解这套源码。2. 数据管道从 formulas.norm.txt 到可训练的样本对2.1 数据文件里到底存了什么项目data/目录下有三个关键文件train.formulas.norm.txt、val.formulas.norm.txt、test.formulas.norm.txt。这是源码预处理后的“规范化”LaTeX 序列每行对应一张公式图片的标注。所谓规范化常见做法是去掉多余空格、统一\left \right配对、把{和}按语义保留或删除。拿一行实际数据举例\frac { x ^ { 2 } 1 } { x - 1 }注意这里 token 之间用空格分隔每个符号都是一个独立的词。这样做的原因是后续构建词表时直接按空白切分就能得到 token 列表不需要再做中文分词或字符级处理。词表文件vocab.json里就是这些 token 到整数 id 的映射形状类似{sos: 0, eos: 1, pad: 2, \\frac: 3, ...}。训练脚本加载公式序列后会为每个序列加上sos开始标记和eos结束标记用于解码器的 teacher forcing 训练。2.2 图片与公式的配对加载逻辑图片数据没有直接打成 lmdb而是用 Image 路径加公式序列的方式组织。数据集的目录结构大致是data/images_train/下存放按序号命名的图片train.formulas.norm.txt的第 N 行对应第 N 张图。加载时最核心的问题是图片宽高不统一而神经网络要求 batch 内张量形状一致。源码中的常见做法是先把图片高度 resize 到固定值比如 64 或 128宽度按原图宽高比缩放然后在一个 batch 内部取最大宽度做 padding。落到代码上是这样from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class FormulaDataset(Dataset): def __init__(self, img_dir, formula_file, vocab, img_height64, max_width512): self.img_paths sorted(glob.glob(f{img_dir}/*.png)) self.formulas [line.strip().split() for line in open(formula_file)] self.vocab vocab self.img_height img_height self.max_width max_width def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img Image.open(self.img_paths[idx]).convert(L) # 保持宽高比缩放到固定高度 w, h img.size new_w int(w * self.img_height / h) img img.resize((new_w, self.img_height), Image.BILINEAR) img_tensor T.ToTensor()(img) # 形状 (1, H, W) # 将 LaTeX 序列转成 id并加上 sos 和 eos formula [sos] self.formulas[idx] [eos] formula_ids [self.vocab.get(tok, self.vocab[unk]) for tok in formula] return img_tensor, torch.tensor(formula_ids, dtypetorch.long), new_w这段代码里有个容易忽略的细节返回了new_w。这不是随便给的而是给 collate 函数用的——batch 内所有图片的宽度不同需要知道每张图的实际宽度来生成 attention mask避免 padding 区域参与 attention 计算。我在实际移植这个逻辑时踩过坑如果不返回宽度只靠 padding 后的张量形状去生成 mask会把 padding 列也当成有效像素导致训练时 loss 偏低但推理时结果明显变差。2.3 序列长度过滤与词表裁剪公式识别和普通 OCR 有一个显著差异LaTeX 序列长度分布极不均匀。简单公式可能只有 20 个 token复杂积分公式可能超过 200 个。如果全部塞进模型batch 里 padding 占比过高GPU 显存浪费严重。源码在build.py里做了长度过滤常见阈值是max_seq_len150超过的直接丢弃。同时词表也做了裁剪只保留出现频率前 5000 的 token低频符号统一映射到unk。配置项常见设置说明img_height64 / 128高度越小训练越快但细分子式可能模糊max_seq_len150超过此长度的样本不参与训练vocab_size5000覆盖率足够覆盖常见 LaTeX 命令过滤比例约 8% 样本被丢弃长公式占比不高影响有限3. Encoder-Decoder 拆解img2seq.py 里的注意力机制3.1 整体架构和 forward 数据流项目源码的model/目录下img2seq.py是主模型文件components/里放着 encoder、decoder 和注意力模块的独立实现。从文件命名能看出这套代码经历了从基础版本到 Torch 版本的演进base.py是抽象接口base_torch.py是 PyTorch 实现img2seq_torch.py是完整模型。整体思路是CNN 编码器抽取图像的视觉特征序列RNN 解码器逐步生成 LaTeX token。流程如下输入图片(batch, 1, H, W)经 CNN 得到特征图(batch, C, H, W)把特征图展平成(batch, H*W, C)作为“视觉 token”序列解码器 LSTM 每步输出一个 token 概率分布同时通过注意力从视觉序列中检索相关区域训练时用 teacher forcing上一步的 ground truth token 作为当前步输入3.2 注意力打分代码级的解释这套源码用的注意力是 Luong 风格的乘性注意力multiplicative attention也叫 dot-product attention。核心代码在components/的 attention 模块里简化后如下import torch import torch.nn as nn import torch.nn.functional as F class Attention(nn.Module): def __init__(self, decoder_dim, encoder_dim): super().__init__() # 将 decoder 隐状态投影到与 encoder 特征相同的维度 self.decoder_proj nn.Linear(decoder_dim, encoder_dim) def forward(self, decoder_hidden, encoder_outputs, maskNone): # decoder_hidden: (batch, decoder_dim) # encoder_outputs: (batch, seq_len, encoder_dim) proj_hidden self.decoder_proj(decoder_hidden).unsqueeze(1) # (batch, 1, encoder_dim) # 点积得注意力分数 scores torch.bmm(encoder_outputs, proj_hidden.transpose(1, 2)).squeeze(2) # (batch, seq_len) if mask is not None: scores scores.masked_fill(mask 0, -1e9) attn_weights F.softmax(scores, dim1) # (batch, seq_len) context torch.bmm(attn_weights.unsqueeze(1), encoder_outputs).squeeze(1) # (batch, encoder_dim) return context, attn_weights这段逻辑描述的是解码器当前隐状态先经过一个线性投影和 encoder 输出的全部视觉特征做点积得到每个位置的匹配分数再 softmax 成权重加权求和得到 context vector。注意力 mask 的作用是把 padding 区域的分数压到负无穷softmax 之后权重接近 0。实际训练中batch 内图片宽度差异大的时候这个 mask 尤其重要——否则模型会学到从空白区域“编造”符号。我在调试时发现如果不加 maskloss 下降更快但验证集 CER字符错误率反而变高典型的过拟合 padding 特征。3.3 解码器的输入拼接和 teacher forcing解码器每一步的输入由三部分拼成上一步预测或 ground truthtoken 的 embedding、上一步的 context vector、以及上一步 LSTM 的输出。这种拼接方式在源码里是直接 concat 后过一层线性层降维。训练时teacher_forcing_ratio控制多大比例用 ground truth 而非模型自己的预测。这个项目默认值常见为 0.5即一半时间用真实 token一半时间用预测 token。训练后期把比例逐渐降到 0能让模型在推理时更稳。4. 训练配置与命令行复现从 model.json 到 evaluate 脚本4.1 模型配置文件的字段含义项目根目录的configs/下同时存在model.json、data.json、training.json三份配置外加一个training_small.json。后者的“small”通常指更小的词表、更矮的图片高度和更短的序列长度用于快速验证代码能不能跑通。打开model.json常见字段如下{ encoder: { cnn_type: resnet, cnn_layers: [2, 2, 2, 2], hidden_size: 256 }, decoder: { rnn_type: lstm, hidden_size: 256, num_layers: 1, dropout: 0.3, attention_dim: 256 }, vocab_size: 5000, max_seq_len: 150, embedding_dim: 128 }cnn_layers对应 ResNet 每个 stage 的残差块数量[2, 2, 2, 2]就是 ResNet18 的结构。hidden_size同时控制 encoder 输出通道和 decoder LSTM 隐层维度两处保持一致能减少不必要的线性变换。dropout只加在 LSTM 输出层不加在注意力模块内部。attention_dim是投影层的目标维度如果 encoder 特征维度和 decoder 隐层维度不同这里通常取两者的公共中间值。4.2 训练命令与超参数调节策略源码根目录的train.py支持直接指定数据集和配置路径。训练命令通常长这样python train.py \ --model_config configs/model.json \ --data_config configs/data.json \ --training_config configs/training_small.jsontraining_small.json里的核心超参数如下表参数名示例值作用batch_size32公式识别显存占用大建议从 16 起步init_lr0.001Adam 初始学习率lr_patience3验证集 loss 连续 3 个 epoch 不降则学习率减半teacher_forcing_ratio0.5训练时使用 ground truth 的概率grad_clip5.0梯度裁剪防止 LSTM 梯度爆炸epochs50早期可设 20 观察收敛曲线直接照抄这份配置跑全量数据集显存占用会非常高。原因在于 encoder 输出的特征图没有下采样到很小比如输入高度 128、宽度 512 的图片经过 ResNet 后空间尺寸可能还有 4×1664 个视觉 token每个 token 的维度是 256batch 32 时 attention 分数矩阵是 32×64×解码步数虽然不算大但解码器每步都要计算反向传播时中间变量累积得很快。我的建议是先把batch_size减半到 16同时把图片高度降到 64验证能正常收敛后再逐步恢复。4.3 评估脚本evaluate_txt.py 和 evaluate_img.py 的分工项目里有两个评估脚本职责完全不同。evaluate_txt.py评估的是“LaTeX 序列预测准确率”——输入图片模型输出一整个 token 序列然后和 ground truth 字符串逐 token 对比。这个脚本能输出两种指标per-token accuracy 和 BLEU 分数。BLEU 在这里比 Levenshtein 距离更好用因为它基于 n-gram 重叠能容忍个别符号预测偏差。evaluate_img.py则是把图片先裁切、resize、归一化再走一遍完整推理实际上是一个可视化推理脚本输出结果直接渲染成图片方便肉眼判断模型识别效果。评估命令python evaluate_txt.py \ --checkpoint checkpoints/best.pt \ --model_config configs/model.json \ --data_root data/ \ --split test运行后每行会打印expected: \frac { 1 } { 2 }和predicted: \frac { 1 } { 2 }的对比末尾汇总平均 token 准确率。这个输出格式很适合检查模型哪一类符号最容易错比如\sqrt和\frac混淆、^和_位置错乱——这些是公式识别特有的错误模式普通 OCR 不会遇到。5. 注意力可视化验证识别模型是“真理解”还是“背公式”公式识别模型最大的隐患是过拟合训练集分布而不是真正学会“看”公式结构。判断模型是否真的在关注图像中的正确区域最直接的方法是可视化注意力权重。项目里的visualize_attention.ipynb就是这个用途。核心做法是在解码器 forward 时把每一层的 attention 权重保存下来然后和原图叠加成热力图。我在复现时写了一个简化版的抽取函数def extract_attention(model, img_tensor): model.eval() with torch.no_grad(): encoder_outputs model.encode(img_tensor.unsqueeze(0)) decoder_hidden model.init_hidden(1) all_attn [] token torch.tensor([[model.vocab[sos]]]) for step in range(model.max_seq_len): context, attn_weights model.decoder_step(token, decoder_hidden, encoder_outputs) all_attn.append(attn_weights.squeeze(0).cpu()) # (seq_len,) token torch.argmax(model.fc_out(context), dim1).unsqueeze(0) if token.item() model.vocab[eos]: break return torch.stack(all_attn)拿到all_attn后把每个 step 的权重 reshape 回 encoder 特征图的空间尺寸高度×宽度再用plt.imshow以alpha0.6叠加到原始图片上。实际效果是生成\frac时注意力应该集中在分数线的位置生成分子内容时注意力移到分子区域生成\sqrt时注意力覆盖整个根号内部。如果注意力在第一步就平均分散在整个图上说明 encoder 没有学到有效特征增大 CNN 深度或加一层BatchNorm可能改善。如果注意力永远集中在图片中心一个小块则可能是图片裁剪过狠公式主体没被完整包进图内。最后一个实用技巧用evaluate_txt.py搭配不同 beam size 跑同一张图——beam size 从 1 调到 5、10观察候选序列的多样性。如果 10 个候选几乎完全相同说明模型已经收敛到确定性映射公式结构简单时这是正常的如果候选彼此差异很大说明 attention 位置不稳定通常需要在解码器输入中加大 context vector 的占比权重。把这一步作为模型上线的质量闸门。这段代码同样可以移植到其他公式识别项目里当通用可视化工具。本文还有配套的精品资源点击获取