
简介基于GIKT深度知识追踪框架的个性化习题推荐系统是一个完整可运行的Python工程专为计算机科学与技术、人工智能等相关专业的毕业设计或期末大作业设计。系统利用深度学习技术对学习者的历史答题序列进行知识状态建模动态预测其对各知识点的掌握程度据此生成个性化习题推荐列表同时涵盖数据预处理、模型训练、推荐算法、结果可视化等环节并附带详细操作指南项目已通过学术导师审核并获优秀评价。资源包共72个文件以20个Python源码、13个Vue前端组件和7个JavaScript脚本为主辅以SQL数据库、NumPy数据文件、JSON配置及说明文档等整体约10.97MB前后端分离的工程结构便于直接运行与二次开发。目前已有58人学习下载对于教育数据挖掘研究而言这是可复现的实验基准也是理解GIKT模型落地细节的实用范例。1. 从 DKT 到 GIKT习题推荐为什么需要“图”这层信息学生刚做错一道应用型大题平台紧接着推三道同知识点、同难度的题结果三道全错——这种“表面推荐”几乎是每个刷题产品的通病。GIKTGraph-based Interaction Knowledge Tracing就是把图结构引入深度知识追踪的代表性方案它最终服务的场景正是个性化习题推荐系统。传统的 DKT 把每道习题当独立 tokenLSTM 只从“有没有答对”里更新隐状态题目之间完全不通气某道冷门题在训练集里只出现几十次时嵌入基本是噪声。GIKT 则先把习题相似关系建成无向图用图卷积对习题嵌入做跨题目信息融合再跑 RNN 序列建模。这套流程的 Python 实现既绕不开图构建也绕不开序列建模和推荐排序下面按这个链路拆开讲。2. 习题相似度图的构建边权重、剪枝与归一化是 GIKT 的前置依赖2.1 两种建图方式知识点共现与题干文本相似度真正常用的是哪一种GIKT 的输入图可以基于两种关系构建。第一种是知识点共现习题 A 覆盖了「一元二次方程」和「判别式」习题 B 也覆盖了「一元二次方程」那 A 和 B 就有一条边权重等于共享知识点数量也可以归一化成 Jaccard 系数。这种图完全由元数据生成冷启动阶段没有题干文本也能直接产出缺点是只有共享显式知识点才能连通像「二次函数」和「一元二次方程」这种前后置关系很容易丢。第二种是用题干文本算相似度。先对题干做清洗再用 TF-IDF、Sentence-BERT 或更轻量的词向量均值池化得到表征最后算余弦相似度。它的优势是能发现没有共享标注但描述相近的题语言类、情境化理科题尤其明显。代价是文本向量质量取决于清洗效果数学公式和符号混排经常让相似度虚高。我一般建议冷启动阶段以知识点共现为主等积累了几万份真实答题日志再把文本相似度以 0.30.5 的权重融合进去。两个矩阵的量纲不同融合前都得先做行归一化否则相似度数值大的那一路会主导整个邻接矩阵。2.2 建图、KNN 剪枝与对称化的 Python 实现实际工程里不可能把全部习题两两连边全连接图在 GCN 层数加深后会出现过平滑所有节点表征趋于一致。更常见的做法是保留每个节点相似度最高的 K 条边再做对称化。下面的代码把两种相似度矩阵合并成一个邻接矩阵import numpy as np from sklearn.metrics.pairwise import cosine_similarity n_ex 2000 # 习题总数 cpt_mat np.random.randint(0, 2, (n_ex, 20)) # 习题-知识点矩阵0/1 # 知识点 Jaccard 相似度共享知识点越多相似度越高 cpt_sim cpt_mat cpt_mat.T row_sum np.sum(cpt_mat, axis1, keepdimsTrue) denom (row_sum row_sum.T - cpt_sim) 1e-8 jaccard cpt_sim / denom # 用真实题干向量替换 text_embedding 即可得到文本相似度矩阵 text_sim cosine_similarity(text_embedding) # text_embedding: (n_ex, d) # 融合成权重矩阵w1 w2 保持为 1 adj 0.6 * jaccard 0.4 * text_sim np.fill_diagonal(adj, 0.0) # KNN 剪枝每个节点只保留相似度最高的 K 个邻居 K 5 idx np.argsort(-adj, axis1)[:, :K] # 每行取 TopK 下标 mask np.zeros_like(adj) for i in range(n_ex): mask[i, idx[i]] 1.0 adj_knn mask * adj # 对称化任一端认为对方是邻居就保留这条边 adj_sym np.maximum(adj_knn, adj_knn.T) # 加自环GCN 传播时才不会丢失节点自身特征 adj_sym adj_sym np.eye(n_ex)K 的取值直接决定图的密度。K 小于 3 会切断大量有效关联图卷积退化成按独立小团体传播K 大于 20 时边权重均值下降消息传递被过度均衡化深层 GNN 的信息增益消失。我在 20005000 题的题库里一般取 510题库过万时每节点 1015 条边更能缓解稀疏问题。2.3 对称归一化与自环对训练稳定性的影响邻接矩阵不归一化直接喂进 GCN高度数节点经常与其它题共现的题目的梯度更新幅度会远大于低度数节点loss 抖动非常明显。工程上要用对称归一化的标准做法# 对称归一化: D^{-1/2} A D^{-1/2} deg adj_sym.sum(axis1) 1e-8 d_inv_sqrt np.diag(1.0 / np.sqrt(deg)) adj_norm d_inv_sqrt adj_sym d_inv_sqrt # 转换为 PyTorch 张量后续模型直接使用 adj_tensor torch.tensor(adj_norm, dtypetorch.float32)对称归一化让度高节点不会在聚合时淹没邻居信息。这个矩阵只在训练前计算一次如果题库上线、下线题目必须重新建图。自环的作用是兜底让节点至少保留自己的原始嵌入这对只出现几次的新题非常关键。邻接矩阵相当于 DKT 之外的先验知识应当保持稳定。除非题库结构大改否则不要每次训练都重新生成图否则模型复现会很困难。3. GIKT 的 Python 实现图卷积嵌入与序列知识状态更新3.1 从输入到输出的张量形状先把维度关系理清实现 GIKT 我会先画一遍张量图。学生交互记录被切成长度为 T 的序列一条样本包含 习题ID、知识点ID、答题对错三个输入都是 (B, T) 整数张量。模型先把习题嵌入矩阵取出来做图卷积增强得到 (n_ex, D) 的表征序列模块的输入是「增强习题嵌入 知识点嵌入 上一题对错标志」拼成的 (B, T, 2D1) 向量GRU 处理后输出 (B, T, H)。在时间步 t 预测第 t1 题的答对概率时把 h_t 与 t1 题经图卷积增强的嵌入拼起来过全连接层。推荐阶段直接用最后一个时间步的 h_t 作为学生当前知识状态。下面这段代码按 GIKT 的思路做了裁剪保留了图卷积和 GRU 两个核心模块本身可以直接跑通训练。3.2 图卷积层与核对齐GIKT 的 3 个核心代码段图卷积层实现的就是标准 GCN 单层操作邻居聚合、线性变换、非线性激活。import torch import torch.nn as nn import torch.nn.functional as F class GraphConvLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.fc nn.Linear(in_dim, out_dim, biasFalse) def forward(self, x, adj): # x: (n_nodes, in_dim)adj: (n_nodes, n_nodes) agg torch.mm(adj, x) # 聚合邻居嵌入 return F.relu(self.fc(agg))torch.mm(adj, x)把每个节点的邻居特征按权重求和。题库上万时稠密矩倲乘法会吃掉数百 MB 内存建议把 adj 转成 scipy 稀疏矩阵再用torch.sparse.mm。核对齐是 GIKT 最容易踩坑的地方。图卷积后ex_emb的索引顺序必须和邻接矩阵的行序一一对应任何洗牌都会导致图信息错位。class GIKT(nn.Module): def __init__(self, n_ex, n_cpt, emb_dim128, hid_dim256, dropout0.2): super().__init__() self.emb_dim emb_dim self.ex_emb nn.Embedding(n_ex, emb_dim) # 习题嵌入 self.cpt_emb nn.Embedding(n_cpt, emb_dim) # 知识点嵌入 # 两层图卷积输出维度不变 self.gcn nn.Sequential( GraphConvLayer(emb_dim, emb_dim), GraphConvLayer(emb_dim, emb_dim), ) # GRU 省显存效果与 LSTM 基本持平 self.gru nn.GRU(emb_dim * 2 1, hid_dim, batch_firstTrue) self.predict_fc nn.Sequential( nn.Linear(hid_dim emb_dim, hid_dim), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hid_dim, 1), ) def set_adj(self, adj_tensor): # 邻接矩阵在外部算好再注入模型 self.adj adj_tensor def forward(self, ex_seq, cpt_seq, mask, y_seq): # ex_seq / cpt_seq / y_seq: (B, T)mask: (B, T) B, T ex_seq.shape # 1. 图卷积增强习题嵌入 gcn_out self.gcn(self.ex_emb.weight, self.adj) # (n_ex, D) e_t gcn_out[ex_seq] # (B, T, D) c_t self.cpt_emb(cpt_seq) # (B, T, D) # 2. 上一题的答对结果拼进当前输入避免信息泄漏 y_prev torch.zeros(B, T, 1) y_prev[:, 1:, 0] y_seq[:, :-1] x torch.cat([e_t, c_t, y_prev], dim-1) # (B, T, 2D1) # 3. 序列建模 h, _ self.gru(x) # (B, T, H) # teacher forcing用当前隐藏状态预测下一题答对概率 future_e gcn_out[ex_seq[:, 1:]] # (B, T-1, D) h_cur h[:, :-1, :] # (B, T-1, H) logits self.predict_fc(torch.cat([h_cur, future_e], dim-1)) logits logits.squeeze(-1) # (B, T-1) # 4. 对齐 mask最后一步没有预测目标 loss_mask mask[:, 1:].float() target y_seq[:, 1:].float() return logits, target, loss_masky_prev是最容易写错的地方。忘记右移一位等于把当前答案直接喂回下一步预测会造成严重的数据泄漏AUC 会异常偏高。GRU 的输入通道数是2D1如果知识点粒度很粗一门课不到 30 个知识点可以去掉c_t退化成纯习题建模。更常见的折中做法是给c_t一个可学习投影缩到 D/2 维再拼接。3.3 损失函数、AUC 与训练参数的建议训练用带 mask 的交叉熵评估用 AUCfrom sklearn.metrics import roc_auc_score def gikt_loss(logits, target, loss_mask): loss F.binary_cross_entropy_with_logits(logits, target, reductionnone) return (loss * loss_mask).sum() / (loss_mask.sum() 1e-8) # epoch 结束时统一计算 AUC比在线累加稳定 flat_logits torch.sigmoid(logits.detach()).cpu().numpy()[loss_mask.cpu().numpy() 0] flat_targets target.cpu().numpy()[loss_mask.cpu().numpy() 0] auc roc_auc_score(flat_targets, flat_logits)常用参数组合emb_dim128hid_dim256GCN 两层dropout0.2Adam 学习率 1e-3batch_size64序列长度截断到 32。20 个 epoch 内基本收敛。如果 AUC 明显偏低先查序列对齐再查建图质量最后才调网络宽度。GIKT 对数据泄漏很敏感预测 t1 题时任何来自 t1 时刻的信息进入序列都会把 AUC 拉到 0.85 以上这种异常值基本可以认定是实现错误。4. 基于 GIKT 预测值的个性化习题推荐候选召回、难度匹配与 Top-K4.1 从知识状态到候选题集三阶段召回GIKT 给推荐系统的核心输出是学生当前的隐状态 h_t配合题库里任意一道题经图卷积增强的嵌入就能算出学生当前答对这道题的概率。推荐的第一步是缩小候选题集避免对全部习题打分。常用的召回策略有三条。第一是知识点召回把学生最近 N 次答题中正确率低于阈值的知识点找出来覆盖这些知识点的题目全部进候选。第二是相似习题召回取当前最近做错的题在习题图上找它的 TopK 邻居这类题与错题表述相近正好检验学生是会做了还是背下了原题。第三是难度阶梯召回预先剔除预测正确率过低或过高的题让候选集集中在学生当前能力边界附近。4.2 正确率预测打分与难度区间选择下面给出一个能直接改用的推荐函数。encode_history在代码里省略本质是把学生历史序列过一次模型的 GRU返回最后一步 hdef recommend_topk(model, h_last, candidate_pool, k10): model.eval() with torch.no_grad(): N len(candidate_pool) pool_t torch.tensor(candidate_pool) gcn_out model.gcn(model.ex_emb.weight, model.adj) pool_emb gcn_out[pool_t] # (N, D) h_expand h_last.repeat(N, 1) # (N, H) logits model.predict_fc(torch.cat([h_expand, pool_emb], dim-1)) probs torch.sigmoid(logits).squeeze(-1) # (N,) # 过滤出处于最佳学习区的题目 cand_mask (probs 0.35) (probs 0.85) selected candidate_pool[cand_mask] selected_probs probs[cand_mask] order torch.argsort(selected_probs, descendingTrue) return selected[order[:k]].tolist(), selected_probs[order[:k]].tolist()候选池再大学生也只有一个 h_last打分速度只取决于一次矩阵乘法和一次全连接。predict_fc接的是「隐藏状态 候选题目增强嵌入」的拼接与训练时的打分逻辑保持了一致。难度区间 0.350.85 是我在多个在线教育场景里常用的初始值低于 0.3 的题基本做不出推给学生只会打击信心高于 0.9 的题全部答对无法带来能力增长。上线后可以把边界设成可配置项A/B 实验里分别测 0.20.9 和 0.350.85观察平均作答时长和连续作答轮次再收敛。4.3 保证学习效率知识点覆盖与连续去除纯概率排序会暴露一个明显问题同一个知识点下的两道相似题会挤满 TopK。连续推荐两道完全同构的题对学习没有增量。我在排序后会加一个去重阶段def diversify(selected_ex, prefs, k10, same_cpt_max2): # prefs: 每道题的知识点列表 out [] cpt_count {} for ex, p in zip(selected_ex, prefs): c p[0] # 简化处理取第一个知识点 if cpt_count.get(c, 0) same_cpt_max: out.append(ex) cpt_count[c] cpt_count.get(c, 0) 1 if len(out) k: break return out更进一步可以加间隔重复机制三天内同一个知识点下推过的题直接移出候选。GIKT 的隐状态本身已经包含时间信息这个去重只作用在产品层避免刷屏不影响模型打分。离线打分链路跑通之后剩下的工作就是把它接到推荐接口里以及设计一套验证指标。5. 冷启动下的 GIKT 推荐图先验的三大用法与离线验证技巧5.1 新学生与新题的冷启动处理新学生没有任何答题序列GRU 隐状态是零向量推荐退化成热门题兜底。但有了习题图新学生注册后可以先推一组相互相似度高的题用前几次答题快速把隐状态拉到有效区域。新上线题目同样没有答题记录它的增强嵌入由 KNN 邻居聚合生成——邻居全是有充分答题数据的高频旧题新题的嵌入就有一个合理初值。这两个问题在纯 DKT 里都得等行为数据积累GIKT 在图卷积这一步就把同类题的信息转移给了新节点。5.2 预测未来 10 次答题的离线 AUC 验证常规知识追踪评估只预测下一个交互离推荐系统的目标太近。更贴近业务的验证是滚动回测取一段连续答题序列的前半部分训练模型从第 k 步开始每一步都用当前已有的序列预测未来 10 道题的对错计算这个窗口的 AUC。def evaluate_rolling_auc(model, seq, horizon10): # seq: 按时间排序的 (ex_id, cpt_id, y) 列表 aucs [] for split in range(30, len(seq) - horizon, 5): history seq[:split] future seq[split:split horizon] # 编码 history 得到 h_last h_last encode_history(model, history) # 对 future 内每道题与 h_last 拼接打分 ex_future torch.tensor([x[0] for x in future]) gcn_out model.gcn(model.ex_emb.weight, model.adj) pool_emb gcn_out[ex_future] logits model.predict_fc( torch.cat([h_last.repeat(len(future), 1), pool_emb], dim-1) ) preds torch.sigmoid(logits).squeeze(-1).numpy() labels np.array([x[2] for x in future]) if len(set(labels)) 2: aucs.append(roc_auc_score(labels, preds)) return float(np.mean(aucs))滚动 AUC 随 t 增大而上升说明推荐内容确实在推进掌握度如果持平甚至下降多半是系统一直在推同一难度区间的题模型没有从新回答中获得信息增益。5.3 三个决定成败的超参调参结论上GIKT 对三个超参最敏感。一是 GCN 层数两层是上限三层以上几乎必现过平滑二是 KNN 剪枝的 K510 对大多数题库适用K 太小图卷积退化成查表三是序列长度64 以上的长序列能利用远距离依赖但 padding 过多拖慢训练我一般平衡在 3248。这三个值分别对应图密度、表示深度和时序长度调好后 GIKT 的 AUC 和推荐效果基本能稳定到可上线状态。本文还有配套的精品资源点击获取