十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TensorFlow手写公式识别与智能阅卷系统实践指南

TensorFlow手写公式识别与智能阅卷系统实践指南 简介面向教育技术开发人员、教师及智能阅卷领域研究者这份PDF系统讲解基于TensorFlow的手写数学公式识别与自动评分实现方案解决传统人工阅卷效率低、主观误差大、复杂公式批改难等痛点可应用于学校日常考试、大规模考试及在线教育平台。内容依次覆盖系统整体架构、TensorFlow基础概念、手写公式数据集收集与预处理、CNN/RNN/编码器-解码器模型构建、综合评分策略和完整代码示例并给出模型评估、调优与案例验证过程既讲清理论也便于工程参考。资源包共1个文件为PDF格式压缩包大小1.94MB正文近29页目录按系统设计、数据准备、模型构建、评分算法、实现与优化等模块展开层级清晰适合按章节检索和逐步实践。已有141人学习下载可作为智能阅卷教学、科研和项目开发的实用参考资料。1. 智能阅卷系统的难点在于手写公式识别而不只是评分一份数学卷子里最消耗阅卷精力的从来不是选择和填空而是那些占了半张纸的解答题。学生手写的公式千奇百怪根号长短不齐分数线歪斜指数位置飘忽不定更别说连笔和涂改。传统 OCR 对印刷体可以做得很好一遇到手写公式就失灵——不是字符识别不出来而是公式的二维空间结构被压扁成了字符串。评分倒是相对明确识别出内容后把答案的语义等价关系理清楚就行。真正决定整个系统上限的是「手写数学公式识别」这一步能不能在真实扫描件上跑稳。这篇文章沿着一条可落地的技术路线展开数据如何准备TensorFlow 模型怎么设计识别结果如何转成可比较的格式评分逻辑怎么处理同义不同形的答案最后如何部署成能抗住批量阅卷的推理服务。面向的读者是手里有数据集、需要在 TensorFlow 里搭完整方案的工程师也包括想评估技术选型的架构师。2. TensorFlow 手写公式识别模型的选型与数据预处理2.1 为什么公式识别不能直接套用整行 OCR整行 OCR 把图像压成一维序列适合英文、数字、代码这种从左往右读的内容。数学公式的阅读顺序是二维的分式的分子分母沿垂直方向排列根号包住内部内容求和符号的上下限呈上下关系。如果强行按行扫描公式结构会丢得一干二净。当前行业里常用的路线有三条选型时需要结合实际标注成本和硬件条件做对比我一般会先把三者列成一张表再定方案方案结构建模能力标注成本TensorFlow 生态成熟度适用场景目标检测 CNN/RNN 识别中中等成熟卷面有大量干扰元素需要先定位公式区域端到端图像转序列Image-to-Markup高高中公式独立成块背景干净无需区域检测预训练视觉模型微调高低成熟数据量少需要借助大规模预训练权重复用端到端 Image-to-Markup 是近年论文里质量比较高的方向但它依赖的标注是「图像-对应的 LaTeX 序列」标注成本很高。对于真实考场扫描件我的习惯是先把公式从卷面里裁出来再走识别。这样上游可以复用成熟的目标检测模型下游识别专注做「图到 LaTeX」两个环节各自迭代互不拖累。2.2 构造训练集公开数据打底自采数据补连笔公开数据里最容易被翻到的是 CROHME 系列涵盖手写公式的 LaTeX 标注与笔迹数据以及一些离线公式图像集合。启动阶段可以先用公开数据把识别模型跑通。但真实考试场景里学生用 2B 铅笔填涂、碳素笔书写、部分区域扫描件反光这些噪声公开数据覆盖不到。我会额外采一批模拟答卷找 5 到 8 个人按真实考试速度写公式每人 50 页手机俯拍加扫描仪各一份。拍摄角度和光照变化能增强泛化性。自采数据的标注可以直接标 LaTeX 字符串。因为公式结构复杂建议先按 LaTeX 的语法树去核对标注人眼一遍根本不够——两周后回看往往会发现漏了括号。标注完的数据建议转成 TFRecord 格式避免训练时读几百张小图片导致 I/O 成为瓶颈。TFRecord 的核心价值是把图像字节和标签文本打包成顺序读取的二进制文件配合 TensorFlow 的并行读取机制可以显著提升 GPU 利用率。一段将图像和 LaTeX 文本写入 TFRecord 的代码示例如下import tensorflow as tf def encode_sample(image_path, latex_text, output_writer): image_data tf.io.read_file(image_path) # 统一转成灰度图后续直接喂给形状为 [height, width, 1] 的张量 image tf.image.decode_image(image_data, channels1, expand_animationsFalse) image tf.image.encode_jpeg(image, quality95) feature { image_raw: tf.train.Feature(bytes_listtf.train.BytesList(value[image.numpy()])), latex: tf.train.Feature(bytes_listtf.train.BytesList(value[latex_text.encode(utf-8)])), height: tf.train.Feature(int64_listtf.train.Int64List(value[image.shape[0]])), width: tf.train.Feature(int64_listtf.train.Int64List(value[image.shape[1]])), } example tf.train.Example(featurestf.train.Features(featurefeature)) output_writer.write(example.SerializeToString())这套代码里有两个值得说明的点。expand_animationsFalse是为了阻止解码 GIF 动图类数据防止异常文件把整个 pipeline 搞挂quality95是在存储体积和图像清晰度之间取平衡公式识别很依赖笔画边缘压缩太狠会丢失细节。写入时把 height 和 width 一并存进去读取时可以省一次tf.shape的动态推断加快预处理。2.3 读 TFRecord 时同步完成增强与过滤训练时从 TFRecord 读出来的图像不能直接进网络。手写公式图像普遍存在三类问题倾斜、笔画偏淡、边缘有无关墨迹。我通常会在数据管道里做三件固定操作同时把样本数量翻倍随机旋转 -3° 到 3°模拟扫描时纸张摆放角度偏差随机亮度抖动应对扫描仪批次差异以 0.5 概率做水平翻转前提是公式本身镜像对称后语义可接受如果做翻转增强需要谨慎处理「6」和「9」以及「/」这种会改变语义的符号。稳妥做法是只对包含纯数字、四则运算的公式启用翻转其余样本跳过。过滤阶段我会按图像宽高比排出异常样本手写公式正常情况是宽大于高竖长条多半是标点或字符碎片宽度小于 32 像素的一律丢弃因为放大后笔画糊成一片模型学不出结构。这是读取阶段的管线代码同时包含了解析、增强、过滤三个环节def parse_tfrecord(example_proto): feature_description { image_raw: tf.io.FixedLenFeature([], tf.string), latex: tf.io.FixedLenFeature([], tf.string), height: tf.io.FixedLenFeature([], tf.int64), width: tf.io.FixedLenFeature([], tf.int64), } parsed tf.io.parse_single_example(example_proto, feature_description) image tf.image.decode_jpeg(parsed[image_raw], channels1) # 统一缩放到固定高度宽度按比例缩放避免长公式被压扁 target_height 64 original_h tf.cast(parsed[height], tf.float32) original_w tf.cast(parsed[width], tf.float32) scale target_height / original_h new_w tf.cast(original_w * scale, tf.int32) image tf.image.resize(image, [target_height, new_w], methodbilinear) latex tf.strings.strip(parsed[latex]) return image, latex dataset tf.data.TFRecordDataset(tfrecord_files, num_parallel_reads8) dataset dataset.map(parse_tfrecord, num_parallel_callstf.data.AUTOTUNE) def augment(image, latex): image tf.image.random_brightness(image, max_delta0.2) # 排除含翻转后语义反转的样本 is_safe_for_flip tf.strings.regex_full_match(latex, ^[0-9\\-*/()x]$) flipped tf.image.random_flip_left_right(image) image tf.cond(is_safe_for_flip, lambda: flipped, lambda: image) return image, latex dataset dataset.map(augment, num_parallel_callstf.data.AUTOTUNE).batch(32).prefetch(2)重点参数有两个。num_parallel_reads8是指同时打开 8 个 TFRecord 文件分片读取上限不是越大越好受磁盘 IOPS 约束机械硬盘设 8 就够SSD 可以到 16。prefetch(2)让数据装载和模型训练两个环节重叠GPU 在等数据时不会完全空转。tf.image.random_brightness的max_delta0.2是经验值超过 0.3 会把淡笔迹直接拉没。3. 基于 TensorFlow 的手写公式识别模型训练与解码3.1 网络骨架CNN 提特征双向 LSTM 建模序列公式识别模型的输入是一张图像输出是对应的 LaTeX 标记序列。参考近年研究和工程实践通用的做法是先让 CNN 把图像按列压缩成特征序列再用双向 LSTM 建模上下文依赖。用 TensorFlow 的 Keras 接口可以直接搭出这套结构代码层面的可读性和维护性都好。CNN 部分的选择会直接影响效果我通常用轻量级卷积栈来平衡速度和精度关键设计是宽卷积核加下采样def build_cnn_encoder(): inputs tf.keras.Input(shape(64, None, 1), dtypetf.float32) x tf.keras.layers.Conv2D(32, (3, 3), strides(1, 1), paddingsame)(inputs) x tf.keras.layers.BatchNormalization()(x) x tf.keras.layers.ReLU()(x) x tf.keras.layers.MaxPooling2D(pool_size(2, 2), paddingsame)(x) # 高变为 32 x tf.keras.layers.Conv2D(64, (3, 3), strides(1, 1), paddingsame)(x) x tf.keras.layers.BatchNormalization()(x) x tf.keras.layers.ReLU()(x) x tf.keras.layers.MaxPooling2D(pool_size(2, 2), paddingsame)(x) # 高变为 16 x tf.keras.layers.Conv2D(128, (3, 3), strides(1, 1), paddingsame)(x) x tf.keras.layers.BatchNormalization()(x) x tf.keras.layers.ReLU()(x) # 不压缩高度了转为 1 行特征图序列保留宽度上的时序 x tf.keras.layers.MaxPooling2D(pool_size(2, 1), paddingsame)(x) # 高变为 8 x tf.keras.layers.Conv2D(256, (3, 3), strides(1, 1), paddingsame)(x) x tf.keras.layers.BatchNormalization()(x) x tf.keras.layers.ReLU()(x) x tf.keras.layers.Reshape((tf.shape(x)[2], 256))(x) # 按宽度方向展开 return tf.keras.Model(inputs, x)代码里有一个值得反复解释的设计第二次 MaxPooling 用(2, 1)意思是高度方向压缩 2 倍、宽度方向不变。公式是横向展开的宽度方向的特征代表时序位置压缩宽度会让长公式丢失细节。第三次池化之后高度变成 8再用Reshape把[batch, 8, width, 256]压成[batch, width, 2048]宽度方向留作 LSTM 的序列长度。BatchNormalization在卷积后是必需品手写公式图像噪声大不归一化的话 ReLU 输出分布漂移训练很难收敛。3.2 CTC Loss 解决对齐问题公式识别最麻烦的地方是模型输出的序列长度和 LaTeX 标记长度不一定相等。一个 5 个字符的「x12」在图上占 300 像素CNN 下采样后特征序列可能还有几十帧。CTCConnectionist Temporal Classification就是用来解决这个错位问题的——它允许模型在每个时间步输出一个标记或空白然后通过动态规划把所有可能的对齐路径概率求和。TensorFlow 里用 Keras 调 CTC 的方式并不复杂但有三处要注意。第一模型输出维度是「字符表大小 1」多出来的是 CTC 的 blank 符号。第二训练时拿到的标签要转成稀疏张量因为不同公式的长度不一样。第三tf.nn.ctc_loss需要传入真实的序列长度不能依赖推理时的动态形状。模型结构与损失函数可以直接写成子类模型class FormulaRecognizer(tf.keras.Model): def __init__(self, vocab_size, rnn_units256): super().__init__() self.encoder build_cnn_encoder() # 复用上面的编码器 self.rnn1 tf.keras.layers.Bidirectional( tf.keras.layers.LSTM(rnn_units, return_sequencesTrue)) self.rnn2 tf.keras.layers.Bidirectional( tf.keras.layers.LSTM(rnn_units, return_sequencesTrue)) self.dense tf.keras.layers.Dense(vocab_size 1) # 1 是 CTC blank def call(self, inputs, trainingFalse): x inputs[image] # shape: (batch, height, width, 1) x self.encoder(x) x self.rnn1(x) x self.rnn2(x) logits self.dense(x) return logits def train_step(self, data): image, labels, seq_len data with tf.GradientTape() as tape: logits self(image, trainingTrue) loss tf.nn.ctc_loss( labelslabels, logitstf.transpose(logits, perm[1, 0, 2]), label_lengthNone, logit_lengthtf.fill([tf.shape(logits)[0]], tf.shape(logits)[1]), blank_indexvocab_size, ) gradients tape.gradient(loss, self.trainable_variables) self.optimizer.apply_gradients(zip(gradients, self.trainable_variables)) return {loss: tf.reduce_mean(loss)}这个实现里最容易被忽略的是tf.transpose(logits, perm[1, 0, 2])。CTC 的logits参数格式是[time_steps, batch_size, num_classes]Keras 默认输出是[batch, time_steps, num_classes]维度顺序不一样不转置会直接报错。blank_indexvocab_size企图让最后一个字符位成为 blank这在 CTC 里是标准做法但代价是字典构建时vocab_size必须准确字符表少一个多一个都会让训练指标和实际效果脱节。3.3 用束搜索解码 LaTeX 序列训练完成后的推理不能再用贪心解码。贪心解码是每个时间步取概率最大的字符连起来的结果在公式场景下往往是错误的模型可能先输出了「\fr」再输出「ac」和「{」但最终拼起来是残缺的 LaTeX 片段。束搜索会保留前 K 条累计概率最高的路径每步扩展所有可能字符最后挑整体序列概率最高的那一条。TensorFlow 中没有公开的高性能束搜索解码器常见做法是借助外部工具或用 tf.py_function 包一层实现了 beam search 的 python 函数def beam_search_decode(logits, seq_len, char_to_idx, beam_width10): # logits shape: (batch, time, vocab) decoded [] for batch_idx in range(logits.shape[0]): seq_logits logits[batch_idx, :seq_len[batch_idx], :] beam [([], 0.0)] for t in range(seq_logits.shape[0]): prob tf.nn.softmax(seq_logits[t]).numpy() new_beam [] for prefix, score in beam: for char_idx, p in enumerate(prob): # 累计对数概率 new_beam.append((prefix [char_idx], score tf.math.log(p))) # 合并相同前缀处理 blank 和重复字符 merged {} for prefix, score in new_beam: sign prefix[-1] if prefix else None if sign char_to_idx[blank]: key prefix[:-1] merged[key] max(merged.get(key, -1e9), score) elif len(prefix) 1 and prefix[-1] prefix[-2]: key prefix[:-1] merged[key] max(merged.get(key, -1e9), score) else: merged[tuple(prefix)] max(merged.get(tuple(prefix), -1e9), score) beam sorted(merged.items(), keylambda x: x[1], reverseTrue)[:beam_width] decoded.append(beam[0][0]) return decoded束宽beam_width10是中文公式场景的常用起点。束宽太小比如 1等于贪心束宽太大会让每一帧的排序开销成倍上涨。批处理中每条的seq_len都要单独记录因为公式图像宽度不同特征序列长度也就不同。解码拿到字符索引后要通过字符字典反向映射成 LaTeX 文本然后在文本层再做一次方括号和花括号配对校验防止解码错误产生不合法标记。4. 自动评分的数学表达式语义等价判断4.1 不能只靠字符串匹配来评分识别模型输出的 LaTeX 是「x^22x1」但标准答案是「(x1)^2」——字符串完全不同数学上却完全等价。自动评分要做的是判断两个表达式在数学意义上是否一致这比字符级匹配复杂得多。评分管线的通常做法是先把 LaTeX 解析成表达式树再做语义归一化最后比较结构。解析环节如果自己写会很费劲常见做法是用 sympy 的parse_latex或者先转成 MathML 再做规范化。我一般会用 sympy 把 LaTeX 转成符号表达式这一步的好处是交换律和结合律天然被 sympy 处理了。from sympy import parse_latex, simplify, latex def normalize_answer(student_latex, standard_latex): try: stu_expr parse_latex(student_latex) std_expr parse_latex(standard_latex) # simplify 会做基本的通分、合并同类项、化简根式 stu_simple simplify(stu_expr) std_simple simplify(std_expr) # 比较是否等价 is_equal stu_simple.equals(std_simple) return is_equal, stu_simple, std_simple except Exception as e: # 解析失败说明识别结果本身就不完整直接判错 return False, None, Noneparse_latex对不完整 LaTeX 会抛异常这反而是个好事——它帮评分模块挡住了很多由识别阶段引入的畸形输出。simplify对多项式、分式、三角恒等式都有一定处理能力但注意它不万能对数恒等式和带绝对值的等式可能化简不出等价关系。如果题目集中在多项式化简这套流程够用如果涉及三角恒等变换建议针对高频考点加白名单规则。4.2 评分规则的颗粒度决定阅卷可信度数学阅卷不是只有对和错两个结果通常还要区分「完全正确」「思路正确但计算错误」「只写了开头」等层级。评分模块要设计成多个规则级联每一级做一种判断。我一般把规则拆成四个级别规则等级判据对应分数权重严格等价化简后表达式完全一致100%结构等价忽略变量名差异后一致90%中间步骤正确学生答案包含正确答案的子表达式60%不匹配没有找到任何语义关联0%变量名等同处理很重要学生写ab标准答案是xy这只是字母选择不同。评分模块需要在归一化时把变量按出现顺序映射成统一占位符再比较结构。中间步骤正确的判断则依赖表达式树的子树匹配以标准答案的根节点为基准检测学生表达式中是否存在同构子树。以下是针对多项式题型的一种轻量实现核心逻辑是提取每一项的「系数-幂次」对from collections import Counter def extract_polynomial_terms(expr_symbol): # 把 sympy 表达式展开成 (变量, 幂次) - 系数 的映射 terms expr_symbol.expand().as_ordered_terms() term_dict Counter() for term in terms: coeff 1 var_powers {} for factor in term.as_coeff_Mul(): # as_coeff_Mul 返回 (系数, 剩余因子) if factor.is_Number: coeff * factor elif factor.is_Pow: var_powers[str(factor.base)] factor.exp elif factor.is_Symbol: var_powers[str(factor)] 1 term_dict[tuple(sorted(var_powers.items()))] coeff return term_dict def fuzzy_score(student_expr, standard_expr): stu_terms extract_polynomial_terms(student_expr) std_terms extract_polynomial_terms(standard_expr) if stu_terms std_terms: return 1.0 # 完全匹配 # 计算相同项占比 common sum((stu_terms std_terms).values()) total sum(std_terms.values()) return common / max(total, 1)这里有个关键点expand()会先做乘法展开如果不展开(x1)^2和x^22x1在幂次统计上对不上等价判断就会失败。as_ordered_terms()返回的是有序项列表它让后续的 Counter 统计结果稳定可复现不会因为符号顺序不同而导致误判。分数映射规则建议在配置中心里维护而不是写在业务代码里这样每次月考后可以根据得分分布人工调整权重。4.3 评分置信度与人工复核联动机制自动评分的输出不能直接作为最终成绩尤其是临界情况。评分管线每个样本都会产出一个置信度——等价比较时两边相似度 0.85 到 0.99 之间的属于低置信度区间需要人工复核。实际考试数据里这个比例通常控制在 10% 到 15% 之间过高说明识别或评分逻辑有系统性偏差。批量阅卷系统的流水线设计可以按「识别 → 评分 → 抽样复核」组织。复核界面把学生原图、识别出的 LaTeX、评分结果三栏并列老师只看低置信度样本单份耗时可以控制在 5 秒内。这个机制既保证了系统效率又给阅卷结果留了人工兜底通道。置信度阈值的调法不是拍脑袋拍出来而是每轮月考后统计评分模块在人工复核样本上的误差率调低阈值直到误差率降到可接受范围。5. 部署为批量推理服务与性能调优5.1 用 TensorFlow Serving 暴露 RPC 接口训练后的模型要服务一个年级几百份卷子不可能每张图都现场跑 Python 推理循环那样既慢又难管理。最常见做法是把 SavedModel 导出给 TensorFlow Serving。TensorFlow Serving 的优势在于热加载模型版本、原生支持批量推理并且和训练框架零版本冲突。对比之下如果选 PyTorch部署时要用 TorchServe 或自己包一层 gRPC 服务两者的生态成熟度差异在实际运维中会拉得很开。导出模型时需要注意输入签名得和训练时的数据管道一致。训练时图像是[batch, height, width, 1]的 uint8 数组导出时最好指定成兼容的 shape并去掉 batch 维度让 Serving 支持动态批处理tf.function(input_signature[tf.TensorSpec([None, 64, None, 1], tf.uint8)]) def serving_fn(image_tensor): image_float tf.image.convert_image_dtype(image_tensor, tf.float32) # 这里直接复用已经训练好的 self 模型 logits self({image: image_float}, trainingFalse) return {logits: logits} tf.saved_model.save( model, export_dir, signatures{serving_default: serving_fn.get_concrete_function()} )tf.image.convert_image_dtype会把 uint8 像素转到 0.0~1.0 区间这个转换如果做在客户端会更容易被遗漏放进 serving 函数里就能保证推理输入永远符合模型训练时的分布。模型服务启动命令必须显式指定模型名和端口否则访问者找不到模型句柄tensorflow_model_server \ --rest_api_port8600 \ --model_nameformula_ocr \ --model_base_path/data/models/formula_ocr 5.2 批量请求策略与单张时长优化阅卷场景的流量特征和在线教育不同一个年级的卷子在一天内集中扫描上传峰值明显平时几乎空闲。这个场景意味着批量推理比在线推理更重要。TensorFlow Serving 内部有动态批处理机制但要在配置里显式打开默认情况下不会有批处理效果。部署时我在 Serving 的batching_parameters.txt里设置max_batch_size64、batch_timeout_micros20000这样 20 毫秒内到达的请求可以聚合成一批送到 GPU。识别单张试卷的公式区域通常会有 20 到 40 个公式一张 A4 卷子扫下来是毫秒级的单公式推理时间批量化后单张卷子的全部识别可以在 2 秒内完成。耗时大头往往出现在图像的公式检测阶段而不是识别模型本身。实践里我会把公式检测和识别拆成两个容器检测服务用 CPU 也能跑识别服务必须上 GPU这样成本更可控。5.3 尾部样本降级策略和分阶段评测指标真实阅卷系统不能追求端到端准确率一个指标我通常会拆成三个独立指标分别盯公式检测召回率卷面上有多少比例的公式区域被框出来了公式识别字符错误率CER识别出的 LaTeX 序列与标注序列的编辑距离评分一致率自动评分结果与人工阅卷结果的吻合比例检测召回率低于 95% 时评分准确率再高也没有意义因为公式压根没进到识别流程。评分一致率的基线我的目标是达到 90% 以上再谈上线剩余 10% 让复核环节兜底。最容易被忽视的是涂改区域的公式。学生写错了划掉重写扫描件上会出现两道黑杠检测模型经常把黑杠也框进公式区域。应对这种尾部样本常见做法是预处理阶段做一次形态学腐蚀操作把细长划痕先淡化掉。实践中这一条能提升 2% 到 3% 的公式检测精度。另外要针对异常宽高比的检测框做二次过滤避免「包含两个公式的大框」这类错误因为这种框识别模型几乎处理不了还不如让它漏检后走人工复核。本文还有配套的精品资源点击获取
返回列表