首页 / 资讯中心 / 文章详情

基于GIKT的习题推荐系统:图神经网络与知识追踪实战

基于GIKT的习题推荐系统:图神经网络与知识追踪实战 ★ FEATURED ARTICLE
简介基于深度知识追踪GIKT模型的习题推荐系统毕业设计源码面向计算机、人工智能、大数据等专业正在做课程设计或毕设项目的学生也适合有一定Python和深度学习基础的技术学习者用于实战参考。整体采用前后端分离架构覆盖数据处理、模型训练、推荐生成与结果展示等关键环节。资源共59个文件压缩包约9.46MB其中20个py负责后端核心逻辑13个vue与7个js构成前端页面交互4个json存放配置另有pyc、csv、npy等数据或缓存文件及README等说明文档目录结构按前后端分离组织层次清晰便于按模块研读。目前已有126人学习下载。代码经过调试可直接运行解决了环境配置和基础功能实现的主要障碍通过完整源码可以学习GIKT知识追踪模型的训练流程、推荐算法与Web端交互设计对理解推荐系统落地和毕业设计答辩准备均有帮助。1. 基于GIKT的习题推荐系统这个毕设选题为什么值得做如果你正在为Python毕业设计选题发愁又不想做一个烂大街的商城管理系统那我强烈建议你看看“基于深度知识追踪GIKT模型的习题推荐系统”这个方向。它本质上是把教育心理学里的“掌握度评估”和推荐系统里的“个性化选题”结合起来系统根据学生历史答题序列用图神经网络判断每个知识点是否掌握然后从题库里挑出“会但没完全会”的题推给学生。相比传统协同过滤只看“谁和谁答过一样的题”GIKT能解释“为什么推荐这道题”——因为它建模的是学生脑袋里的知识状态变迁。这套方案用到Python生态里的PyTorch、PyG和Pandas既有模型深度又有工程落地空间能写论文、能出系统、能部署演示一个选题打通毕设所有环节。2. GIKT模型原理从DKT到交互感知图卷积知识点依赖怎么建模2.1 为什么DKT的RNN不够隐式状态与显式依赖深度知识追踪Deep Knowledge TracingDKT是这套方案的老祖宗。它用LSTM或GRU把学生的答题序列喂进去靠隐状态记录知识掌握度输出对下一道题答对的概率。在2015年刚提出时效果惊艳但你实际做习题推荐时会发现它有个很别扭的缺陷知识点之间的关系全是隐式的。比如“一元一次方程”和“一元二次方程”有先后关系“因式分解”是“解方程”的前置技能这些依赖在LSTM里被压缩成几百维的向量谁也说不清它学到哪一步。反映在推荐场景里就是模型预测“概率对了”但推荐结果毫无逻辑——它可能跳过基础题直接推综合题。这就像用一个黑匣子给学生画像画像出了但画像怎么画的、缺什么知识模块系统完全无法解释。而这恰恰是教育产品最看重的东西老师要敢用、学生要信服。GIKT要解决的就是把“隐式状态”换成“显式图结构”。2.2 GIKT的两层图概念关系图与交互图分别建模什么GIKTGraph-based Interaction-aware Knowledge Tracing在思路上做了一个关键转换把知识追踪问题从“时序序列建模”改成“图结构上的信息聚合”。整个模型里存在两张图职责完全不同别搞混。第一张是知识概念关系图。节点是知识点比如“二次函数”“因式分解”边是知识点之间的关联关系。这张图的边来源一般有两种要么直接用公开数据集里题目与知识点的映射关系做共现统计一道题同时考察两个技能它们就建立一条边要么让知识点的先后关系参与建模比如经过预处理的先修关系数据集。GIKT的图卷积就作用在这张图上每一层卷积会把邻居知识点的嵌入加权聚合到当前节点上让学“二次函数”时自动带上“配方”和“一元二次方程”的信息。第二张是交互图这是GIKT比早期GKT类模型多出来的部分。每次答题记录作为一个节点如果同一个学生在时间上连续做了两道题或者两道题涉及同一个知识点节点之间就连边。交互图中融入了两个关键特征答对/答错的结果以及做题花费的反应时间。这样图卷基层就能区分“这道题答错了但很快”——说明知识点本身不熟和“这道题答错了但磨了五分钟”——可能是粗心或题目超纲。GIKT这个名字里的Interaction-aware交互感知指的就是把答题结果和反应时间这种细粒度交互信号编码进图的节点特征里而不是只喂一个0/1对错标签。2.3 核心计算流程图卷积更新与答对概率预测如果你只是为了跑通毕设不需要把GIKT原论文的公式逐行复刻但主流程必须理解否则后面调参全靠玄学。整个前向计算分四步第一步初始化嵌入。每个习题节点和每个知识点节点都随机初始化一个向量维度一般取64或128。习题的嵌入会绑定它涉及的知识点嵌入比如“解一元二次方程”这道题的初始向量是“配方”“判别式”“开方”三个知识点嵌入的均值。第二步在知识概念图上做多层GCN聚合。每一层的更新逻辑是把邻居节点的特征乘以归一化系数后求和再经过一次线性变换和ReLU激活。归一化系数用的是常见的 (1/\sqrt{(deg(i)\cdot deg(j))})度大的热门知识点更新幅度会被压住避免高频概念淹没低频概念。第三步把聚合后的知识点向量结合学生历史交互序列在交互图上再过一层。这一步让每个交互节点“看到”邻近交互的结果相当于把学生的做题节奏考虑进去。第四步预测。把当前要预测的习题向量和对应知识点的聚合向量拼接送进一个两层的MLP输出一个sigmoid值即“该学生答对这道题的概率”。推荐模块拿这个概率去做策略排序。代码实现时用PyTorch Geometric的GCNConv就能很方便地复现这两层图卷积项目里常见的工程简化做法是用知识概念图做主干、把交互特征拼到输入里参与预测。原版的交互图如果做全量显存压力太大见第5章避坑。3. 数据处理与交互图构建ASSISTments从CSV到图结构的落地脚本3.1 数据集选择与字段说明skill_builder.csv里有什么做GIKT最常用的公开数据集是ASSISTments 2009-2010的skill_builder_data.csv你写毕设的话直接网上搜ASSISTments公开数据集就能下载到。这个CSV每行是一条学生答题记录关键字段就五个user_id、problem_id、skill_id、correct、timestamp。correct是0/1答对标记timestamp是毫秒级时间戳用来保持答题序列顺序。另有两个字段比较容易被忽略ms_first_response是学生第一次作答的毫秒数overlap_time是前后两次答题的重叠时间这两个在GIKT里能作为交互特征。数据拿到手先别急着喂模型。原始文件大概有50万行以上里面有大量重复行同一学生对同一题的多次尝试还有skill_id为空的行。我一般写三个预处理步骤全部用pandas就能搞定import pandas as pd df pd.read_csv(skill_builder_data.csv, encodinglatin-1) df df[[user_id, problem_id, skill_id, correct, timestamp, ms_first_response, overlap_time]] # 1) 去掉关键字段缺失的行转成朴素而稳定的数值类型 df df.dropna(subset[skill_id, problem_id, correct]) df df[df[correct].isin([0, 1])] df[skill_id] df[skill_id].astype(int) # 2) 过滤冷启动样本答题少于10次的学生、被答题少于5次的习题直接删 user_size df.groupby(user_id).size() prob_size df.groupby(problem_id).size() df df[df[user_id].isin(user_size[user_size 10].index)] df df[df[problem_id].isin(prob_size[prob_size 5].index)] # 3) 重编号pandas的category codes会把user_id/prob/skill压成从0开始的稠密索引 df[user_id] pd.Categorical(df[user_id]).codes df[problem_id] pd.Categorical(df[problem_id]).codes df[skill_id] pd.Categorical(df[skill_id]).codes # 4) 真正的时间维度同一个学生做题按timestamp升序排 df df.sort_values([user_id, timestamp], kindmergesort).reset_index(dropTrue) print(df.shape) print(df[skill_id].nunique(), 个知识点)这里的过滤逻辑看着粗暴但很必要。答题少于10次的学生GIKT的图卷积根本聚合不到有效邻居学出来的嵌入基本是噪声被答题少于5次的习题属于长尾中的长尾推荐系统里可以留到上线后再处理训练阶段剔掉能让模型收敛快很多。重编号那一步特别重要因为图神经网络的节点索引必须是连续的整数你原CSV里skill_id可能是35、401这样稀疏的编号直接塞进Embedding层会浪费大量内存。3.2 清洗与划分按时间切片避免数据泄漏划分训练集和测试集是这套方案最容易翻车的地方。新手最容易犯的错误是随机切分——同一个学生的答题记录随机散落在train和test里模型在训练时已经“见过”了测试序列的一部分测试AUC会虚高好几个点答辩时老师一问就露馅。正确的做法是按学生分组、按时间切片# 每个学生取最后20%的作答记录作为测试集 test_mask df.groupby(user_id).apply( lambda x: x.index[int(len(x) * 0.8):] ).explode().values df[split] train df.loc[test_mask, split] test # 训练集中再切10%作为验证集用于early stopping val_mask df[df[split] train].groupby(user_id).apply( lambda x: x.index[int(len(x) * 0.85):] ).explode().values df.loc[val_mask, split] val print(df[split].value_counts())注意切分粒度必须到学生级别也就是代码里先groupby(user_id)再切。训练时GIKT在一个batch里看到多个学生如果学生A的测试序列混进了训练batch模型相当于开了天眼。另外验证集务必要跟测试集一样按时间后切不能随机抽样否则early stopping选的模型会有偏。划分完之后把训练集里每个学生的输入序列整理成模型需要的格式每个时间步的特征包括习题ID、知识点ID、当前答题是否正确。GIKT在推理时是一次输入一个学生的完整历史序列当前要预测的那道题的“是否正确”是未知的所以特征里要区分“历史答题”和“待预测题”。3.3 构建概念共现图一组边、自环与归一化的细节知识概念关系图的构建是最能体现工程细节的地方。我的做法是把同一道题涉及的所有知识点两两连边。比如一道题同时考察“因式分解”和“一元二次方程”那这两个概念之间就来一条无向边。一共多少道题没关系我们只关心知识点对。同时每个知识点必须加自环否则GCN第一层聚合时自己节点的信息反而丢了。import torch from torch_geometric.utils import add_self_loops, degree def build_concept_graph(df, num_concepts): # 统计每道题涉及的知识点列表 prob_skills df.groupby(problem_id)[skill_id].unique() edges set() for skills in prob_skills: s sorted(int(x) for x in skills) for i in range(len(s)): for j in range(i 1, len(s)): edges.add((s[i], s[j])) edges.add((s[j], s[i])) edge_index torch.tensor(list(edges), dtypetorch.long).t().contiguous() # 加自环让节点在GCN聚合时保留自身信息 edge_index add_self_loops(edge_index, num_nodesnum_concepts)[0] return edge_index edge_index build_concept_graph(train_df, num_conceptsdf[skill_id].nunique()) print(概念图边数:, edge_index.size(1))构建边集合用Python的set天然去重避免重复边导致GCN里的归一化系数算错。add_self_loops之后GCNConv内部会自动做对称归一化不需要你手动再算一次拉普拉斯算子。有一点要提醒如果两道题共享的知识点很多共现图会变成一个稠密团GCN聚合时所有节点向量趋向一致也就是过平滑。应对办法是在统计共现时设置一个频次下限——某个知识点对至少在3道题里共同出现才建边把这个过滤条件加到上面代码的edges.add前即可。4. 模型与推荐实现PyTorch代码骨架、训练流程与推荐策略4.1 GIKT模型类GCN两层、习题嵌入与概念嵌入融合有了图结构模型代码其实不长。我下面给的这个版本是在原版GIKT基础上的工程简化用知识概念图做主干把习题嵌入和概念嵌入融合再用两层GCN卷积。如果你用PyTorch Geometric核心模型类长这样import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GCNConv class GIKT(nn.Module): def __init__(self, num_questions, num_concepts, hid_dim128, dropout0.5): super().__init__() self.q_emb nn.Embedding(num_questions, hid_dim) # 习题嵌入 self.c_emb nn.Embedding(num_concepts, hid_dim) # 知识点嵌入 self.gcn1 GCNConv(hid_dim, hid_dim) self.gcn2 GCNConv(hid_dim, hid_dim) self.dropout nn.Dropout(dropout) self.fuser nn.Sequential( nn.Linear(hid_dim * 2, hid_dim), nn.ReLU(), nn.Linear(hid_dim, 1) ) def forward(self, q_ids, c_ids, edge_index): # 图节点特征来自知识点嵌入习题嵌入单独保留 node_feat self.c_emb.weight h F.relu(self.gcn1(node_feat, edge_index)) h self.dropout(h) h F.relu(self.gcn2(h, edge_index)) # 经过聚合的知识点向量 # 当前习题的嵌入加上其关联知识点的聚合向量拼接后预测 q_emb self.q_emb(q_ids) # [batch, hid] c_agg h[c_ids] # [batch, hid] x torch.cat([q_emb, c_agg], dim-1) # [batch, hid*2] p torch.sigmoid(self.fuser(x)).squeeze(-1) # [batch] return p这里面有几个参数值得说明。hid_dim128是嵌入维度毕设场景128够用太大容易过拟合且显存压力大dropout0.5在GCN之间加防止图卷积把噪声也聚合进来。习题嵌入和图卷积的出发点不同习题嵌入负责分辨“这是哪道题”GCN负责输出“这道题现在在这个学生脑中被理解成什么样了”。所以二者用拼接而不是相加信息不混叠。4.2 训练循环BCE损失、Adam参数与早停设置训练目标很简单就是二分类交叉熵。但要注意的是dataset构造方式和batch策略——每个学生是一条变长序列全部pad成一样长太浪费算力。常见做法是把训练集切成多个等长片段比如每个batch包含若干个学生最近20次答题记录超过20步的截断不足20步的前面补0掩码。PyTorch自带的pack_padded_sequence在GNN场景下用不上因为图卷积的batch维度是节点维度。from torch.utils.data import DataLoader def collate(batch): # batch里每个元素是 (q_ids, c_ids, label, edge_index) q torch.stack([item[0] for item in batch]) c torch.stack([item[1] for item in batch]) y torch.stack([item[2] for item in batch]) edge batch[0][3] # 概念图在整个训练集是共享的 return q, c, y, edge model GIKT(num_questions, num_concepts, hid_dim128) opt torch.optim.Adam(model.parameters(), lr2e-3, weight_decay1e-5) scheduler torch.optim.lr_scheduler.StepLR(opt, step_size10, gamma0.5) for epoch in range(30): model.train() total_loss, cnt 0, 0 for q, c, y, edge in loader: pred model(q, c, edge) loss F.binary_cross_entropy(pred, y.float()) opt.zero_grad() loss.backward() opt.step() total_loss loss.item() * y.size(0) cnt y.size(0) scheduler.step() print(fepoch {epoch1}: loss{total_loss/cnt:.4f})lr2e-3配合StepLR每10轮降一半是GNN类项目里比较稳的组合。学习率再高的话GCN部分会震荡再低的话影响嵌入层快速收敛。weight_decay1e-5只对角动量温和约束就够了太大模型会欠拟合。早停的标准我用验证集AUC连续3轮不涨就停防止30轮后过拟合。4.3 推荐策略最近发展区、探索系数与Top-N输出模型训练完推荐策略是另一层设计空间。最傻的做法是把预测答对概率从高到低排序推荐——但学生全做对的题再推一遍没有学习价值真正有教育意义的是维果茨基说的“最近发展区”推荐那些答对概率在0.4到0.7之间的题即“有一定基础但还没完全掌握”。我的推荐函数长这样def recommend_topk(model, stu_history, candidate_questions, k10, epsilon0.1): model.eval() with torch.no_grad(): probs model(stu_history[q_ids], candidate_questions[c_ids], edge_index).cpu().numpy() # 兴趣分离0.5越近越值得推荐 scores 1.0 - np.abs(probs - 0.5) candidates candidate_questions[q_ids].numpy() top_indices np.argsort(scores)[::-1][:k] if np.random.rand() epsilon: # 小概率随机探索避免信息茧房 top_indices np.random.choice(len(candidates), k, replaceFalse) return candidates[top_indices]scores 1 - |p - 0.5|是把概率转成兴趣分p0.5时得分最高。这比直接拿p排序好在三点不推荐已经掌握的高分题不推荐完全不会的低分题给中等难度的题更高曝光。epsilon0.1是典型的探索-利用权衡每十次推荐至少一次随机选让系统有机会探索题库里冷门的边缘知识点也避免推荐结果永远集中在一个小范围。如果你做的方向更偏教育心理可以把这个策略跟历年错题本结合只从错题涉及的知识点里选候选集效果在演示时更有说服力。5. 避坑与排查AUC虚高、显存爆炸、冷启动等5个常见问题5.1 现象测试AUC稳定在0.63不再上涨比论文里差一大截原因最大的嫌疑是数据划分方式不对。测试集里混进了同一学生的历史交互或者训练集和测试集之间没有按时间隔离学生后来的答题记录被用来训练了等于“提前剧透”了答案。另一个常见原因是timestamp没有参与排序数据顺序是乱的LSTM和GNN都学不到序列模式。解决严格按第3.2节的做法先按user_id分组、组内按timestamp排序再切时间窗口。切完之后检查一下测试集中每个学生的第一条记录是否在时间上都晚于训练集最后一条。另外把AUC低于0.65的epoch的样本拿出来看下是不是有一堆correct1的题被预测成0.5这通常意味着图嵌入没收敛可以把训练轮数拉长或把学习率降到5e-4。5.2 现象训练到一半显存OOM原因GIKT的交互图如果显式构建每个学生历史序列里每一步答题都是一个节点几百学生的batch一拼边数直接上百万显卡根本扛不住。解决三个手段组合用。一是加大batch_size的反向操作——减小batch32个学生一个batch二是历史序列截断保留最近15次答题不要贪长三是不建全量交互图把交互特征答题对错、反应时间拼接到当前预测的特征向量里替代显式的交互图卷积。这是毕设里很常见的简化处理效果损失不大但工程难度直线下降。5.3 现象loss一直降推荐命中率却不升原因loss是逐个样本的BCE它衡量的是“答对概率预测得准不准”而推荐任务衡量的是“推荐列表里有多少题是学生真实想做的”。两者相关性没有你想象的高尤其是正确答案占比高的数据集——全预测1也能拿到不错的AUC但推荐毫无区分度。解决评估指标一律以HR10和NDCG为准不要在论文里只放AUC。调参时按测试集的HR10选模型不要按loss选。如果HR10一直不涨回去看推荐候选集范围——如果候选集里全是简单题系统只能矮子里拔将军。5.4 现象冷启动学生答题记录很少的预测全落在0.5附近原因GCN聚合邻居信息时一个只做过3道题的学生在交互图上几乎没有可聚合的邻居节点特征退化到初始随机嵌入sigmoid的输出自然倾向0.5。解决给嵌入层一个知识先验把每个知识点在训练集上的历史平均正确率作为嵌入初始化的一部分而不是纯随机初始化。这样冷启动学生的预测会向该知识点的全局统计回归。另一个讨巧的方法是推荐策略兜底——检测到交互数小于阈值时直接走“该学生所在年级的必学知识清单出错率最高的知识点”的规则推荐不依赖模型。5.5 现象构建概念图的脚本跑了半小时还在转原因用df.groupby(problem_id)[skill_id].unique()拿到的知识点列表在双重循环里逐对建边如果一道题挂10个知识点那就是100层嵌套。ASSISTments里最大的一道题能关联十几个技能循环量爆炸。解决建边上并行的最有效套路不是循环而是用itertools.combinations展开后再一次性建集合。另外对共现频次加阈值低频知识点对直接丢弃边数能少一个数量级。如果还嫌慢就先用numpy对skill_id做one-hot矩阵乘法一次算出所有概念对的共现矩阵取上三角再转边列表这一步从循环改矩阵运算能快几十倍。6. 验证与进阶从AUC到HR10再往可解释性方向扩6.1 评估一套完整的指标组AUC、ACC、HR10、NDCG答辩时只报一个AUC是站不住脚的建议按“预测精度推荐质量”两组指标来报。预测精度用AUC排序能力和ACC阈值0.5时的准确率推荐质量用HR10推荐列表前10里有多少是学生真实做对的题和NDCG推荐列表中正确答案排位是否靠前。我这边跑出来的结果长这样配置不同数值会有浮动仅供参考。配置AUCHR10随机推荐0.500.21DKT仅LSTM序列0.730.30GIKT概念图交互特征0.810.436.2 验证方法留一交互交叉验证除了常规的train/val/test划分推荐系统方向还流行一种“留一交互”验证每个学生的最后一次做题记录作为测试前面的全做训练。这种方式更接近真实场景——“预测学生下一题能否做对”。实现时把第3.2节的划分代码稍改一下每个学生取最后一条记录做test就行。6.3 进阶方向难度参数、遗忘曲线与可解释输出如果你的毕设想冲高分有两条扩展路径是论文里很吃香的。一是引入IRT中的题目难度参数把习题的答题正确率作为难度嵌入与习题嵌入拼接让推荐策略能区分“不会做的简单题”和“不会做的难题”前者补基础、后者做挑战。二是接入遗忘曲线根据距上次练习同一个知识点的间隔天数做时间衰减权重间隔越长、对当前掌握度预测的贡献越小——这解决的正是GIKT里“历史答题一直等权参与聚合”的短板。我自己的教训是做这个系统时最吃亏的一点是前期只盯AUC调参等到论文要写推荐效果时才发现缺HR10和NDCG临时补数据又跑了一轮实验白白浪费两周。给学弟学妹的建议是第一天就按预测、推荐两类指标各挂一个TensorBoard跟loss一起看着训练。这样你才会对“模型学得好不好”和“推荐推得好不好”分别有数也才能在答辩时讲清楚GIKT在哪个环节真正贡献了性能。这套方案整体链路清晰、改动点看得见摸得着值得你投进去做深。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站