简介:面向计算机视觉与深度学习选题的毕业设计源码包,基于神经网络编解码模型配合注意力机制实现 LaTeX 数学公式识别,解决了从公式图像到可编译文本的端到端识别问题。压缩包共 76 个文件、44.5MB,以 35 个 Python 源文件、8 个 TXT/8 个 JSON 数据与配置、4 个 Jupyter 演示、4 个 PNG/10 个 GIF 可视化素材为主,附带 DOCX 版文档说明,目录包含模型组件、工具函数、训练预测与注意力可视化等完整模块。已有 146 人学习浏览,资源经本地编译可运行,评审分达到 95 分以上。下载后可获得可直接运行的源代码、注意力可视化脚本及配套说明,既能复现公式识别流程和注意力权重分布,也可参考其数据配置与预测逻辑,适合作为毕业设计、课程设计或深度学习方法实践的基准项目。
1. 数学公式识别到底在做什么:一张公式截图如何变成可编辑的 LaTeX
一行手写的\frac{a}{b}+\sqrt{x^2+y^2},在公式编辑器里要敲半天,丢给 Python 写的神经网络模型,几秒钟返回可编辑的 LaTeX 源码——这正是 Python实现基于神经网络模型的数学公式识别这个毕业设计在做的事。它和手写数字识别有个本质区别:数字识别是分类,公式识别是结构生成。同一个式子换字号、换排版、换手写风格,像素形态可以差很远;模型要输出的不是某一个类别标签,而是一整串有序 token。这篇文章按我自己做这类项目的落地路径拆:模型怎么选、数据怎么做、源码从哪读起、训练时哪些坑一定躲不掉。适合两类人:一是拿这个题目做毕业设计、想跑通又想讲清楚原理的在校生,二是手头有公式图片需要批量转 LaTeX、又不想依赖付费接口的工程师。
2. 模型选型先把路走对:CNN 编码、注意力解码与三套可选骨架
2.1 为什么是“编码器-解码器”:公式识别的本质是序列生成
公式识别的输入是一张二维图片,输出是一维 LaTeX 字符串。二维图片要变成一维序列,中间必须有一个把空间信息压缩成上下文特征的过程,这就天然决定了架构:卷积神经网络模型负责提取图像的视觉特征,循环网络或注意力机制负责把特征逐步“翻译”成 token 序列。这个结构在 OCR 领域就叫“编码器-解码器”,公式识别只是它的一个特例,不是新造出来的模型。
为什么不直接拿全连接网络铺平像素做分类?因为公式的类别数量不对:一个公式是否合法,取决于结构而不是某个局部图案。\frac{1}{2}和\frac{12}{3}共享大量像素块,但输出序列完全不同;全连接网络面对这种组合爆炸只能靠暴力记忆,数据量上万就崩。为什么不单独用 LSTM 吃像素序列?因为 LSTM 对二维空间关系的建模很弱,把图片按行扫成一维向量之后,分号的上下对齐关系会丢,模型分不清\sum的上限和下限到底属于谁。
所以常见的做法是:CNN 把图片压成特征图,然后用注意力机制让解码器在生成每个 token 时回头看特征图的对应区域。注意力是这个框架里最值钱的一环,它解决的是公式的长距离依赖问题。生成\frac的分子时,解码器需要知道分号上方的那块图像区域在哪;生成到分母时,注意力要能转移到下方。没有注意力,单靠 LSTM 的隐状态去记这些空间关系,短公式还行,一遇到嵌套分式和矩阵就丢三落四。
2.2 三种可用骨架的对比:CNN+RNN、Transformer、图神经网络的适用边界
选模型骨架之前,先明确你要在什么数据量下工作。把三种常见做法放在同一张表里对比,可能比读十篇论文更直观:
| 骨架 | 训练数据量 | 推理速度 | 对结构误差的容忍度 | 适合的场景 |
|---|---|---|---|---|
| CNN + RNN + Attention | 1 万级能出效果 | 快 | 高 | 毕设、小样本、印刷体识别 |
| Transformer | 10 万级以上更稳 | 中 | 中 | 大规模数据、端到端训练 |
| CNN + 图神经网络 | 视任务而定 | 慢一层 | 对公式树友好 | 版面分析、结构后处理 |
CNN+RNN+Attention 是这个领域被验证最充分的组合,PyTorch 官方教程里也有类似结构可以参考,改造成本低。Transformer 不是不能用,但公式 token 序列比自然语言短得多、结构化强得多,注意力头还没学会局部对齐,数据量不够时反而比 LSTM 更容易产生幻觉。图神经网络模型近年常出现在公式结构预测的论文里,把运算符和操作数建模成图,但做序列生成时仍要接一个解码器,复杂度上去了、收益不明显,不建议作为第一个跑通的方案。
我做这个方向时一开始也试过直接上 Transformer,结果小数据集上 loss 掉不下去;换回单层 LSTM 解码器之后,同样的数据量、同样的训练轮数,ExpRate 反而涨了十几个点。教训是:别被“最新”两个字带着走,选型先看数据量,再看调试成本。
2.3 给新手的最小模型配置:一张参数表直接对照
如果你不想研究论文里的花式变体,直接从这张表开始,足够跑通公式识别的主链路:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 图片输入 | 灰度图,高 64、宽 256 | 统一尺寸,减少计算量 |
| 编码器 | ResNet-18,去掉最后的全连接层 | 输出特征图参与注意力计算 |
| 解码器 | 单层 LSTM,隐藏单元 256 | 一层比两层更好调 |
| 词嵌入维度 | 128 | 词表通常在 200~800 之间 |
| 注意力 | Bahdanau Attention,加 key_padding_mask | 遮蔽 padding 位置 |
| 优化器 | Adam,初始学习率 1e-3 | 配合 ReduceLROnPlateau |
| 损失函数 | CrossEntropyLoss,忽略 pad 位置 | 序列生成的标准做法 |
| max_len | 150 | 训练标签长度 95 分位 * 1.5 |
| 推理时 beam size | 5 | 这个值性价比最高 |
这里图片宽度可以按公式长度动态调整,但新手先固定 256,等跑通了再改。编码器换 ResNet-34 对精度提升有限,显存却要多占一截,不划算。解码器用单层 LSTM 因为公式生成不需要很深的循环层次,两层 LSTM 在 1 万级数据上反而容易过拟合。
3. 把公式变成训练样本:matplotlib 渲染脚本与预处理管线
3.1 公式数据从哪来:CROHME 与 IM2LATEX 的取舍
训练数据是公式识别项目里最先卡人的地方。公开数据集有两条主流路子:CROHME 是手写公式识别竞赛的数据集,标签是 LaTeX 序列,量不大但真实,适合做最终验证;IM2LATEX 是印刷体渲染出来的大规模数据集,样本量在数十万级,适合做预训练。对这个毕业设计来说,我的建议是别一上来就盯着 CROHME 死磕——手写样本噪声大、量又少,模型还没学会结构就先被笔画干扰带偏了。
更务实的路径是:先用印刷体数据把结构识别跑通,再用 CROHME 做一次微调。印刷体数据可以从两个方向拿:一是下载现成数据集,二是用脚本自己渲染。自己渲染的好处是标签绝对干净、可以控制公式复杂度梯度,坏处是要确保渲染出来的图片和标签严格一致。下面这一节就写怎么做自渲染,这也是我推荐你优先做的事,因为公开数据集的下载链接经常失效,自渲染不受网络环境影响。
3.2 用 matplotlib 渲染自己的公式图片:一段可直接跑的生成脚本
matplotlib 自带 mathtext 引擎,能解析 LaTeX 的一个子集,不需要本地安装 TeX 发行版就能渲染\frac、\sqrt、\sum、上下标这些常用结构。下面这段脚本用随机模板批量生成图片和同名标签文件:
# gen_data.py import os import random import matplotlib.pyplot as plt TEMPLATES = [ r"\frac{{{a}}}{{{b}}}", r"\sqrt{{x^{{{n}}} + y^{{{m}}}}}", r"\sum_{{i={k}}}^{{{N}}} \frac{{1}}{{{i}}}", r"f(x) = {c} \cdot x^{{{e}}} + {d}", r"{a}^{{{p}}} \times {b}^{{{q}}}", ] def make_label(tmpl: str) -> str: """从模板随机填充参数,返回 LaTeX 标签内容(不带 $ 符号)""" label = tmpl.format( a=random.randint(1, 9), b=random.randint(1, 9), n=random.randint(2, 5), m=random.randint(2, 5), k=random.randint(0, 3), N=random.randint(5, 10), c=random.randint(1, 5), e=random.randint(2, 4), d=random.randint(1, 9), p=random.randint(2, 5), q=random.randint(2, 5), ) return label def render_one(label: str, out_path: str) -> None: """渲染公式为 PNG,bbox_inches='tight' 保证图片紧贴内容""" fig = plt.figure(figsize=(6.0, 1.5)) fig.text(0.5, 0.5, f"${label}$", ha="center", va="center", fontsize=20) plt.axis("off") plt.savefig(out_path, bbox_inches="tight", pad_inches=0.05, dpi=120) plt.close(fig) def main(save_dir: str, count: int = 1000) -> None: os.makedirs(save_dir, exist_ok=True) for i in range(count): label = make_label(random.choice(TEMPLATES)) img_path = os.path.join(save_dir, f"{i:05d}.png") txt_path = os.path.join(save_dir, f"{i:05d}.txt") render_one(label, img_path) with open(txt_path, "w", encoding="utf-8") as f: f.write(label) if __name__ == "__main__": main("data/train", count=1000)这段脚本的逻辑很直白:模板决定公式结构,随机整数决定公式内容;render_one用 matplotlib 的 mathtext 把带有$包裹的 LaTeX 字符串画成图片;标签文件写入的是不带$的纯 LaTeX 内容。注意bbox_inches="tight"会让图片尺寸随公式实际内容变化,所以它不能直接作为模型输入,后面预处理要统一到固定宽高。
参数说明:figsize控制画布大小,fontsize=20控制符号大小,dpi=120决定像素密度;三者一起影响最终图片的分辨率。模板里用了双重花括号{{{a}}},这是 Python str.format 的转义写法,渲染出来的实际 LaTeX 是单层花括号。mathtext 不支持\begin{matrix}这类环境,所以矩阵公式需要另想办法——要么给脚本接本地 LaTeX,要么先用初等公式跑通管线,矩阵留到后处理阶段再扩展。
3.3 图片与标签的预处理:灰度、padding 和词表构建
渲染出来的图片是 RGB 彩色图,对公式识别没有增益,转灰度能省显存、加速训练。接下来要做三件事:统一尺寸、反色、构建词表。
# preprocess.py import re import numpy as np from PIL import Image import torch TOKEN_RE = re.compile(r"\\[a-zA-Z]+|\s+|[a-zA-Z0-9+\-*/=()\[\]{}^_]") def load_formula_image(path: str, height: int = 64, width: int = 256) -> torch.Tensor: """读取公式图片并转为反色灰度张量,背景为 0、符号为 1""" img = Image.open(path).convert("L") img = img.resize((width, height), Image.BILINEAR) arr = np.asarray(img).astype(np.float32) / 255.0 arr = 1.0 - arr # 反色:符号区域接近 1,背景接近 0 return torch.from_numpy(arr).unsqueeze(0) def build_vocab(labels: list[str]) -> dict[str, int]: """从标签集合构建 token -> index 词表,特殊 token 固定在前 4 位""" tokens = set() for label in labels: for tok in TOKEN_RE.findall(label): tokens.add(tok) vocab = {"<pad>": 0, "<sos>": 1, "<eos>": 2, "<unk>": 3} for tok in sorted(tokens): if tok not in vocab: vocab[tok] = len(vocab) return vocabload_formula_image里的convert("L")把彩图转成单通道,resize把尺寸固定到 64x256;反色这步很多人会漏掉,CNN 对极性不敏感,但固定成“背景为 0、符号为 1”可以让 padding 区域的 0 和背景一致,避免模型学到“黑边是噪声”这种无关特征。
build_vocab的关键在TOKEN_RE这个正则:它把\frac这样的 LaTeX 命令整体当做一个 token,而不是拆成字母 f、r、a、c。这是公式识别和普通文本分词最大的不同点。如果你对 python 基础语法还不太熟,先把这个正则和字典的 get 逻辑看懂,再去看模型代码会顺畅很多。词表构建完成后,把每个标签转成 index 序列,开头加<sos>、结尾加<eos>,不足 max_len 的部分用<pad>补齐,这就是整个数据管线的全部。
4. 源码模块拆解:训练、预测与文档说明的阅读顺序
4.1 拿到 zip 先按这条路径读:配置、数据、模型、训练、预测
网上能下到的免费 python 源码很多,这个题目下的 zip 解压之后,先别急着跑 train.py。我一般会先找配置文件或 config.py,看图片尺寸、batch_size、词汇表路径是不是对着当前机器的环境;再读 data_loader,确认有没有把训练集和验证集分开;最后才是 model.py 和 train.py。原因是大部分毕设跑不起来的根因不在模型,而在数据集路径和参数不匹配。
源码包里的文档说明通常包含四块:环境依赖、数据集格式、模型结构、训练结果。环境依赖那块非常值得先读,它写清楚了 Python 版本、CUDA 版本、PyTorch 版本,照着装能省掉很多编译期的怪问题。不装好就硬跑,import 直接报错,后面全是白忙。其次是数据集格式说明,搞清楚它期望的图片放哪个目录、标签是 txt 还是 json。这两块确认完,再动手跑训练。
阅读顺序比阅读本身更重要。正确的顺序是:先跑通 predict(做推理),再回看 train(做训练),最后才读 model(做改进)。推理代码数据流向最短,报错范围小,能最快确认环境没问题;训练代码其次;模型代码最复杂,但对新手来说反而是信息密度最低的,因为网络结构在论文里都能找到。
4.2 训练循环里值得抄的三段关键代码
训练循环是源码里最值得逐行读的部分,它决定了模型能不能收敛。下面这段是很多公式识别项目的核心训练逻辑,我按自己的习惯做了精简:
# train_step.py def train_one_step(model, images, targets, optimizer, criterion, teacher_forcing_ratio=0.9, device="cuda"): images = images.to(device) targets = targets.to(device) # [B, T] # 解码器前向:把目标序列作为输入,用 teacher forcing 控制监督强度 logits = model(images, targets, teacher_forcing_ratio=teacher_forcing_ratio) # logits 形状 [B, T, V],需要展平成 [B*T, V] 才能算交叉熵 loss = criterion(logits.view(-1, logits.size(-1)), targets.view(-1)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) # 防梯度爆炸 optimizer.step() return loss.item()重点看两个位置。第一是logits.view(-1, logits.size(-1)):PyTorch 的 CrossEntropyLoss 期望输入形状是[N, C],输出是[N],所以要把时间维度 T 和批量维度 B 合并。第二是clip_grad_norm_(..., 5.0):公式识别解码器是 LSTM,长序列反传时梯度范数很容易冲到几十,不裁剪的话 loss 会突然跳到 NaN。teacher forcing 的意思是训练时把真实标签的前一个 token 喂给解码器,而不是用它自己上一个输出——这样收敛快,但训练后期要逐渐降下来,否则推理时一步错步步错。
训练时的学习率调度我见过太多人忽略,直接固定 1e-3 跑到底。常见做法是加一个 ReduceLROnPlateau:验证集 loss 连续 3 个 epoch 不下降就把学习率乘 0.5,配合 clip_grad_norm,基本能保证在 1 万级数据上稳定收敛。
4.3 从贪心到 Beam Search:推理代码的取舍
推理阶段最常见的写法是贪心搜索:每个时刻只取概率最大的 token,作为下一步输入。这个写法代码短,但公式识别里有个实际痛点——\frac之后如果第一步选了错误的结构,后面全串了。保持多个候选路径能显著提高整句正确率,这就是 beam search。
# beam_search.py import torch def beam_search_decode(model, image, vocab, beam_size=5, max_len=150): """简化版 beam search:每一步保留打分最高的 beam_size 个候选序列""" reverse_vocab = {v: k for k, v in vocab.items()} # 候选元素为 (token序列, 累计对数概率) candidates = [([vocab["<sos>"]], 0.0)] for _ in range(max_len): new_candidates = [] for seq, score in candidates: if seq[-1] == vocab["<eos>"]: new_candidates.append((seq, score)) continue logits = model.decode_step(image, seq) # [1, V] log_probs = torch.log_softmax(logits, dim=-1)[0] topk = log_probs.topk(beam_size) for idx in range(beam_size): new_seq = seq + [topk.indices[idx].item()] new_score = score + topk.values[idx].item() new_candidates.append((new_seq, new_score)) # 全局排序后截断 candidates = sorted(new_candidates, key=lambda x: x[1], reverse=True)[:beam_size] best_seq, _ = candidates[0] best_seq = [reverse_vocab[t] for t in best_seq] return best_seq[1:-1] # 去掉 <sos> 和 <eos>beam search 的核心就两个操作:扩招和剪枝。每个候选序列在每一步都展开成 beam_size 个新候选,然后全局按累计对数概率排序,只保留前 beam_size 个。代码里的model.decode_step是解码器单步前向,实际项目中要传入注意力计算的中间状态,我这里简化成只传序列。beam_size 越大结果越稳,但推理时间近似线性增长;公式识别里 5 是性价比最高的值,8 以上的提升很小。
5. 常见问题与避坑记录:五个让公式识别翻车的典型场景
5.1 loss 不降反升:学习率与 teacher forcing 的配合问题
现象:训练开始后 loss 在前几个 epoch 确实降了,但跑到第 15 个 epoch 左右反而从 3.2 一路涨到 8.5,验证集准确率跟着往下掉。
原因:学习率固定不变,优化器在损失曲面里来回震荡;同时 teacher forcing ratio 设成了 0,模型每一步都用自己上一步的错误输出当输入,误差滚雪球。
解决:把优化器换成 ReduceLROnPlateau,patience 设 3,factor 设 0.5;teacher forcing ratio 前 20 个 epoch 固定 0.9,之后每个 epoch 乘 0.95 逐渐衰减。这个组合在 1 万级数据上几乎不会翻车。调学习率这种事,与其说是玄学,不如说是先给一个保守基线再慢慢放开。
5.2 训练正常但预测全是乱码或重复 token:词汇表没对齐
现象:训练 loss 收敛得不错,验证集 loss 也正常,但跑 predict 时输出一堆<unk>或者连续三四个<eos>,看起来像黑匣子坏了。
原因:训练时构建词表的顺序和推理时不一致。常见的是训练脚本里用set()收集 token 后没排序,每次运行词表顺序不同;或者推理时直接用了别人打包好的 vocab.json,但模型是在另一份词表下训练的。
解决:把词表构建单独抽成一个脚本,生成 vocab.json 固定下来;训练和推理都从同一个文件读取。加载模型权重后加一行断言:assert model.decoder.embedding.weight.size(0) == len(vocab),不相等就说明词表对不上,果断放弃这次加载。
5.3 长公式总是被截断:max_len 与注意力遮蔽的问题
现象:短公式识别得很好,长度超过 80 个 token 的公式每次最后几个字符丢失,或分母的那段结构直接被砍掉。
原因:max_len 设成了 50,超过就强制截断;另一个隐蔽原因是注意力机制没有对 padding 位置做遮蔽,解码器在生成长序列时把注意力分散到了<pad>占位符上。
解决:统计训练集所有标签的 token 长度,取 95 分位再乘 1.5 作为 max_len,我通常得到的结果是 120~150。注意力层传入key_padding_mask,把 padding 位置对应的注意力分数置为负无穷,这样解码器永远看不到无效位置。做完这两步,长公式的错误率能砍掉一半以上。
5.4 Windows 下跑不通:中文路径与 Python 环境配置
现象:同样的代码在 Linux 上好好的,在 Windows 上报cv2.imread返回 None,或者FileNotFoundError,路径里一出现中文就炸。
原因:OpenCV 的 imread 对非 ASCII 路径支持极差,中文目录下直接静默失败;Python 在 Windows 默认编码又容易引发读写 txt 标签文件时的 UnicodeDecodeError。
解决:图片统一用 PIL(Pillow)的Image.open读取,它对路径宽容得多;设置环境变量PYTHONUTF8=1后再启动训练脚本。如果你刚按 python 安装教程装完环境,先别急着碰模型代码,花半小时把 pycharm 或 vscode 的解释器指到正确的虚拟环境,再把常用库装齐,后面会顺很多——这个题目的坑,一半其实在环境配置上。
5.5 6GB 显存跑不动 batch_size 32:梯度累积与混合精度
现象:训练代码跑起来直接CUDA out of memory,把 batch_size 降到 8 能跑但 loss 波动很大。
原因:ResNet-18 编码器加 LSTM 解码器的中间激活在 batch_size 32 时占满显存,小 batch 又会带来严重的梯度噪声。
解决:保持 batch_size 32,用梯度累积攒够等效步数再更新权重。混合精度训练在前向里加torch.cuda.amp.autocast(),反向用 GradScaler,显存占用能降到原来的六成左右:
# amp_accum.py scaler = torch.cuda.amp.GradScaler() accum_steps = 4 for i, (images, targets) in enumerate(train_loader): with torch.cuda.amp.autocast(): logits = model(images, targets, teacher_forcing_ratio=0.9) loss = criterion(logits.view(-1, logits.size(-1)), targets.view(-1)) / accum_steps scaler.scale(loss).backward() if (i + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()梯度累积的要点是把 loss 除以累积步数,否则等效 batch 变大后梯度尺度也变大,模型会突然失控。混合精度不是选项而是必选项,只要显卡支持半精度(20 系以上 N 卡都支持),就该默认打开。
6. 让模型真正可用:自建验证集、ExpRate 评分与后处理纠错
6.1 用 20 条自建样本评估 ExpRate
公开数据集和项目自带测试集只能证明“别人的公式”识别率,不能证明你的模型能扛住实际输入。我习惯在项目文档说明上额外附一组自建验证集:20 条从课本章节里挑的公式,覆盖分式、根号、求和、矩阵、分段函数五类结构。评分指标用两个:ExpRate(完全正确率)和编辑距离(反映近似程度),两者配合使用,否则“差一个花括号”和“整个公式乱码”在 ExpRate 上都是零分。
# evaluate.py def edit_distance(a: str, b: str) -> int: """标准 Levenshtein 距离,用于评估识别结果的近似程度""" dp = [[0] * (len(b) + 1) for _ in range(len(a) + 1)] for i in range(len(a) + 1): dp[i][0] = i for j in range(len(b) + 1): dp[0][j] = j for i in range(1, len(a) + 1): for j in range(1, len(b) + 1): cost = 0 if a[i - 1] == b[j - 1] else 1 dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost) return dp[len(a)][len(b)]跑完这 20 条样本,把 ExpRate 和平均编辑距离记录到文档说明里,这个毕业设计的说服力比贴训练曲线强得多。
6.2 后处理纠错:括号配对与常见误识别替换
神经网络输出偶尔会漏掉花括号或把\frac拼成\farc,这类错误可以通过规则修正。我做了一个极简的后处理函数,只在生成完之后调用一次:
# postprocess.py def auto_fix(latex: str) -> str: """补全未闭合的花括号,替换常见误拼写""" open_count = latex.count("{") close_count = latex.count("}") if open_count > close_count: latex += "}" * (open_count - close_count) # 常见 OCR 误识别替换 latex = latex.replace(r"\farc", r"\frac") latex = latex.replace(r"\sigm", r"\sum") latex = latex.replace(r"\sprt", r"\sqrt") return latex这种启发式方法只能兜底,不能当主要贡献写进论文,但它确实能把 ExpRate 拉高 2~3 个点。括号补全的依据是 LaTeX 里花括号必须成对。
我每次把新模型跑完,都会用同样的 20 条样本重新过一遍,把 ExpRate 和编辑距离写进文档说明的最后一页。这个方向真正做到位,你会看到公式图片变成 LaTeX 的那一刻——前面所有调参、排错、重训的折腾,在那个瞬间都是值得的。希望帮到你。
本文还有配套的精品资源,点击获取