FEATURED · 精选文章

基于ResNet与Transformer的手写数学公式识别:从原理到工程实践

发布时间 / 2026/8/31 20:09:38
来源 / 创域科博编辑部
栏目 / 资讯中心
基于ResNet与Transformer的手写数学公式识别:从原理到工程实践 简介本资源是一套基于ResNet与Transformer混合架构的手写数学公式识别Python实现面向深度学习初学者与计算机视觉方向进阶学习者解决教育、科研场景中手写公式图像到LaTeX序列的端到端识别问题。压缩包共40个文件含19个核心Python源码涵盖数据模块datamodule、编码器encoder、解码器decoder、位置编码pos_enc、训练脚本train.py及推理脚本reco-v1.1.py等、6个备份文件.zbak、3个说明类txt文档及1个config.yaml配置文件结构清晰、模块职责分明便于理解模型整体流程与各组件协同逻辑。资源包大小为4.21MB轻量易部署。已有151人学习下载代码经严格调试可直接运行附带vocab词表、字典文件dictionary.txt及测试样例支持从数据预处理、模型训练到单图识别的全流程复现并包含验证脚本validation.py与集成推理lit_ensemble.py有助于深入掌握视觉-序列建模的关键技术细节。1. 项目缘起为什么手写数学公式识别是个“硬骨头”几年前我在参与一个教育科技项目时遇到了一个非常具体且棘手的需求如何将学生写在纸上的数学解题步骤快速、准确地转换成可编辑、可分析的LaTeX代码当时市面上的一些OCR工具识别印刷体文字还行但一遇到手写的、结构复杂的数学公式比如一个带上下限的积分符号加上分式准确率就惨不忍睹。公式不是简单的文字序列它是一个二维的、具有复杂空间结构的“图”。识别它本质上是一个“图像到结构化标记语言”的翻译问题这比传统OCR难太多了。传统方法要么依赖复杂的规则和模板匹配脆弱且泛化能力差要么用简单的CNN接RNN的编解码模型对长距离依赖和复杂结构建模能力不足。直到Transformer架构在机器翻译领域大放异彩大家才意识到这种完全基于自注意力机制的模型天生适合处理这种“序列到序列”的翻译任务尤其是当输入序列图像特征和输出序列LaTeX标记之间存在复杂的、非局部的对应关系时。所以这个“基于ResNet与Transformer模型的手写数学公式识别”项目绝不是一个简单的模型堆叠练习。它代表了一种当前解决此类问题的经典且高效的范式用ResNet作为强大的“视觉特征提取器”将二维图像压缩成一个富有语义信息的特征序列再用Transformer作为“序列翻译器”将这个视觉序列精准地翻译成结构化的LaTeX序列。这个组合兼顾了图像特征的深度表征能力和序列建模的全局上下文理解能力是拿下高分项目、解决实际痛点的关键。2. 核心架构拆解ResNet-Transformer如何协同工作整个模型的流水线可以清晰地分为三个核心阶段图像预处理、视觉特征编码ResNet、序列解码生成Transformer。下面我们深入每个环节看看它们具体做了什么以及为什么这样设计。2.1 第一阶段图像预处理与标准化拿到一张手写公式图片第一步不是直接扔进模型。原始图片可能大小不一、笔迹深浅不同、存在倾斜或噪声。糟糕的输入会导致模型学习无关的噪声严重影响效果。标准化流程通常包括灰度化与二值化将彩色或灰度图转为黑白二值图突出笔迹减少计算量。可以使用自适应阈值法如Otsu‘s来应对光照不均。尺寸归一化将所有图片缩放到一个固定高度如64像素宽度按比例缩放。这是为了适配后续CNN的输入要求。注意这里只固定高度宽度可变因为公式的长宽比差异很大。填充Padding将不同宽度的图片在右侧填充到同一个最大宽度或一个预设的固定宽度形成批次Batch数据。填充部分通常用0黑色表示。归一化Normalization将像素值从[0, 255]归一化到[0, 1]或[-1, 1]区间有助于模型稳定训练。数据增强可选但强力推荐为了提升模型鲁棒性可以在训练时加入随机增强如轻微旋转±5度、弹性形变模拟手写抖动、添加椒盐噪声、模拟墨迹洇染等。这能极大地增强模型对书写风格、纸张背景变化的适应能力。注意预处理的所有参数如目标高度、归一化均值/标准差必须在训练集上确定并严格应用于验证集和测试集这是保证评估公平性的基础。2.2 第二阶段视觉编码器——ResNet的变体与特征序列化经过预处理的图像例如[batch_size, 1, H, W]被送入视觉编码器。这里为什么是ResNet因为手写公式识别需要深层的、具有强语义的特征。ResNet通过残差连接缓解了深度网络梯度消失的问题让我们能使用更深的网络如ResNet-34, ResNet-50来提取更丰富的特征。但是标准的ResNet需要一点“改造”输入通道我们的图片是单通道灰度而ResNet通常预训练于3通道RGB的ImageNet。有两种处理方式一是将单通道图像复制三份变成“伪RGB”二是修改ResNet第一层卷积的输入通道数为1并随机初始化权重。前者能利用ImageNet预训练权重通常收敛更快是更常见的选择。去除全连接层我们不需要ResNet最后的全局平均池化层和全连接层用于分类。我们只需要它最后的卷积层输出的特征图Feature Map。特征图到序列的转换这是关键一步CNN输出的特征图是一个三维张量形状为[batch_size, C, H, W]C是通道数H‘和W’是高度和宽度。我们需要将其转换为一个序列才能输入给Transformer编码器。通常的做法是将特征图在高度维度H‘上压平。具体来说将特征图在空间维度上视为H个“条带”strip每个条带宽度为W包含C个通道的信息。通过一个线性变换层将每个空间位置共H * W个的C维特征映射到Transformer模型约定的隐藏维度d_model。最终我们得到一个形状为[batch_size, L, d_model]的序列其中L H * W。这个序列的每个元素都对应原图一个局部区域的视觉特征。为什么这么做这相当于把二维图像网格拉直成了一个一维序列同时保留了空间局部信息。序列中元素的顺序通常按从左到右、从上到下隐含了原始的部分空间关系。2.3 第三阶段序列解码器——Transformer的魔力现在我们有了视觉特征序列V。我们的目标是生成LaTeX标记序列Y (y_1, y_2, ..., y_T)例如\int_{a}^{b} \frac{x}{2} , dx。Transformer解码器在这里扮演了语言模型和翻译器的双重角色。其工作流程如下目标序列嵌入与位置编码首先将目标LaTeX标记在训练时是真实标记在推理时是上一时刻预测的标记通过一个标记嵌入层Token Embedding转换为向量。然后加上位置编码Positional Encoding为序列注入顺序信息。得到[batch_size, T, d_model]的序列Y_emb。解码器自注意力Masked Self-Attention解码器第一层是掩码自注意力层。它让解码器在预测当前位置的标记时只能“看到”已经生成的左侧标记通过掩码实现而不能“偷看”未来的标记这符合自回归生成的过程。编码器-解码器注意力Cross-Attention这是连接视觉和语言的关键解码器利用上一步的输出作为Query去“询问”编码器输出的视觉特征序列V作为Key和Value。这个过程可以理解为解码器在生成每一个LaTeX标记如“\int”时都在整个图像特征序列中寻找最相关的视觉证据。例如生成积分符号时注意力权重应该集中在图像中积分符号所在的区域。前馈网络与残差连接经过注意力机制后特征会通过一个前馈网络进行非线性变换并且每一层都伴有残差连接和层归一化确保训练稳定。线性层与Softmax解码器最后一层的输出通过一个线性层映射到词汇表大小的维度再经过Softmax函数得到每个位置上所有可能标记的概率分布。我们取概率最大的标记作为当前时刻的预测输出。训练时我们使用“教师强制”Teacher Forcing即将完整的真实目标序列右移一位作为解码器输入让模型学习预测下一个标记。损失函数通常使用交叉熵损失计算预测序列与真实序列在每个位置上的差异。推理时这是一个典型的自回归生成过程从起始符sos开始每次将当前已生成的序列输入解码器预测下一个标记直到生成结束符eos或达到最大长度。3. 从零搭建关键代码实现与解释理论清晰后我们来看如何用PyTorch实现核心部分。这里会省略一些工程细节如数据加载聚焦于模型定义的关键代码块。3.1 构建视觉编码器ResNet Backboneimport torch import torch.nn as nn from torchvision import models class EncoderCNN(nn.Module): def __init__(self, encoded_image_size14, train_cnnFalse): super(EncoderCNN, self).__init__() # 使用预训练的ResNet-50 resnet models.resnet50(pretrainedTrue) # 移除最后的全连接层和平均池化层 modules list(resnet.children())[:-2] self.resnet nn.Sequential(*modules) # 我们是否要微调ResNet在数据量不大时通常先冻结训练后期再解冻部分层 for param in self.resnet.parameters(): param.requires_grad train_cnn # 自适应池化将特征图统一到固定大小 (encoded_image_size x encoded_image_size) # 这有助于将不同尺寸的图片特征图统一成相同长度的序列 self.adaptive_pool nn.AdaptiveAvgPool2d((encoded_image_size, encoded_image_size)) # 一个可选的微调在ResNet输出后加一个1x1卷积降低通道数减少参数量 # 因为ResNet-50输出通道是2048可能过高 self.reduce_channel nn.Conv2d(2048, 512, kernel_size1) def forward(self, images): images: [batch_size, 3, height, width] # 提取特征 [batch_size, 2048, H/32, W/32] features self.resnet(images) # 自适应池化到统一尺寸 [batch_size, 2048, encoded_size, encoded_size] features self.adaptive_pool(features) # 降低通道数 [batch_size, 512, encoded_size, encoded_size] features self.reduce_channel(features) # 将特征图展平为序列 batch_size, C, H, W features.size() # 将空间维度展平 - [batch_size, C, H*W] features features.view(batch_size, C, -1) # 调整维度为 Transformer 期望的输入: [batch_size, seq_len, d_model] # 这里 seq_len H*W, d_model C features features.permute(0, 2, 1) return features # [batch_size, L, d_model]关键点解析encoded_image_size这个参数决定了特征图被池化后的空间大小。L encoded_image_size * encoded_image_size就是最终视觉序列的长度。这个值不宜过小会丢失细节或过大增加计算负担14x14是一个常用起点。train_cnn是否微调ResNet。在项目初期或数据较少时建议先冻结False只训练Transformer部分。待模型初步收敛后再解冻ResNet的后几层进行微调往往能带来精度提升。reduce_channel1x1卷积是通道维度的线性变换能将2048维的高维特征压缩到与Transformer隐藏层维度如512匹配显著减少后续注意力计算的参数量和计算量。3.2 构建序列解码器TransformerPyTorch提供了nn.Transformer模块但为了更清晰地理解流程和控制细节我们基于nn.TransformerDecoderLayer来构建。import math import torch.nn as nn class PositionalEncoding(nn.Module): 标准的正余弦位置编码 def __init__(self, d_model, dropout0.1, max_len5000): super(PositionalEncoding, self).__init__() self.dropout nn.Dropout(pdropout) pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div_term torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) pe pe.unsqueeze(0) # [1, max_len, d_model] self.register_buffer(pe, pe) def forward(self, x): # x: [batch_size, seq_len, d_model] x x self.pe[:, :x.size(1), :] return self.dropout(x) class DecoderTransformer(nn.Module): def __init__(self, vocab_size, d_model512, nhead8, num_layers6, dim_feedforward2048, dropout0.1, max_seq_len150): super(DecoderTransformer, self).__init__() self.d_model d_model self.embedding nn.Embedding(vocab_size, d_model) self.pos_encoder PositionalEncoding(d_model, dropout, max_seq_len) # 使用 PyTorch 的 TransformerDecoderLayer 堆叠 decoder_layer nn.TransformerDecoderLayer(d_modeld_model, nheadnhead, dim_feedforwarddim_feedforward, dropoutdropout, batch_firstTrue) self.transformer_decoder nn.TransformerDecoder(decoder_layer, num_layersnum_layers) # 输出层 self.fc_out nn.Linear(d_model, vocab_size) # 初始化参数 self._init_weights() def _init_weights(self): for p in self.parameters(): if p.dim() 1: nn.init.xavier_uniform_(p) def forward(self, tgt, memory, tgt_maskNone, tgt_key_padding_maskNone): tgt: 目标序列 (LaTeX token ids) [batch_size, tgt_seq_len] memory: 编码器输出的视觉特征序列 [batch_size, src_seq_len, d_model] tgt_mask: 目标序列的掩码 (用于防止看到未来信息) tgt_key_padding_mask: 目标序列的填充掩码 # 嵌入和位置编码 tgt_emb self.embedding(tgt) * math.sqrt(self.d_model) tgt_emb self.pos_encoder(tgt_emb) # Transformer解码 output self.transformer_decoder(tgttgt_emb, memorymemory, tgt_masktgt_mask, tgt_key_padding_masktgt_key_padding_mask) # 映射到词汇表 logits self.fc_out(output) # [batch_size, tgt_seq_len, vocab_size] return logits def generate_square_subsequent_mask(self, sz): 生成用于自回归解码的掩码矩阵 mask (torch.triu(torch.ones(sz, sz)) 1).transpose(0, 1) mask mask.float().masked_fill(mask 0, float(-inf)).masked_fill(mask 1, float(0.0)) return mask3.3 组装完整模型与训练流程将编码器和解码器组合起来并编写训练步骤。class FormulaRecognitionModel(nn.Module): def __init__(self, vocab_size, encoder, decoder): super(FormulaRecognitionModel, self).__init__() self.encoder encoder self.decoder decoder # 起始符和结束符的索引需要预先定义在词汇表中 self.sos_idx 1 # 假设词汇表中索引1是sos self.eos_idx 2 # 假设词汇表中索引2是eos self.pad_idx 0 # 假设索引0是pad def forward(self, images, captions, caption_lengths): 训练阶段的前向传播 images: 输入图像 captions: 目标LaTeX序列 (带sos和eos) # 编码图像 memory self.encoder(images) # [batch_size, L, d_model] # 准备解码器输入 (教师强制) # 解码器输入是 captions 去掉最后一个token (eos) decoder_input captions[:, :-1] # 解码器输出应对应 captions 去掉第一个token (sos) decoder_target captions[:, 1:] # 生成目标序列的填充掩码 (忽略pad部分) tgt_key_padding_mask (decoder_input self.pad_idx) # 生成自回归掩码 tgt_seq_len decoder_input.size(1) tgt_mask self.decoder.generate_square_subsequent_mask(tgt_seq_len).to(images.device) # 解码 logits self.decoder(decoder_input, memory, tgt_mask, tgt_key_padding_mask) return logits, decoder_target def inference(self, image, max_len150): 推理阶段自回归生成序列 self.eval() with torch.no_grad(): # 编码图像 memory self.encoder(image.unsqueeze(0)) # 增加batch维度 # 初始化输出序列为起始符 ys torch.ones(1, 1).fill_(self.sos_idx).long().to(image.device) for i in range(max_len - 1): # 生成当前输入序列的掩码 tgt_mask self.decoder.generate_square_subsequent_mask(ys.size(1)).to(image.device) # 解码 logits self.decoder(ys, memory, tgt_mask) # 取最后一个时间步的预测 next_token_logits logits[:, -1, :] next_token next_token_logits.argmax(dim-1).item() # 将预测的token拼接到序列后 ys torch.cat([ys, torch.ones(1, 1).fill_(next_token).long().to(image.device)], dim1) # 如果预测到结束符则停止 if next_token self.eos_idx: break return ys.squeeze(0) # 返回生成的token序列训练循环的核心步骤def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0 for batch_idx, (images, captions, lengths) in enumerate(dataloader): images images.to(device) captions captions.to(device) # 前向传播 logits, targets model(images, captions, lengths) # 计算损失 (忽略填充部分) # logits: [batch_size, seq_len, vocab_size] - 需要reshape # targets: [batch_size, seq_len] loss criterion(logits.view(-1, logits.size(-1)), targets.reshape(-1)) # 反向传播 optimizer.zero_grad() loss.backward() # 可选梯度裁剪防止梯度爆炸对Transformer训练很重要 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)4. 项目实战数据、训练技巧与评估一个高分项目不仅要有正确的模型更要有严谨的数据处理、训练策略和评估方法。4.1 数据准备LaTeX标记化与词汇表构建这是整个项目的地基。你需要一个手写数学公式的数据集例如CROHME手写数学表达式识别竞赛数据集或HME100K。数据集应包含图片和对应的LaTeX源码。标记化Tokenization流程清洗LaTeX去除多余空格、换行符。有时需要将一些宏包命令如\displaystyle标准化或移除。拆分标记LaTeX公式不能简单地按空格拆分。你需要一个专门的标记化工具或自定义规则。例如\frac{a}{b}应该被拆分为[\frac, {, a, }, {, b, }]或更粗粒度的[\frac, a, b]取决于你的设计。常用的Python库latex2sympy或pylatexenc可以提供参考。构建词汇表统计所有训练数据中出现的标记为每个标记分配一个唯一的ID。务必包含特殊标记pad填充、sos序列开始、eos序列结束、unk未知标记。序列填充将一个批次内的所有LaTeX标记序列填充到相同的长度取该批次最大长度或一个预设最大值填充符使用pad。实操心得标记化的粒度是影响模型性能的关键因素之一。过细的粒度如拆分到每个花括号会导致序列过长增加学习难度过粗的粒度如把\frac{a}{b}当作一个整体会大大增加词汇表大小且对未见过复杂公式泛化能力差。一个折中的方案是将常见的LaTeX命令如\frac,\sum,\int作为独立标记而将变量、数字、简单符号a, b, 1, 2, , 作为原子标记。花括号{}通常需要保留因为它们定义了命令的作用域。4.2 训练策略与超参数调优Transformer模型对超参数比较敏感合理的设置能事半功倍。优化器AdamW是目前的首选它修正了Adam的权重衰减方式。学习率通常设置得较小如3e-4或5e-4。学习率调度使用带热启动的余弦退火CosineAnnealingWarmRestarts或ReduceLROnPlateau当验证集指标不再提升时降低学习率。前者能帮助模型跳出局部最优后者更稳定。批次大小Batch Size在GPU内存允许的情况下尽可能大。对于此任务32或64是常见的起点。更大的批次有时能带来更稳定的梯度估计。Dropout在Transformer的注意力机制和前馈网络中应用Dropout如0.1是防止过拟合的有效手段。标签平滑Label Smoothing在计算交叉熵损失时使用标签平滑如smoothing0.1可以缓解模型对正确标签的过度自信提升泛化能力。梯度裁剪如前代码所示对梯度范数进行裁剪如max_norm1.0是训练Transformer的标配能防止梯度爆炸。早停Early Stopping持续监控验证集上的损失或准确率当其在多个epoch内不再提升时停止训练并回滚到最佳模型。4.3 评估指标不仅仅是准确率不能只看整体的标记准确率Token Accuracy因为公式识别有很强的结构性。Exact Match Accuracy生成的整个LaTeX序列与标准答案完全一致的比例。这是最严格的指标但可能因为一个空格或括号顺序不同就判错过于严苛。Token Accuracy所有位置上预测正确的标记数占总标记数不包括填充符的比例。这是最常用的基础指标。BLEU Score从机器翻译借鉴来的指标考虑n-gram的匹配程度能更好地衡量生成序列的整体流畅度和相似度。通常看BLEU-4。Edit Distance (Levenshtein Distance)计算将预测序列转换为标准答案所需的最少编辑操作插入、删除、替换次数。距离越小越好。可以将其归一化后作为相似度分数。结构相似性指标将LaTeX解析成语法树使用如sympy或自定义解析器然后比较树的结构是否一致。这更能反映公式的语义是否正确但实现较复杂。在项目中我建议同时报告 Token Accuracy 和 BLEU-4 分数并从验证集中挑选一些典型样例简单、中等、复杂公式进行可视化展示预测结果和错误案例这样评估才全面。4.4 常见问题与调试技巧问题模型不收敛损失为NaN。检查学习率是否过高尝试降低到1e-5。梯度裁剪是否生效检查输入数据是否有异常值如未归一化。尝试使用更小的模型减少层数、头数和更小的数据子集先确保能过拟合。问题模型过拟合训练集验证集指标很差。检查增加Dropout率。使用更强大的数据增强。如果微调了ResNet尝试减少微调的层数或使用更小的学习率。尝试权重衰减AdamW中已包含或增加其系数。问题推理时生成重复或无意义的标记如一堆花括号。检查这可能是“曝光偏差”Exposure Bias或训练-推理不一致导致的。可以尝试以下技巧计划采样Scheduled Sampling在训练时以一定概率使用模型自己上一时刻的预测而非真实标记作为当前输入让模型适应推理时的环境。束搜索Beam Search在推理时不要只贪心地选择概率最大的下一个标记而是维护一个大小为k如5的候选序列集合最终选择整体概率最高的序列。这能有效减少局部最优导致的错误。长度惩罚在束搜索中对短序列进行惩罚鼓励生成更长的、更完整的序列。问题模型对某些符号如手写体希腊字母识别很差。检查词汇表中是否包含了这些符号训练数据中这些符号的样本是否足够可以考虑收集更多此类样本或使用数据增强专门模拟这些符号的多种写法。5. 进阶优化与扩展思路做到基础版本能跑通并取得不错成绩后可以考虑以下方向进行优化和扩展这往往是高分项目的加分项。5.1 视觉编码器的增强更强的Backbone将ResNet-50替换为ResNet-101、ResNeXt、EfficientNet或Vision TransformerViT。ViT直接将图像切分为Patch序列可能更契合后续的Transformer解码器但需要更多的数据预训练。引入注意力机制在ResNet的特征图后加入一个轻量的空间注意力模块如CBAM、SE Block让模型在特征提取阶段就更关注笔迹区域抑制背景噪声。多尺度特征融合不仅使用ResNet最后一层的特征还将中间层的特征图通过FPN特征金字塔网络等方式融合起来兼顾低层的高分辨率信息和高层的语义信息对小符号识别更有帮助。5.2 解码器的改进拷贝机制Copy Mechanism对于公式识别很多符号如变量a, b, c直接从图像中“拷贝”过来比从固定词汇表中生成更合理。拷贝机制允许解码器在生成某个标记时选择从输入图像特征序列中“拷贝”一个元素这对于识别罕见或手写风格独特的字符非常有效。覆盖机制Coverage Mechanism在生成过程中记录哪些源序列图像区域已经被注意力过并在后续生成中惩罚重复关注相同区域这有助于模型更均匀地“扫描”整个公式避免遗漏部分结构。使用预训练语言模型初始化如果你的LaTeX词汇表很大可以考虑使用在大量文本上预训练过的Transformer模型如BERT、GPT-2的权重来初始化你的解码器嵌入层甚至部分层这能为模型提供先验的语言知识。5.3 后处理与纠错语法约束解码在束搜索过程中引入LaTeX的语法规则作为约束。例如一个\frac命令后面必须紧跟两个用花括号包裹的参数。这可以过滤掉大量语法错误的候选序列。独立纠错模型训练一个小的序列到序列模型专门用于对初步识别结果进行语法纠错和格式化。这个模型可以学习常见的错误模式如括号不匹配、命令拼写错误并进行修正。5.4 工程化与部署考虑模型轻量化为了部署到移动端或Web端可以考虑使用知识蒸馏训练一个更小的学生模型或使用模型剪枝、量化技术来减少模型大小和提升推理速度。构建Pipeline服务将预处理、模型推理、后处理打包成一个完整的服务。使用ONNX或TorchScript将模型导出利用TensorRT或OpenVINO进行加速推理。提供简洁的API接口方便集成到其他应用如在线教育平台、作业批改系统中。这个项目从理论到实践涵盖了深度学习应用的完整链条。核心在于理解ResNet-Transformer这个编解码框架如何将视觉问题转化为序列翻译问题并熟练处理数据、调试模型、科学评估。当你成功运行起第一个能识别简单公式的模型并一步步解决遇到的各种坑时你对CV和NLP结合的理解会深刻得多。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻