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, grade=None): 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, key=ops.get) self.features['main_operation'] = main_op # 假设problems是MathProblem对象的列表 problems = [MathProblem(1, "小明买了3个苹果,每个苹果5元,他付了20元,应找回多少钱?", grade=3), MathProblem(2, "小红有15颗糖,她给了弟弟5颗,又吃了2颗,还剩几颗?", grade=2)] 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(), key=lambda x: x[1], reverse=True) 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) # label=1 表示 i 比 j 难, label=0 表示 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(objective='binary:logistic', n_estimators=100) 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)是处理这类问题的强大工具。
第四,教育问题需要教育视角。技术只是工具。在特征设计和结果评估阶段,我们多次与一线数学教师沟通,了解他们判断题目相似和难度的直觉依据。这些领域知识帮助我们设计出了更贴合教育实际的特征(如“是否包含逆向思维”、“条件是否冗余”),也让最终的模型输出更容易被教育工作者理解和信任。
最后,这个项目的代码和文档虽然是为竞赛而生,略显粗糙,但其中贯穿的思路——将模糊的教育概念转化为可计算的特征,利用数据和算法来辅助教育决策——正是当前教育智能化探索的一个缩影。希望这次分享,能为你打开一扇窗,看到技术赋能教育的更多可能性。如果你正在从事相关领域的工作,或者对这个方向感兴趣,欢迎一起交流探讨。