尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于TensorFlow的手写数学公式识别与自动评分系统实践

基于TensorFlow的手写数学公式识别与自动评分系统实践 简介这是一份围绕TensorFlow智能阅卷系统的手写数学公式识别与自动评分完整技术文档面向教育技术开发人员、教师以及对深度学习落地教育场景感兴趣的读者。内容从智能阅卷的背景与系统框架讲起覆盖TensorFlow核心概念、手写公式数据集构建、CNN/RNN与编码器-解码器模型设计并结合规则与机器学习提出综合评分策略后半部分还给出系统实现代码、评估指标、案例分析和改进方向能让读者获得从模型训练到评分部署的完整思路。资源为单份PDF文件约1.94MB目录结构清晰便于按章节查阅。目前已有141人学习下载适合作为课程设计、科研参考或智能教育项目起点也可直接用于学校日常考试、在线教育平台的智能阅卷方案预研。1. 为什么手写公式识别比普通OCR更难在学校日常考试和在线教育平台这类场景里批量扫描后的数学试卷带来的主要工作量从来不在选择题而在包含分式、根号、指数上下标的演算题。普通OCR对印刷体英文和数字已经比较成熟但手写数学公式存在两重困难第一是书写风格差异大连笔、涂改、倾斜都会干扰字符切分的判断第二是公式本身是二维结构x1/2到底是“x加二分之一”还是“(x1)分之2”依赖上下标和横线的空间关系单靠字符级识别无法还原。TensorFlow要做的是把公式图像端到端地转换为LaTeX语义序列然后再基于语义做自动评分。这套思路适合教育技术方向的开发者和算法工程师也适合需要快速搭建自动批改原型的团队参考。2. 数据集构建与预处理从CROHME到数据增强公式识别模型的效果上限由数据集决定这句话在实践里基本是成立的。CROHME是手写数学公式识别领域使用最广的公开基准它提供了多种书写者的公式图像和对应的LaTeX标注适合做预训练和基线评估。MathBrush数据集同样包含手写公式的解析树标注但它的规模相对小更适合做特殊结构的补充验证。实际项目里通常的做法是先用CROHME做模型预热再结合业务场景收集一批本地试卷图像做领域微调。数据集来源标注形式适合用途CROHME手写公式识别竞赛LaTeX序列预训练、模型选型MathBrush学术项目解析树结构补充验证自建数据集扫描仪或平板采集LaTeX 步骤元数据领域微调、评分规则绑定2.1 图像清洗与倾斜校正的OpenCV实现扫描图像先要解决噪声和倾斜。高斯滤波能平滑笔迹边缘的孤立噪点直方图均衡化可以缓解光照不均导致的灰度偏移。这两个操作在OpenCV里都是几行代码的事但参数需要按实际扫描分辨率调整。import cv2 img cv2.imread(scan_math.jpg, cv2.IMREAD_GRAYSCALE) blurred cv2.GaussianBlur(img, (5, 5), 0) equalized cv2.equalizeHist(blurred) _, binary cv2.threshold(equalized, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU)高斯核(5,5)是中等偏保守的设置扫描分辨率是300dpi时可以保持这个值如果图像里笔迹较细可以换成(3,3)保留更多边缘细节。Otsu阈值法避免了手动调阈值的环节它的原理是最大化前景背景类间方差对大多数白纸黑字的试卷图像表现足够稳定。2.2 公式区域定位与统一缩放定位公式区域最直接的方法是轮廓检测加最大外接矩形。注意要先做形态学闭运算把断裂的笔画和上下标连接起来否则根号内的字符可能被切成多个独立轮廓。kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) x, y, w, h cv2.boundingRect(max(contours, keycv2.contourArea)) crop binary[y:yh, x:xw] resized cv2.resize(crop, (224, 224), interpolationcv2.INTER_AREA)裁剪后的公式宽度差异很大直接拉伸到224×224可能导致长公式中的字符变形。比较稳妥的做法是保持长宽比缩放剩余空间用白色像素填充这样能避免模型学到错误的字符宽高比。INTER_AREA插值在缩小图像时对高频笔迹的保留效果优于双线性插值。2.3 数据增强的边界在哪里数据增强能显著提升泛化能力但用在公式图像上要克制。旋转角度超过15度会破坏横线的语义竖直翻转更是直接把上下标关系颠倒。我一般只保留小角度旋转、轻微亮度扰动和缩放裁剪同时关闭水平翻转和竖直翻转。import tensorflow as tf augment tf.keras.Sequential([ tf.keras.layers.RandomRotation(0.1), tf.keras.layers.RandomTranslation(0.05, 0.05), tf.keras.layers.RandomBrightness(0.1), ])旋转范围0.1弧度大约5.7度足够模拟手写倾斜又不会影响分式横线的空间关系。RandomTranslation控制在5%以内的平移防止公式主体移出视野后丢失关键符号。亮度抖动只加0.1因为数学试卷大多是黑白印刷过强的亮度扰动反而会让模型学到与识别无关的噪声模式。2.4 标注与数据集划分标注工具方面LabelImg适合做区域框选公式内容转成LaTeX可以用Mathpix辅助生成初稿再人工校对。划分数据时要注意按书写者分组不能随机切分图像否则同一个人的笔迹会同时出现在训练集和测试集评估结果虚高。from sklearn.model_selection import train_test_split writers list(set(sample[writer_id] for sample in samples)) train_w, temp_w train_test_split(writers, test_size0.3, random_state42) val_w, test_w train_test_split(temp_w, test_size0.5, random_state42) train_data [s for s in samples if s[writer_id] in train_w] val_data [s for s in samples if s[writer_id] in val_w] test_data [s for s in samples if s[writer_id] in test_w]按书写者ID划分有一个额外好处训练时看到的手写风格和测试集差异更真实后期部署到新学校时模型的冷启动表现更接近这个评估结果。random_state固定为42保证实验可复现调参过程中不要改变这个值否则每次对比实验的数据分布都不一样结论不可信。3. CNNSeq2Seq公式识别模型的搭建与训练模型选型时最常被问到的问题是为什么不用Transformer公式识别确实有Transformer方案且在CROHME上能拿到相当高的指标但需要的数据量和训练技巧也更高。中小规模数据集上用CNN提取图像空间特征、RNN序列建模、注意力机制对齐字符位置这套组合更容易收敛调试成本也更低。TensorFlow和PyTorch都能实现这套结构关键不在于框架而在于数据管道和损失函数的设计。3.1 输入管道从图像到序列数据用tf.data构建管道读取图像并转换为模型输入格式。标签是LaTeX序列需要先构建字符表。import tensorflow as tf def parse_example(image_path, label_str): img tf.io.read_file(image_path) img tf.image.decode_jpeg(img, channels1) img tf.cast(img, tf.float32) / 255.0 img tf.image.resize(img, [64, 256]) tokens [char_to_id.get(ch, char_to_id[unk]) for ch in label_str] return img, tokens dataset tf.data.Dataset.from_tensor_slices((image_paths, labels)) dataset dataset.map(parse_example).padded_batch( batch_size32, padding_values(0.0, char_to_id[pad]) )64×256是经过实验验证的输入尺寸对一行公式足够用又不会让模型参数过多。padded_batch用0.0填充图像用pad填充标签序列保证同一批内张量shape一致。图像统一除255是为了把像素值压缩到[0,1]这比不做归一化直接喂原始灰度值收敛快很多。3.2 模型结构CNN特征提取与Attention解码编码器部分用四层卷积提取空间特征每层卷积后接BatchNormalization和MaxPooling输出再压平成一个特征序列。解码器用GRU加BahdanauAttention配合Teacher Forcing训练。from tensorflow.keras import layers encoder_input tf.keras.Input(shape(64, 256, 1), nameimage) x layers.Conv2D(32, (3,3), paddingsame, activationrelu)(encoder_input) x layers.BatchNormalization()(x) x layers.MaxPooling2D((2,2))(x) x layers.Conv2D(64, (3,3), paddingsame, activationrelu)(x) x layers.BatchNormalization()(x) x layers.MaxPooling2D((2,2))(x) x layers.Conv2D(128, (3,3), paddingsame, activationrelu)(x) x layers.BatchNormalization()(x) x layers.MaxPooling2D((2,2))(x) encoder_features layers.Reshape((-1, 128))(x) decoder_input tf.keras.Input(shape(None,), namelabel) embedding layers.Embedding(vocab_size, 128, mask_zeroTrue)(decoder_input) gru_out layers.GRU(256, return_sequencesTrue)(embedding) dense_out layers.Dense(vocab_size, activationsoftmax)(gru_out) model tf.keras.Model([encoder_input, decoder_input], dense_out) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), losstf.keras.losses.SparseCategoricalCrossentropy(from_logitsFalse), metrics[accuracy] )这里代码简化了Attention机制。完整的BahdanauAttention实现中解码器每一步会用上一时刻的隐藏状态参与加权求和encoder_features中的每个位置得到上下文向量后与当前词嵌入拼接再输入GRU。embedding层的mask_zeroTrue能让GRU忽略padding部分的计算这也要求模型第一层必须处理mask多层RNN时还需要手动传入mask。3.3 损失函数选型CTC还是Teacher Forcing公式识别有两种主流训练方式一种是输出概率序列后用CTC Loss对齐适合输入输出长度关系复杂的情况另一种是Encoder-Decoder加Teacher Forcing每步用上一时刻的真实标签作为当前输入交叉熵直接监督每一步的字符预测。CROHME这类短公式场景选用后者理由有三点数据集不包含字符位置级的对齐标注CTC需要额外对齐层公式序列通常在20-40个token以内逐字符交叉熵的误差传播更直接Teacher Forcing训练稳定早停的指标波动小。训练方式对齐要求数据标注收敛特征CTC Loss需要输入与标签长度对齐序列标注即可前期loss下降慢后期较稳Teacher Forcing无需严格对齐序列标注即可前期收敛快依赖词嵌入设计强化学习微调无需对齐需设计奖励函数用于指标优化不宜冷启动3.4 训练时容易踩的坑第一个坑是learning_rate太高导致梯度震荡。公式识别模型的embedding层比较深建议初始1e-3配合ReduceLROnPlateau在验证集loss不降时自动衰减。第二个坑是验证集和测试集不做字符级别去重导致同样的手写体出现在两边。第三个坑是padding位置参与loss计算导致模型学会把pad输出为空白字符。checkpoint tf.keras.callbacks.ModelCheckpoint( formula_ocr.keras, monitorval_loss, save_best_onlyTrue ) early_stop tf.keras.callbacks.EarlyStopping( monitorval_loss, patience5, restore_best_weightsTrue ) lr_schedule tf.keras.callbacks.ReduceLROnPlateau( monitorval_loss, factor0.5, patience3 ) history model.fit( dataset, validation_dataval_dataset, epochs50, callbacks[checkpoint, early_stop, lr_schedule] )ModelCheckpoint的save_best_only配合monitor一起用否则每个epoch都保存全模型磁盘占用会快速膨胀。restore_best_weights保证早停后模型权重回滚到验证loss最小的状态避免直接使用最后一个epoch的欠拟合或过拟合参数。ReduceLROnPlateau的factor设为0.5表示验证loss停滞3个epoch后学习率减半。4. 自动评分算法从LaTeX结果到步骤分判定识别模型输出的是LaTeX序列评分阶段需要将这个序列还原成可计算的数学表达式。直接用字符串比较两个公式的LaTeX是行不通的因为\frac{1}{2}与0.5在语义上等价但字符串完全不同。评分按照“结果分步骤分”两个维度计算公式正确性通过解析后对比符号结构步骤分靠规则引擎判断关键步骤是否出现。4.1 用sympy校验结果等价性sympy可以把LaTeX解析成数学表达式树然后进行结构化比较。需要注意操作数顺序、恒等变形和有无化简这三类情况。import sympy as sp from sympy.parsing.latex import parse_latex def check_equivalent(student_latex, answer_latex): try: expr_student sp.simplify(parse_latex(student_latex)) expr_answer sp.simplify(parse_latex(answer_latex)) return sp.simplify(expr_student - expr_answer) 0 except Exception: return Falsesimplify操作会合并同类项并尝试化简expr_student减去expr_answer化简为0说明语义等价。这一步相对可靠但如果有理式、三角恒等式这类需要特定变换的情况simplify的结果可能不稳定实际项目里我会对这类题目先做人工标注枚举等价答案。4.2 步骤分判定规则引擎怎么写步骤分不是看识别结果本身而是看中间推导过程中是否出现关键中间式。比如求解一元二次方程关键步骤是判别式b²-4ac是否出现、配方是否出现、开方是否出现。把识别出的完整公式序列按行切分逐行匹配这些关键模式。import re def extract_steps(formula_seq): lines formula_seq.split(\\\\) steps [] for line in lines: has_discriminant bool(re.search(rb\s*\^\s*2\s*-\s*4\s*a\s*c, line)) has_square bool(re.search(r\\sqrt, line)) steps.append({ line: line, has_discriminant: has_discriminant, has_square: has_square, }) return steps def grade_steps(steps, required_steps): score 0 for step in steps: if step[has_discriminant] and discriminant in required_steps: score required_steps[discriminant] if step[has_square] and square in required_steps: score required_steps[square] return min(score, 100)规则引擎的粒度需要控制把步骤拆得过细会导致误判学生跳步但最终结果正确时反而扣分过多拆得过粗又无法区分“完全不会”和“思路正确但计算出错”两种学生。经验值是每道题设定2-4个关键步骤每个步骤10-20分结果分占40%-50%。4.3 规则与机器学习结合的综合评分纯规则引擎在迁移到新题型时维护成本很高因为每类题型都要重写匹配规则。我一般会先用规则把公式特征提取成向量例如是否包含根号、是否含二阶导数、符号数量、分式嵌套深度再用一个轻量级模型做纠偏。这样保留了规则的可解释性也保留了数据驱动带来的灵活性。def extract_score_features(formula_seq, steps): features { has_sqrt: 1 if \\sqrt in formula_seq else 0, has_frac: 1 if \\frac in formula_seq else 0, num_symbols: len(re.findall(r[a-zA-Z], formula_seq)), step_hits: sum(1 for s in steps if s[has_discriminant] or s[has_square]), result_correct: 1 if check_equivalent(formula_seq, answer_latex) else 0, } return features纠偏模型建议用梯度提升树或逻辑回归输入特征维度在10个以内时逻辑回归已经足够可解释性远好于神经网络。注意训练这个评分模型时需要人工标注一批“该给一半分”的样本否则模型只能学到0分和满分两个极端。特征向量里不要放原始图像评分阶段只依赖识别出的LaTeX这是为了在系统出错时能区分是识别模块的问题还是评分模块的问题。5. 评估指标与TensorFlow Serving部署5.1 先看评估指标再谈上线公式识别模型的评估不能只看准确率。字符错误率CER比整句准确率更敏感能反映模型是否因个别符号出错导致整句判错。评分系统的核心指标是评分偏差即模型评分与教师人工评分的差值绝对值一般要求平均偏差不超过5分。import Levenshtein def cer(pred_seq, true_seq): if len(true_seq) 0: return 0.0 dist Levenshtein.distance(pred_seq, true_seq) return dist / len(true_seq)Levenshtein.distance计算编辑距离除以真实标签长度得到CER。这类指标在验收模型时比loss更直观因为模型report里的accuracy会被padding位置的预测结果稀释。例如一个公式真实长度是25个tokenpadding到40模型即使把后15个token全猜对accuracy也会虚高。5.2 用TensorFlow Serving打包识别服务识别模型训练好后需要把预训练权重和模型结构打包成SavedModel格式再用TensorFlow Serving对外提供HTTP接口。这里给出关键步骤和请求发送代码。model.export(saved_model/1)导出后目录结构是saved_model/1/saved_model.pb加variables文件夹数字1是版本号。启动服务tensorflow_model_server \ --rest_api_port8501 \ --model_nameformula_ocr \ --model_base_path$(pwd)/saved_model客户端用requests发送图像数据。注意输入数据要与训练时的预处理完全一致灰度图、缩放尺寸、归一化方式。否则模型返回的结果会系统性地变差最常见的错误是推理时忘了做Otsu二值化。import requests def call_ocr_api(image_bytes): resp requests.post( http://localhost:8501/v1/models/formula_ocr:predict, json{ instances: [{ image: image_bytes.decode(latin1) }] } ) return resp.json()POST请求的body是{instances: [...]}instances里的每个字典对应一个输入样本。image字段按训练时的预处理方式序列化实际项目中常把base64编码的图像直接塞进这个字段服务端解码后送入模型。5.3 部署后的验证技巧新数据进来时建议先做100张抽样图像的预测对比统计CER和评分偏差。如果CER突然从5%上升到15%优先检查预处理管线是否和训练时一致比如灰度化用了不同参数或者resize尺寸写成了64×224。评分模块的回归问题用回放测试解决把线上预测的公式保存下来与人工评分的对应关系定期离线验证避免模型结构或评分规则在执行更新后出现行为漂移。部署后还要关注GPU显存占用和单请求延迟公式图像分辨率不高批量推理时设置max_batch_size能显著提升吞吐。本文还有配套的精品资源点击获取
返回列表