ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于TensorFlow的手写数学公式识别与自动阅卷技术全解析

基于TensorFlow的手写数学公式识别与自动阅卷技术全解析 简介面向教育技术研发人员、教师及智能阅卷爱好者这份 PDF 文档系统讲解基于 TensorFlow 的手写数学公式识别与自动评分实现方案重点解决传统阅卷效率低、评分主观性强的痛点可应用于学校日常考试、大规模考试和在线教育平台。资源包中仅含 1 个 PDF 文件大小约 1.94MB内容完整且便于直接阅读归档。文档从 TensorFlow 张量、计算图与会话等基础讲起依次覆盖手写公式数据集的收集、预处理、标注与划分CNN/RNN/编码器-解码器等模型构建以及基于规则与机器学习融合的综合评分算法同时给出系统实现代码示例和案例分析。已有 141 人学习下载。读者可掌握图像预处理、公式识别到自动评分的完整项目路径也能从评估指标、调优方法和未来改进方向中获得实用参考适合作为相关课程设计或毕业设计的入门资料。1. 手写数学公式为何让自动阅卷难以落地刷题软件已经把印刷体 OCR 做得很顺但阅卷系统遇到数学大题绝大多数还是退回人工判。卡点不在字符识别而在公式识别必须同时恢复二维结构分数线上下是什么、根号盖住哪些项、下标里是变量还是脚注。这类信息在普通 OCR 里可以丢掉在数学阅卷里一丢就没法谈自动评分。这套方案的做法是把 TensorFlow 当作从手写图像到 LaTeX 标记序列的识别引擎再在识别结果上做语义级评分而不是对字符串。实际业务里还要面对粘连笔迹、涂改、题号混排。一个稳定可交付的模型通常不是调大模型那么简单而是把图像预处理、序列解码和后处理纠错串成一条完整链路。下面按这条链路写下去先立住建模思路再给出可复现的最小实现和评分策略。2. TensorFlow 识别链路的建模二维结构与序列化输出2.1 公式识别与印刷OCR的差异输出必须保留空间结构普通 OCR 文本行是线性字符流可以按字符边界切分把图像→字符串当作串行任务处理。数学公式不是线性排列同级符号可以出现在主基线上下分数杆把式子分成两层根号覆盖宽度需要靠感知范围决定。因此绝大多数方案把公式识别建模为图像到序列生成一张手写公式图给定一个起始 token解码器逐 token 生成 LaTeX 标记如\frac、^、_等。LaTeX 序列本身是线性的但它编码了二维布局信息让下游评分可以还原结构。这个思路最早在印刷公式识别上被验证后来迁移到手写场景。实际训练中模型不直接预测像素级的符号位置而是学会在特征图上做注意力对齐所以解码器是否有稳定的注意力覆盖、是否会在长公式上重复生成直接影响识别质量。这里和 OCR 的一个明显差别是OCR 可以依赖语言模型做纠错公式没有很好的语言模型可依赖只能靠语法约束和格子结构纠正。2.2 框架选型TensorFlow与PyTorch在选择上的边界先说结论做研究、调新模型很多人现在更愿意用 PyTorch但做教育阅卷这类私有化部署TensorFlow 仍有实际优势。2024 年前后的明显趋势是研究社区大量转向 Transformer 和 PyTorch 生态TensorFlow 在学术界热度相对回落但在工程侧保留了完整的交付链训练好的模型导出 SavedModel直接起 TF Serving 容器或者转成 TensorFlow Lite 放进低算力盒子版本兼容性相对集中。对人数不多的小团队选 TensorFlow 可以把模型、服务、前端的对接成本压到一条线上。Transformer 本身与框架无关TF 的 Keras 层也可以构建编码器解码器结构。差别在于数据处理管道、分布式训练策略和服务化组件的成熟度。TensorFlow 的 TFRecord 和tf.data在训练吞吐上设计得比较省心它和tf.train.Checkpoint、SavedModel的配合在离线批处理场景足够顺。如果团队已有 PyTorch 代码也不值得重写用 HF 的权重互转通道即可但边界要提前划清。阅卷系统的核心瓶颈往往在数据清洗和评分规则而不是模型结构所以先选部署链路最顺的那套框架更稳。2.3 识别模型结构CNN特征提取加注意力解码器的数据流推荐基线结构是三分段CNN 主干、位置编码、解码器。CNN 主干常用 DenseNet 或 ResNet 变体把输入图从[H, W, 1]压成[H, W, C]的特征图。接着把特征图在宽度方向摊成带时序的序列交给双向 GRU 或 Transformer 编码器做上下文聚合。解码器按自回归方式输出 LaTeX token 序列每一步通过注意力机制去特征图上拿该看的区域。手写公式和印刷公式的差别集中在解码器的注意力行为上。手写图像里符号位置漂移大模型很容易在等号、括号这类高频符号上循环重复所以常见做法是在注意力内加入覆盖惩罚已经看过的区域给负向偏置引导解码器继续往没看过的位置移动。训练阶段用 teacher forcing 输入真实 token推理阶段用 beam searchbeam 宽度取 5 到 10。对数学公式宽 beam 不一定更准因为错误 token 积累会导致结构性崩溃宽度 5 通常够用。2.4 训练数据集构成与CROHME基准的局限离线手写公式公开数据集不多CROHME 是最常被引用的评测基准它带有标准 LaTeX 标注。但 CROHME 的题目定义、书写者风格和国内学生的手写习惯有明显差距直接拿它做训练数据部署到生产环境会明显掉点。常见做法是用合成数据打底取大量公式 LaTeX 源码渲染成图像再叠加手写风格扰动、背景噪声、笔画缺失得到一个规模可控的训练集。真实手写数据只需要一小部分做 fine-tune合成与真实比例通常控制在 4:1 到 8:1 之间。比例过高会让模型在验证集上看着很好一到真实阅卷就崩。在评估上CROHME 官方指标常用 ExpRate整条 LaTeX 完全正确的比例但生产环境更关注语义正确率识别结果和标准答案在数学上等价就算对。这要求评测代码做解析而不是纯字符串比对。这一点和自动评分模块直接相关第四章会展开。3. 最小可复现的 TensorFlow 公式识别模型3.1 TensorFlow 环境下的依赖组合与安装检查先给一个可复现的环境创建命令把 TensorFlow 安装和 GPU 检查一次做掉conda create -n formula python3.10 -y conda activate formula pip install tensorflow[and-cuda] # GPU机器用这个纯CPU可去掉后缀 python -c import tensorflow as tf; print(tf.__version__)tensorflow[and-cuda]是 TensorFlow 2.x 后期提供的一组依赖扩展会自动匹配一套经过验证的 CUDA、cuDNN 组合不需要手工安装 CUDA 工具链。如果只做离线推理或小规模训练CPU 版也能跑通全流程只是训练慢。第一次跑上面的命令容易在导入时遇到 libcuda 相关报错多数是因为宿主机 NVIDIA 驱动版本偏旧或者 conda 环境里混入了多套 CUDA 库。检查时先看nvidia-smi的驱动版本再在 Python 里确认tf.config.list_physical_devices(GPU)能看到设备。从能导入到确定在用 GPU两步确认可以避免后面训练了却一直吃 CPU。3.2 定义模型卷积特征提取加带注意力的GRU解码器下面给一个不依赖复杂 attention 实现的骨架模型。目标是跑通数据管道和训练循环真正要打榜时需要把 Decoder 替换为带注意力覆盖的版本。import tensorflow as tf def build_formula_model(vocab_size): # 输入统一为 64 x 256 的灰度图 image tf.keras.Input(shape(64, 256, 1), nameimage) x tf.keras.layers.Conv2D(32, 3, paddingsame, activationrelu)(image) x tf.keras.layers.MaxPool2D((2, 2))(x) x tf.keras.layers.Conv2D(64, 3, paddingsame, activationrelu)(x) x tf.keras.layers.MaxPool2D((2, 2))(x) x tf.keras.layers.Conv2D(128, 3, paddingsame, activationrelu)(x) x tf.keras.layers.MaxPool2D((2, 2))(x) _, h, w, c x.shape # 摊平成 (batch, width, height * channel) 的序列 seq tf.keras.layers.Reshape((int(w), int(h * c)))(x) seq tf.keras.layers.Bidirectional( tf.keras.layers.GRU(256, return_sequencesTrue) )(seq) context tf.keras.layers.LayerNormalization()(seq) # decoder 使用 teacher forcing decoder_input tf.keras.Input(shape(None,), dtypeint64, namedecoder_input) embed tf.keras.layers.Embedding(vocab_size, 256, mask_zeroTrue)(decoder_input) dec_rnn tf.keras.layers.GRU(512, return_sequencesTrue) dec_out dec_rnn(embed, initial_statecontext[:, -1, :]) logits tf.keras.layers.Dense(vocab_size)(dec_out) model tf.keras.Model(inputs[image, decoder_input], outputslogits) return model这段代码的意图很直接。CNN 主干用三层卷积把图像压成宽度为原来 1/8 的特征序列每个时间步对应原图一个竖向条带因此解码器在生成一个 token 时只能看到该条带内的信息。真实生产模型不会把initial_state简单取为context[:, -1, :]而是加一个 Attention 层去对整张特征图加权求和否则当符号在条带间偏移时信息会被截断。GRU 的 units 取 256 到 512 之间过大会让训练显存占用翻倍收益却不明显。embedding 维度 256 在词汇表小于 1200 的公式场景够用不需要设到 512。mask_zeroTrue要求 token id 0 必须是 padding 位序列输出和标签都要遵守这个约定。3.3 用TFRecord把合成公式数据喂进训练管道训练数据做成 TFRecord 之后读取效率远高于从文件路径逐个读图。先看写入端的简化代码def write_tfrecord(path, images_np, labels_np): with tf.io.TFRecordWriter(path) as writer: for img, lab in zip(images_np, labels_np): feature { image: tf.train.Feature( bytes_listtf.train.BytesList(value[img.tobytes()])), label: tf.train.Feature( int64_listtf.train.Int64List(valuelab)), length: tf.train.Feature( int64_listtf.train.Int64List(value[len(lab)])), } example tf.train.Example( featurestf.train.Features(featurefeature)) writer.write(example.SerializeToString())img.tobytes()拿到的是内存连续的原始像素如果直接保存 PNG 再解码会平白增加磁盘 IO。label 统一 padding 到固定长度多出的位置补 0并保存真实长度后面才知道哪里对齐是对的。读取端用tf.data.TFRecordDataset跑def parse_fn(record): schema { image: tf.io.FixedLenFeature([], tf.string), label: tf.io.FixedLenFeature([96], tf.int64), length: tf.io.FixedLenFeature([], tf.int64), } parsed tf.io.parse_single_example(record, schema) image tf.io.decode_raw(parsed[image], tf.uint8) image tf.reshape(image, [64, 256, 1]) image tf.image.convert_image_dtype(image, tf.float32) image (image - 0.5) * 2.0 return {image: image, decoder_input: parsed[label][:-1]}, parsed[label][1:]字段图很清楚decoder input 和 label 错开一位让模型用前一个 token 预测下一个 token。读取管道里通常还会加repeat()、shuffle(10000)和map(num_parallel_callstf.data.AUTOTUNE)。注意shuffle要在repeat之前做否则跨 epoch 会产生重复边界。decode_raw出来的通道数必须是 1如果原始数据是 RGB要先转灰度再写 TFRecord。训练参数可以参考这张表参数建议值说明image height64固定高度宽可以到 256超过部分截断或等比缩放batch size16~32这种 CNNGRU 用 32 足够太大影响注意力收敛学习率峰值1e-3配合 warmup过大会让注意力训练不稳定warmup steps1000让解码器先在语言模式上稳定optimizerAdamW权重衰减 0.01gradient clip5.0防止自回归阶段梯度爆炸固定图像高度为 64 能把公式排版里常见的上下标变化保留住。宽度设为 256 对多数手写式子够用碰到特别长的多项式要改成动态宽度分桶 bucketing否则宽度 padding 太多反而影响注意力。3.4 预测阶段Beam Search把向量序列解码为LaTeX训练结束后的推理不能像训练一样 teacher forcing只能自回归地取模型输出并作为下一步输入def beam_decode(model, image, token_table, beam_width5, max_len96): start token_table(s) sequences [[start]] scores [0.0] for _ in range(max_len - 1): candidates [] for seq, score in zip(sequences, scores): dec_input tf.constant([seq], dtypetf.int64) logits model({image: image, decoder_input: dec_input})[0, len(seq) - 1] probs tf.nn.log_softmax(logits) topk tf.math.top_k(probs, kbeam_width) for prob, idx in zip(topk.values.numpy(), topk.indices.numpy()): candidates.append((seq [idx], score float(prob))) candidates.sort(keylambda x: x[1], reverseTrue) sequences [c[0] for c in candidates[:beam_width]] scores [c[1] for c in candidates[:beam_width]] if all(seq[-1] token_table(/s) for seq in sequences): break best max(zip(sequences, scores), keylambda x: x[1])[0] return token_table.decode(best)beam search 最关键的是每步只保留分最高的前beam_width条路径。公式解码和机器翻译类似但公式语法严密连续两步出现\frac就必须在后面补两个参数闭包如果模型还没有对结构语法敏感高概率候选会卡在分数线的参数缺省上。这种错误在纯字符串 ExpRate 上会拉低成绩但到了语义评分阶段用语法解析器去修会自动暴露。生产上通常先跑 beam width5再对得分接近的前 3 条候选做 LaTeX 语法校验选第一个能完整编译的作为最终结果。4. 自动评分模块把LaTeX转成语义一致的打分4.1 评分的第一步把LaTeX解析为符号表达式AST公式识别出来是一串 LaTeX 字符串但如果评分直接比较字符串会带来两个问题\frac{1}{2}和0.5被当成不同答案(x1)^2和x^22x1也判错。正确做法是把 LaTeX 解析成符号表达式 AST用代数运算判断等价。在 Python 生态里常用latex2sympy2配合 SymPy 完成这步from latex2sympy2 import latex2sympy import sympy as sp x sp.symbols(x) expr_student latex2sympy(rx^{2} 2 x 1) expr_std latex2sympy(r(x 1)^{2}) diff sp.simplify(expr_student - expr_std) print(diff 0) # True这段代码先把学生识别结果和标准答案都转成 SymPy 表达式再对两者做差并化简。如果化简结果恒为 0就认为代数等价。用sp.simplify做全量恒等判定在多项式、有理式、基础三角函数范围内有效但遇到积分、极限、矩阵这类抽象对象时简化过程可能很慢或直接失败。评分前要先做语法清理删除多余空格、把模型可能多出的\left\right配对、对非法命令做白名单过滤。4.2 不同的题型要用不同的匹配策略阅卷不是只有对/错一种输出小学计算题、初中代数、高中证明题需要的评分粒度完全不同。我一般把评分逻辑拆成三类分别对待题型评分依据处理方式结果为数值数值近似解析后abs(evalf(student) - evalf(std)) 1e-6结果为代数式代数等价求差后simplify或随机采样点比较步骤给分关键式子命中将学生步骤切片与标准中间式做等价比较按命中率给比例分数值题要避免浮点比较直接用必须设容差。代数等价判定里随机采样是一种稳健的兜底对表达式里的每个符号随机取 10 组值把两个表达式都转成浮点数比较差异持续小于阈值就认为等价。这项技术对包含sqrt、sin的式子比simplify更快但存在漏判风险所以生产环境中simplify为真就算真等价只有simplify超时时才落到采样比较。步骤给分是较难放开的部分如果模型识别错误出现在第一步后续式子都可能偏离标准路径这时按式子命中率给分会误伤。我会加上位置信息只把 step i 附近的标准式纳入候选。4.3 识别置信度作为自动评分的前置闸门公式识别模型给出的概率可以为评分可靠性做一道很有价值的闸门。对整条 LaTeX 序列可以计算所有 token 对数的平均作为句子级置信度。在服务端预测时从 softmax 输出中拿到每个 token 的概率并累乘def sequence_logprob(model, image, tokens, token_table): dec_input tf.constant([tokens[:-1]], dtypetf.int64) logits model({image: image, decoder_input: dec_input})[0] logp 0.0 for i, tid in enumerate(tokens[1:]): token_probs tf.nn.log_softmax(logits[i]) logp float(token_probs[tid]) return logp / (len(tokens) - 1)如果这个平均 log 概率低于一个阈值比如 -0.8说明模型自身对这次识别不自信评分结果不应直接返回给学生。落地方案通常是进入人工复核队列只有高置信度样本走自动评分。阈值需要在测试集上画 PR 曲线来定而不是拍脑袋。一个常见经验是把置信度分位数在 30% 以下的样本全部转人工自动覆盖率仍能保持 70% 左右评分的可解释性要比硬调模型高得多。5. 上线的关键一步手写区域分割与低置信度回退5.1 块级与行内公式区分避免把题干当成答案数学试卷版面复杂题目里公式、答题区域的公式、学生推演过程的公式混在一起。识别模型只负责把输入图转成 LaTeX它不会区分这部分是题干还是作答区。实践中要先做一个版面分析把试卷扫描图里的手写区域切成块级公式独立一行如多项式化简和行内公式嵌在文字里如由 x 1 可得。我的做法是先训练一个简化的版面检测器或者用传统的投影分割定位手写连通域再把每个候选区域送入公式识别模型。如果候选区域宽度超过整行的 60%视为块级公式否则视为行内公式。块级公式在送入模型前还要按高度把分数线和根号覆盖范围裁剪出来避免把上下两行公式拼成一张图。这个步骤出错会在识别阶段直接爆炸而且很难靠后处理救回来。5.2 低置信度样本回退与人工复核的阈值监控自动阅卷系统交付后不能只看整体准确率要看自动通过比例和自动通过样本准确率两个指标联动。假设模型在 1000 份试卷上识别出 800 份高置信度结果其中 780 份评分正确那这 800 份可以放心自动返回剩下 200 份进人工其中如果 100 份人工复核发现是题干内容说明版面分割阈值定得太宽要回头调块级公式的切割条件而不是增加模型容量。一个可落地的小技巧是记录每条自动评分样本的置信度分位和评分耗时到本地日志表每隔一个迭代周期做一次切分校验。如果发现低置信度样本大量集中在某一题号基本可以定位到该题的版面特征比如有大量上标或涂改。把这类规则写进前置过滤比重新训练模型更快见效。还有一点值得盯识别模型在真实手写上的错误往往是对称性的比如分数线上下两项整体交换会被评分为错。这类错误在 ExpRate 上被扣分但放到语义评分里\frac{a}{b}和\frac{b}{a}的差是(a^2-b^2)/ab在特定符号赋值下非零。遇到这种高区分度的样例可以单独抽出来做回归测试集每次改模型或调阈值时优先跑这一批。本文还有配套的精品资源点击获取
返回列表