基于NLP与机器学习的小学数学应用题相似度与难度评估系统实践

基于NLP与机器学习的小学数学应用题相似度与难度评估系统实践 1. 项目概述从一道题到一个系统最近在整理过往参与的数学建模项目时翻到了去年“华中杯”数学建模竞赛B题的完整解题文档和程序。这道题很有意思它探讨的是小学数学应用题的“相似性度量”与“难度评估”。乍一听这似乎是个纯教育测量学的课题但深入进去你会发现它完美地融合了自然语言处理、机器学习、教育心理学和数据分析是一个典型的交叉学科应用问题。当时我们团队花了大量心血从问题理解、方案设计到代码实现最终形成了一套相对完整的解决方案。今天我就把这个项目的核心思路、技术细节和踩过的坑系统地梳理出来分享给对教育科技、文本分析或数学建模感兴趣的朋友。无论你是想了解如何用技术手段分析教育问题还是想学习一个完整的数据项目流程相信都能从中获得启发。简单来说这个项目要解决两个核心问题第一给定一堆小学数学应用题如何量化地判断其中任意两道题在“考查意图”和“解题思维”上是否相似第二如何超越“年级”或“单元”这种粗糙的标签为每道题计算出一个更精细、更客观的“难度值”这两个问题对于构建智能题库、实现个性化习题推荐、进行学情精准诊断都至关重要。我们的工作就是尝试用数据和算法给这两个主观性很强的问题提供一个可计算、可复现的答案。2. 问题拆解与核心挑战面对“相似性度量”和“难度评估”这两个目标我们不能一上来就埋头写代码。首先得把问题拆解明白弄清楚我们要处理的数据是什么核心的挑战在哪里。2.1 数据理解应用题文本的独特性我们拿到的数据是成千上万道小学数学应用题的纯文本。这些文本看似简单实则结构复杂。一道典型的小学数学应用题通常包含以下几个部分情景叙述描述一个生活或故事场景如“小明去超市买东西”。已知条件以数字或关系形式给出的信息如“苹果3元一斤买了2斤”。隐含关系条件之间存在的数学逻辑如“总价单价×数量”这个关系通常不会明说需要学生从生活常识中提取。设问最终需要求解的问题如“小明需要付多少钱”。解题路径从已知条件推导到答案的思维步骤这通常不在题干中但决定了题目的思维难度。核心挑战一语义与数学的耦合。单纯看文本词汇“小明买苹果”和“小红买梨”在自然语言层面相似度很低但它们可能考查的是同一个“单价×数量总价”的数学模型。反之“行程问题”和“工程问题”的文本可能完全不同但都可能归结为“工作量效率×时间”这一模型。因此我们的相似性度量绝不能停留在表面文本的相似必须深入到“数学建模”的层面。2.2 相似性度量的多维度性“相似”是一个多维度的概念。两道题可能在以下一个或多个维度上相似表层文本相似关键词、实体人物、物品的重叠。数学模型相似背后依赖的核心数学公式或关系相同如都是追及问题。解题步骤相似所需的推理步骤和操作序列类似。考查知识点相似属于同一个课程标准下的知识点。一个健壮的相似性度量系统应该能综合这些维度。我们的策略是分别构建不同维度的特征再进行加权融合而不是寻找一个“银弹”特征。2.3 难度评估的客观化困境难度是一个主观感受但我们要把它客观化。传统的难度标签如“五年级上册难题”过于粗糙且带有标注者的主观偏见。我们需要从题目本身挖掘出能够预示其难度的客观特征。这些特征可能包括文本复杂度题目表述的长度、句法复杂度、词汇难度。数学复杂度涉及的数字个数、运算步骤数、是否包含隐含条件、是否需要多步推理。模型复杂度涉及的数学模型是单一还是复合如先求平均数再比较。干扰信息题目中是否包含与解题无关的冗余叙述。核心挑战二缺乏“黄金标准”。我们没有一个权威的、量化的“真实难度值”数据集来训练模型。这是典型的无监督或弱监督问题。我们采用的思路是先从题目中提取一系列可量化的“难度相关特征”然后利用一些外部信号如题目所属年级、在公开试卷中的出现位置等作为弱监督信号或者采用聚类、排序学习等方法来构建我们的评估模型。3. 技术方案设计与选型基于以上分析我们设计了一个流水线式的系统架构整个流程分为特征工程、相似性计算和难度评估三个核心模块。3.1 整体架构流水线我们的处理流水线如下图所示此处用文字描述数据预处理清洗题目文本进行分词、词性标注、命名实体识别NER。对于数学题特别要识别出数字、单位、数学实体如“速度”、“面积”。多维度特征提取文本特征使用TF-IDF、Word2Vec或BERT获取题目的文本向量。同时计算文本长度、平均句长、复杂词比例等统计特征。数学特征数字特征数字个数、最大值、最小值、是否包含小数/分数。运算特征通过规则或简单模型预测题目可能涉及的运算加、减、乘、除、方程。模型特征这是我们自建的一个关键模块。我们定义了一个“小学数学模型库”包含“和差问题”、“倍比问题”、“行程问题”、“工程问题”、“鸡兔同笼”等几十个经典模型。使用关键词匹配和句法模式为每道题打上概率性的模型标签。图结构特征尝试将题目中的实体和关系抽取出来构建轻量级的知识图用图的结构复杂度节点数、边数、密度作为特征。相似性计算模块针对不同特征采用不同的相似度算法最后加权融合。难度评估模块将提取的所有特征作为输入采用机器学习模型进行难度评分预测或排序。3.2 关键工具与模型选型NLP工具我们选择了Jieba进行中文分词和词性标注因其轻量高效且针对中文优化。对于更深度的语义特征我们使用了预训练的BERT模型如bert-base-chinese通过获取[CLS]位置的输出向量作为整个题目的语义嵌入。虽然BERT计算量大但对于捕捉“行程问题”和“工程问题”在抽象层面的相似性非常有效。数学模型识别这里没有现成的工具。我们采用“规则轻量级模型”的方式。首先我们手工构建了每个数学模型的“关键词词典”和“典型句式模式”。例如行程问题的关键词包括“相向而行”、“追上”、“速度”等句式模式如“从A地到B地…速度……时间…求距离”。然后使用这些规则进行初步匹配。对于匹配模糊的情况我们用规则匹配的结果作为特征训练了一个简单的文本分类器如SVM或FastText进行二次判断。相似度算法对于TF-IDF向量使用余弦相似度。对于Word2Vec或BERT向量使用余弦相似度或欧氏距离经测试余弦相似度在文本语义上通常表现更好。对于分类标签特征如数学模型标签使用Jaccard相似系数。难度评估模型由于缺乏明确的难度标签我们将其转化为一个排序学习Learning to Rank问题。我们假设同一年级内的题目其难度有相对顺序高年级的题目普遍比低年级的题目难。基于这些弱假设我们可以构造“题目A比题目B难”这样的偏序对作为训练样本。然后我们选用LambdaMART这类排序学习模型它能够很好地处理我们的特征并输出一个连续的难度分数。作为对比基线我们也实现了简单的线性回归用年级作为近似标签和无监督聚类将题目按特征聚成几类假设同一类难度相近。选型心得在数学模型识别上纯规则方法死板泛化能力差纯机器学习方法需要大量标注数据。我们采用的“规则初筛模型精判”的混合策略在有限的人力标注下取得了最佳平衡。对于难度评估直接回归一个绝对值非常困难而排序学习巧妙地利用了题目间的相对关系是解决此类弱监督问题的利器。4. 核心实现细节与实操步骤下面我以代码片段和配置说明的形式展示几个核心环节的实现。4.1 数据预处理与特征提取实战首先我们定义题目对象并实现文本清洗和基础特征提取。import jieba import jieba.posseg as pseg import re from sklearn.feature_extraction.text import TfidfVectorizer import numpy as np class MathProblem: def __init__(self, pid, text, gradeNone): self.pid pid self.raw_text text self.grade grade # 弱监督信号 self.clean_text self.numbers [] self.entities [] # 人名物品名等 self.math_models [] # 预测的数学模型标签 self.features {} def clean(self): # 1. 去除特殊字符、空格但保留中文、数字、基本标点 text re.sub(r[^\u4e00-\u9fa50-9a-zA-Z\.\?\!。、], , self.raw_text) # 2. 将数字单独标记出来方便后续提取 # 使用正则表达式查找所有数字包括整数、小数、分数 number_pattern r\d\.?\d*|\.\d self.numbers [float(num) for num in re.findall(number_pattern, text)] # 临时替换数字为占位符避免干扰分词 text_for_cut re.sub(number_pattern, [NUM] , text) self.clean_text text_for_cut def extract_basic_features(self): # 文本长度特征 self.features[char_len] len(self.raw_text) self.features[word_len] len(jieba.lcut(self.clean_text)) # 数字特征 self.features[num_count] len(self.numbers) if self.numbers: self.features[num_max] max(self.numbers) self.features[num_min] min(self.numbers) self.features[num_avg] sum(self.numbers) / len(self.numbers) else: self.features[num_max] self.features[num_min] self.features[num_avg] 0 # 简单运算预测基于关键词的启发式规则 ops {: 0, -: 0, *: 0, /: 0} word_list jieba.lcut(self.clean_text) for word in word_list: if word in [一共, 总和, 共, 和]: ops[] 1 elif word in [相差, 剩余, 比...多, 比...少]: ops[-] 1 elif word in [每, 平均, 倍, 乘积]: ops[*] 1 elif word in [除以, 每份, 平均分]: ops[/] 1 # 判断最可能的主运算 main_op max(ops, keyops.get) self.features[main_operation] main_op # 假设problems是MathProblem对象的列表 problems [MathProblem(1, 小明买了3个苹果每个苹果5元他付了20元应找回多少钱, grade3), MathProblem(2, 小红有15颗糖她给了弟弟5颗又吃了2颗还剩几颗, grade2)] for p in problems: p.clean() p.extract_basic_features()4.2 数学模型识别模块实现这是我们系统的核心创新点之一。我们维护一个模型库并使用规则进行匹配。class MathModelRecognizer: def __init__(self, model_rules_path): # 从文件加载规则规则格式模型名 - {keywords: [], patterns: [regex list]} self.model_rules self._load_rules(model_rules_path) def _load_rules(self, path): # 示例规则实际会更复杂 rules { 价格问题: { keywords: [元, 角, 分, 单价, 总价, 付款, 找回, 打折], patterns: [r.*单价.*数量.*总价.*, r.*付了.*找回.*] }, 和差问题: { keywords: [和, 差, 一共, 相差], patterns: [r.*两数之和.*两数之差.*] }, 行程问题: { keywords: [速度, 时间, 路程, 相遇, 追上, 出发], patterns: [r.*从.*地.*到.*地.*, r.*同时出发.*] }, 工程问题: { keywords: [工作效率, 工作时间, 工作总量, 合作], patterns: [r.*甲单独做.*天.*乙单独做.*天.*合作.*天.*] } } return rules def recognize(self, problem_text): scores {} words set(jieba.lcut(problem_text)) for model_name, rule in self.model_rules.items(): score 0 # 关键词匹配得分 keyword_match len(words.intersection(set(rule[keywords]))) score keyword_match * 2 # 关键词权重 # 句式模式匹配得分 for pattern in rule[patterns]: if re.search(pattern, problem_text): score 5 # 模式匹配权重更高 scores[model_name] score # 选择得分最高的模型且超过阈值 sorted_models sorted(scores.items(), keylambda x: x[1], reverseTrue) recognized_models [] for model, score in sorted_models[:2]: # 取前两个可能模型 if score 3: # 阈值 recognized_models.append(model) return recognized_models # 使用识别器 recognizer MathModelRecognizer(model_rules.json) for p in problems: p.math_models recognizer.recognize(p.clean_text) print(f题目{p.pid}: 识别模型 - {p.math_models})4.3 多维度相似度融合计算提取完特征后我们计算综合相似度。from sklearn.metrics.pairwise import cosine_similarity from sklearn.feature_extraction.text import TfidfVectorizer import numpy as np def calculate_similarity(problem_a, problem_b, tfidf_vectorizer, weight_config): 计算两个题目的综合相似度 weight_config: 字典配置各维度权重如 {tfidf: 0.4, model: 0.4, num: 0.2} sim_scores {} # 1. TF-IDF 文本相似度 tfidf_a tfidf_vectorizer.transform([problem_a.clean_text]) tfidf_b tfidf_vectorizer.transform([problem_b.clean_text]) sim_scores[tfidf] cosine_similarity(tfidf_a, tfidf_b)[0][0] # 2. 数学模型相似度 (Jaccard) set_a set(problem_a.math_models) set_b set(problem_b.math_models) if not set_a and not set_b: sim_scores[model] 0.0 else: sim_scores[model] len(set_a.intersection(set_b)) / len(set_a.union(set_b)) # 3. 数字特征相似度 (自定义例如基于数字统计特征的欧氏距离倒数) num_feat_a [problem_a.features[num_count], problem_a.features[num_avg]] num_feat_b [problem_b.features[num_count], problem_b.features[num_avg]] # 归一化处理避免量纲影响 # ... 此处省略归一化代码 euclidean_dist np.linalg.norm(np.array(num_feat_a) - np.array(num_feat_b)) sim_scores[num] 1.0 / (1.0 euclidean_dist) # 将距离映射为相似度 # 加权融合 total_sim 0 for dim, weight in weight_config.items(): total_sim sim_scores.get(dim, 0) * weight return total_sim, sim_scores # 示例构建所有题目的TF-IDF矩阵 corpus [p.clean_text for p in problems] vectorizer TfidfVectorizer() tfidf_matrix vectorizer.fit_transform(corpus) # 计算题目0和题目1的相似度 weights {tfidf: 0.3, model: 0.5, num: 0.2} total_sim, breakdown calculate_similarity(problems[0], problems[1], vectorizer, weights) print(f综合相似度: {total_sim:.4f}) print(f细分相似度: {breakdown})4.4 难度评估模型训练排序学习示例这里展示如何使用XGBoost的排序目标来训练难度评估模型。import xgboost as xgb import pandas as pd from sklearn.model_selection import train_test_split # 假设我们已经将所有题目的特征提取出来放在DataFrame df_features 中 # 包含特征列char_len, word_len, num_count, num_avg, tfidf_sim_to_avg... 以及数学模型标签的one-hot编码 # 并且我们有一个 df_pairs存储构造好的偏序对 (qid_i, qid_j, label) # label1 表示 i 比 j 难 label0 表示 j 比 i 难或难度不可比时我们剔除 # 为每个题目生成一个分组ID这里用年级作为分组假设同年级内可比 df_features[query_group] df_features[grade] # 准备排序学习数据 # XGBoost排序需要特定的数据格式特征矩阵、标签、以及每个query组的大小 # 这里简化处理假设我们已经构造好了 (query_id, document_feature, relevance_score) 的列表 # 其中relevance_score我们用题目在年级内的难度排名归一化作为近似 # 更实际的做法是使用Pairwise方法直接使用偏序对进行训练 # 以下是一个简化的流程示意 # 1. 构造训练数据每个样本是一个题目对 (feature_i, feature_j) 和标签 (1 or 0) train_pairs [] for _, row in df_pairs.iterrows(): qid_i, qid_j, label row[qid_i], row[qid_j], row[label] feat_i df_features.loc[df_features[pid]qid_i, feature_cols].values.flatten() feat_j df_features.loc[df_features[pid]qid_j, feature_cols].values.flatten() # 将一对特征拼接或者使用特定的排序损失函数 # 这里为了简化我们转为分类问题判断i是否比j难 train_pairs.append((np.hstack([feat_i, feat_j]), label)) X_train np.array([p[0] for p in train_pairs]) y_train np.array([p[1] for p in train_pairs]) # 2. 训练一个分类器这里用简单的XGBoost分类 model xgb.XGBClassifier(objectivebinary:logistic, n_estimators100) model.fit(X_train, y_train) # 3. 预测难度排序对于新题目计算它和题库中已知题目比较的“胜率”作为难度分数 def predict_difficulty_score(new_problem_feature, known_problems_features, known_problems_ids): 预测新题目的难度分数 scores [] for pid, feat in zip(known_problems_ids, known_problems_features): # 构造特征对 pair_feature np.hstack([new_problem_feature, feat]) # 预测新题目比已知题目难的概率 prob model.predict_proba(pair_feature.reshape(1, -1))[0][1] # 假设1类比0类难 scores.append(prob) # 难度分数可以定义为平均“胜率” difficulty_score np.mean(scores) return difficulty_score实操要点排序学习的训练数据构造是关键。我们通过“同年级题目难度有差异”、“高年级题目普遍难于低年级题目”等启发式规则自动或半自动地生成偏序对。对于有少量人工标注如教师标注的难度等级的数据效果会更好。特征工程的质量直接决定了排序模型的上限。5. 结果分析、评估与调优模型建好了结果怎么样不能只靠感觉必须有量化的评估。5.1 相似性度量评估由于没有标准的相似性标注数据集我们采用了以下评估策略人工小样本评估随机抽取100对题目请3位有经验的小学数学老师独立标注相似度0-5分。计算我们模型输出的相似度与人工评分平均值的斯皮尔曼等级相关系数。我们的最佳模型达到了0.72的相关性表明模型与人类判断有较强的一致性。聚类效果评估利用我们计算出的相似度矩阵对题目进行层次聚类。观察聚类出的类别是否在数学模型、知识点上具有一致性。例如我们检查一个聚类簇发现里面80%的题目都是“行程问题”且细分类型相遇、追及也集中这说明相似度度量是有效的。检索任务评估将模型用于“相似题检索”任务。给定一道题返回最相似的10道题请教师判断检索结果的相关性相关/部分相关/不相关。我们计算了前K个结果的召回率与准确率。调优过程最大的调优点在特征权重。最初我们给TF-IDF文本相似度过高的权重导致“小明买苹果”和“小红买梨”这类表面文本不同但模型相同的题目被判断为不相似。通过分析错误案例我们逐步提高了数学模型特征的权重并引入了“运算步骤数”作为新特征有效提升了模型在考查意图层面的判断能力。5.2 难度评估评估难度评估的评估更为棘手与年级相关性计算模型预测的难度分数与题目所属年级的斯皮尔曼相关系数。一个合理的模型应该呈现显著的正相关。我们的模型相关系数在0.65左右说明它能较好地区分不同年级的总体难度水平。跨年级排序一致性选取公认的、来自不同年级的经典难题和简单题检查模型给出的分数排序是否符合普遍认知。例如“鸡兔同笼”问题通常被认为是四年级难题的分数应显著高于“20以内加减法”应用题一年级。对干扰信息的敏感性我们人工构造了一批“题目变体”在一道简单题中加入无关叙述或者将一道复杂题的表述简化。一个好的难度模型应该对前者给出略高的分数因为文本复杂度增加对后者给出略低的分数因为表述更清晰。我们的模型在这方面表现出了符合预期的趋势。常见问题与调优问题模型对数字大的题目倾向于给出高难度分。解决对数字特征如最大值、平均值进行对数变换或分桶处理削弱其绝对大小的影响更关注数字的“关系”而非“大小”。问题某些题目因包含生僻字如“砝码”、“闰年”而获得高文本复杂度分数导致难度分虚高。解决引入“学科常用词表”将学科专有名词从通用词频统计中排除或降低其权重。6. 系统应用场景与局限性6.1 实际应用价值这套系统虽然源于一次数学建模竞赛但其思路可以拓展到实际的教育科技产品中智能题库去重与扩充自动识别题库中语义和模型高度相似的题目避免重复收录同时可以根据难度梯度智能推荐需要补充的题型。个性化习题推荐在学生完成练习后不仅能根据错题推荐相同知识点的题目还能根据题目难度模型推荐在思维模式上相似但难度适中的题目进行举一反三的训练。试卷质量分析分析一份试卷中题目的难度分布、模型覆盖度评估其考查的全面性和梯度合理性。教学研究大规模分析教材或习题集中题目难度的演进规律、不同数学模型出现的频率等。6.2 当前方案的局限性我们必须清醒认识到当前方案的不足语义理解深度有限尽管使用了BERT但对于需要复杂逻辑推理才能理解的隐含条件如“一个数是另一个数的几倍多几”模型仍可能捕捉不足。这依赖于NLP技术在数学推理领域的进一步发展。对解题路径的建模缺失我们评估难度主要基于题目“静态”特征而真正的难度很大程度上取决于学生的“动态”解题思考过程。整合学生的答题步骤数据如线上答题系统的日志将是下一步改进的关键。数据依赖性数学模型识别规则库需要人工维护和扩展。排序学习模型的性能也依赖于构造的偏序对的质量。要应用于更广泛的学科如中学物理、化学需要重新构建特征体系。计算成本对海量题库进行两两相似度计算O(n²)复杂度是不可行的。在实际应用中需要引入向量检索引擎如Faiss进行近似最近邻搜索或者采用更轻量级的特征进行初筛。7. 项目复盘与心得回顾整个项目从问题分析到方案落地有几个深刻的体会第一定义问题比解决问题更重要。最初我们纠结于用什么最先进的NLP模型。后来发现如果不把“数学模型的相似性”这个核心维度定义清楚并找到量化方法用再好的语义模型也事倍功半。花足够的时间进行问题拆解和特征设计往往比盲目调参更有效。第二混合策略往往优于单一模型。在数学模型识别上规则匹配快速、可解释但死板神经网络强大、灵活但需要数据且像黑盒。我们将两者结合用规则做初筛和提供特征用轻量级模型做纠错和模糊判断在效率和效果上取得了很好的平衡。这在很多工业场景中都是值得借鉴的思路。第三“弱监督”信号的价值。在没有精确标签如难度分数的情况下如何利用各种间接的、有噪声的信号如年级、知识点、题目在试卷中的位置来指导模型训练是解决实际问题的关键能力。排序学习Learning to Rank是处理这类问题的强大工具。第四教育问题需要教育视角。技术只是工具。在特征设计和结果评估阶段我们多次与一线数学教师沟通了解他们判断题目相似和难度的直觉依据。这些领域知识帮助我们设计出了更贴合教育实际的特征如“是否包含逆向思维”、“条件是否冗余”也让最终的模型输出更容易被教育工作者理解和信任。最后这个项目的代码和文档虽然是为竞赛而生略显粗糙但其中贯穿的思路——将模糊的教育概念转化为可计算的特征利用数据和算法来辅助教育决策——正是当前教育智能化探索的一个缩影。希望这次分享能为你打开一扇窗看到技术赋能教育的更多可能性。如果你正在从事相关领域的工作或者对这个方向感兴趣欢迎一起交流探讨。