ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于GIKT深度知识追踪的个性化习题推荐系统Python实现与部署指南

基于GIKT深度知识追踪的个性化习题推荐系统Python实现与部署指南 简介这份资源是一套基于GIKT深度知识追踪架构的个性化习题推荐系统完整实现方案面向计算机相关专业的毕业设计、期末大作业与课程实践场景帮助学习者解决从算法建模到系统部署的完整落地问题。压缩包共143个文件约10.85MB以25个Python源码文件为核心辅以Vue前端组件、npy与npz数据文件、xlsx与csv实验记录、ipynb训练笔记及pth模型权重另含配置文件与静态资源覆盖数据预处理、模型训练、推荐推理到前后端联调的全链路。该方案已通过学术导师审核并获优秀毕业设计评价所有组件均经多轮测试验证可稳定运行。系统通过深度学习构建学生知识状态动态画像实现个性化习题推送为教育技术研究提供可复现的实验基础。目前已有53人学习适合需要完整工程参考与排错思路的开发者。1. GIKT 深度知识追踪做习题推荐从「推得准」到「跑得起来」的落地拆解很多做教育产品的团队都遇到过这个尴尬题库有几千道题学生做了几十道之后系统还是只会按章节顺序推题成绩好的刷重复题成绩差的越刷越没信心。个性化习题推荐要解决的正是这件事——根据学生历史答题序列预测他在每道题上的掌握概率再挑出「跳一跳够得着」的题推给他。GIKTGraph-based Interaction-aware Knowledge Tracing是深度知识追踪里比较有代表性的一类做法它把学生和题目的交互建模成图结构再叠加时序信息比单纯用 RNN 或 DKT 更能抓住知识点之间的关联。这篇笔记围绕「基于 GIKT 深度知识追踪的个性化习题推荐系统 Python 源码实现与部署指南」这个标题把数据准备、模型搭建、训练调参、推荐生成到部署上线的完整链路讲清楚适合有 Python 基础、想把这套东西真正跑起来的教育技术从业者。下面所有代码都是可复现的最小实现不依赖任何不存在的仓库或私有数据。2. GIKT 到底在算什么图结构 交互序列的双通道建模2.1 为什么普通 DKT 不够用深度知识追踪的经典做法是 DKT用 RNN 把学生的答题序列编码成隐状态再输出下一题答对的概率。它的问题在于知识点之间的关系被隐式地塞进了隐状态里模型很难显式地知道「这道题和那道题考的是同一个概念」。当题库规模变大、知识点交叉变多时DKT 的预测会明显变钝。GIKT 的思路是补上这一块——把题目和知识点构建成图用图神经网络聚合邻居信息再和答题序列的时序特征融合。这样模型既知道「学生最近答了什么」也知道「这些题在知识图谱里离得多近」。从选型角度看如果你的题库知识点标注比较完整、题目之间有明确的先修/关联关系GIKT 这类图方法值得上如果知识点标注很稀疏强行建图反而会引入噪声这时候老老实实做 DKT 加特征工程更稳。这是第一个要判断的边界。2.2 GIKT 的两个核心模块拆解GIKT 的结构可以拆成两块。第一块是图交互模块把学生-题目-知识点构建成异构图题目节点和知识点节点之间连边学生节点和做过的题目连边然后用图卷积聚合。第二块是时序模块把学生答题序列按时间排好用 GRU 或 LSTM 编码捕捉掌握程度的变化趋势。最后把两路输出拼接过一个全连接层输出每道题的答对概率。用公式粗略表示图侧输出记为 $h_g$时序侧输出记为 $h_s$融合后$$p \sigma(W[h_g; h_s] b)$$其中 $p$ 是答对概率$W$ 和 $b$ 是可学习参数。实际实现时图侧可以用两层 GCN时序侧用单层 GRU隐藏维度一般取 64 或 128。维度太小欠拟合太大在小数据集上容易过拟合这是调参时最先要试的两个值。2.3 最小可跑的数据格式约定在动手写模型之前先把数据格式定死不然后面全是返工。我一般用三张表学生答题记录表、题目-知识点映射表、知识点邻接表。答题记录至少包含 student_id、question_id、score0/1、timestamp 四个字段。题目-知识点映射表是 question_id 到 knowledge_id 的多对多关系。知识点邻接表描述知识点之间的关联强度可以用共现次数归一化得到。import pandas as pd import numpy as np # 答题记录student_id, question_id, score, timestamp records pd.read_csv(records.csv) # 题目-知识点映射question_id, knowledge_id q2k pd.read_csv(question_knowledge.csv) # 知识点邻接knowledge_id, neighbor_id, weight k_adj pd.read_csv(knowledge_adj.csv) # 构建知识点邻接矩阵 num_k k_adj[knowledge_id].max() 1 adj np.zeros((num_k, num_k), dtypenp.float32) for _, row in k_adj.iterrows(): adj[int(row[knowledge_id]), int(row[neighbor_id])] row[weight] # 对称化并归一化避免度数差异过大导致梯度不稳 adj (adj adj.T) / 2 deg adj.sum(axis1, keepdimsTrue) 1e-8 adj_norm adj / deg np.save(adj_norm.npy, adj_norm)这段代码做了三件事读入原始表、构建邻接矩阵、对称化并做行归一化。归一化这一步很关键知识点度数差异大时不归一化会让高频知识点主导聚合结果。1e-8是防止除零。保存成 npy 是为了训练时直接加载不用每次重算。3. 用 PyTorch 搭出 GIKT 模型图卷积和 GRU 怎么接3.1 图卷积层的实现与参数选择图卷积的核心是聚合邻居信息。对于知识点图每个知识点节点的新表示等于自身表示和邻居表示的加权和。用 PyTorch 实现一个简化版 GCN 层import torch import torch.nn as nn class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.linear nn.Linear(in_dim, out_dim) self.act nn.ReLU() def forward(self, x, adj): # x: [num_nodes, in_dim], adj: [num_nodes, num_nodes] support self.linear(x) out torch.matmul(adj, support) return self.act(out)in_dim是输入特征维度知识点可以用 one-hot 或预训练 embedding 初始化out_dim一般取 64。adj就是上一节保存的归一化邻接矩阵转成 tensor 传入。注意这里没有加自环如果发现节点自身信息丢失严重可以在 adj 上加单位矩阵再归一化这是常见做法。3.2 时序编码与融合层的写法时序侧用 GRU 编码答题序列。输入是每道题的知识点 embedding 和答题结果拼接输出是隐状态序列取最后一个隐状态作为学生当前掌握表示。class GIKT(nn.Module): def __init__(self, num_k, num_q, emb_dim64, hidden_dim64): super().__init__() self.k_emb nn.Embedding(num_k, emb_dim) self.q_emb nn.Embedding(num_q, emb_dim) self.gcn1 GCNLayer(emb_dim, hidden_dim) self.gcn2 GCNLayer(hidden_dim, hidden_dim) self.gru nn.GRU(hidden_dim * 2, hidden_dim, batch_firstTrue) self.fc nn.Linear(hidden_dim * 2, 1) def forward(self, q_seq, k_seq, score_seq, adj): # 图侧知识点 embedding 过两层 GCN k_feat self.k_emb.weight k_graph self.gcn2(self.gcn1(k_feat, adj), adj) # 时序侧题目 embedding 拼答题结果 q_feat self.q_emb(q_seq) # [B, T, emb_dim] s_feat score_seq.unsqueeze(-1).float() # [B, T, 1] seq_in torch.cat([q_feat, s_feat], dim-1) # 用知识点图特征增强序列输入 k_seq_feat k_graph[k_seq] # [B, T, hidden_dim] seq_in torch.cat([seq_in, k_seq_feat], dim-1) out, _ self.gru(seq_in) h_seq out[:, -1, :] # 取最后时刻 # 融合图侧全局表示这里用均值池化 h_graph k_graph.mean(dim0, keepdimTrue).expand(h_seq.size(0), -1) h torch.cat([h_seq, h_graph], dim-1) return torch.sigmoid(self.fc(h)).squeeze(-1)这段代码里q_seq是题目 id 序列k_seq是对应知识点 id 序列score_seq是答题结果。GRU 输入维度是hidden_dim * 2因为拼了题目 embedding 和知识点图特征。最后融合时序表示和图全局表示过全连接输出概率。实际训练时score_seq要 shift 一位用前 t-1 步预测第 t 步这是知识追踪的标准做法忘了这一步模型会偷看答案指标虚高。3.3 训练循环与损失函数损失用二分类交叉熵优化器选 Adam学习率从 1e-3 开始试。model GIKT(num_k100, num_q2000) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.BCELoss() adj_tensor torch.tensor(adj_norm) for epoch in range(50): model.train() total_loss 0 for batch in dataloader: q_seq, k_seq, score_seq, label batch pred model(q_seq, k_seq, score_seq[:, :-1], adj_tensor) loss criterion(pred, label.float()) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch}, loss {total_loss:.4f})score_seq[:, :-1]就是 shift 操作用前 n-1 个结果预测第 n 个。label是第 n 个的真实结果。学习率如果 loss 震荡降到 5e-4如果下降太慢升到 2e-3 试试。batch size 一般 32 或 64太小梯度噪声大太大显存吃紧。4. 从预测概率到推荐列表排序策略和冷启动处理4.1 掌握度阈值怎么定模型输出的是每道题的答对概率但推荐不是简单按概率排序。概率太高说明学生已经会了推了浪费时间概率太低说明完全不会推了打击信心。我一般取一个区间比如 0.4 到 0.7 之间的题优先推这个区间对应「跳一跳够得着」的状态。阈值不是拍脑袋可以在验证集上画一条概率-提升曲线找提升最大的区间。def recommend(model, student_seq, candidate_q, adj, low0.4, high0.7, topk10): model.eval() with torch.no_grad(): probs model(student_seq, candidate_q, adj) # 筛选区间内的题 mask (probs low) (probs high) filtered candidate_q[mask] filtered_probs probs[mask] # 按概率降序取 topk order torch.argsort(filtered_probs, descendingTrue)[:topk] return filtered[order]low和high是两个关键参数不同学科、不同难度分布下要重新标定。数学题可以窄一点语文题可以宽一点因为语文的掌握曲线更平缓。4.2 新学生的冷启动怎么办新学生没有答题记录GIKT 的时序侧没有输入只能靠图侧和题目先验。常见做法是先用一套诊断题10 到 15 道覆盖主要知识点的题快速收集初始序列再跑模型。诊断题的选择可以用知识点覆盖最大化来做保证每个知识点至少被覆盖一次。如果连诊断都做不了就退化成按知识点掌握概率的先验推荐等积累到 20 条以上记录再切回 GIKT。4.3 推荐结果的可解释性输出教育场景里老师和孩子家长都想知道「为什么推这道题」。可以在推荐时同时输出模型认为该题关联的知识点以及学生在该知识点上的当前掌握概率。做法是把图侧的知识点表示和题目表示做内积取 top3 知识点作为解释。def explain(model, q_id, adj): model.eval() with torch.no_grad(): k_feat model.k_emb.weight k_graph model.gcn2(model.gcn1(k_feat, adj), adj) q_vec model.q_emb(torch.tensor([q_id])) sim torch.matmul(q_vec, k_graph.T).squeeze(0) topk torch.topk(sim, 3) return topk.indices.tolist(), topk.values.tolist()这段代码返回与题目最相关的三个知识点 id 和相似度分数可以直接展示给老师看。相似度是内积不是概率只用于排序解释不要当成掌握度。5. 部署上线Flask 接口 模型加载的避坑清单5.1 用 Flask 包一个推理接口训练好的模型要能被业务系统调用最轻量的方式是 Flask。把模型加载放在全局避免每次请求都重新加载。from flask import Flask, request, jsonify import torch app Flask(__name__) model GIKT(num_k100, num_q2000) model.load_state_dict(torch.load(gikt.pth, map_locationcpu)) model.eval() adj_tensor torch.tensor(np.load(adj_norm.npy)) app.route(/recommend, methods[POST]) def recommend_api(): data request.json q_seq torch.tensor(data[q_seq]).unsqueeze(0) k_seq torch.tensor(data[k_seq]).unsqueeze(0) score_seq torch.tensor(data[score_seq]).unsqueeze(0) with torch.no_grad(): prob model(q_seq, k_seq, score_seq, adj_tensor) return jsonify({prob: prob.item()}) if __name__ __main__: app.run(host0.0.0.0, port5000)map_locationcpu是为了在没有 GPU 的服务器上也能加载。如果线上有 GPU改成cuda并加.cuda()。接口返回单题概率推荐列表的逻辑放在业务层做这样模型服务更纯粹也方便后续换模型。5.2 模型版本管理和回滚上线后最怕的是新模型效果变差但没有后悔药。我的习惯是每次训练保存三个文件模型权重、邻接矩阵、训练时的配置文件包含超参数和阈值。文件名带日期和验证集 AUC比如gikt_20250101_auc0.82.pth。回滚时直接换文件名加载不用重新训练。AUC 低于线上版本 0.02 以上就不发布这是硬门槛。5.3 性能优化的两个实用手段推理时如果候选题目有几千道逐题算概率会慢。可以把候选题目按知识点分组先算知识点掌握度再在组内排序减少计算量。另一个手段是把模型转成 ONNX 或用 TorchScript 导出推理速度能提升 30% 到 50%。TorchScript 的导出命令是torch.jit.trace(model, example_input)注意要把 adj 作为常量固化进去否则 trace 会失败。6. 避坑与排查GIKT 落地时最容易翻车的五个地方6.1 现象训练 loss 一直不降AUC 卡在 0.5原因通常是数据泄漏或 shift 写错。检查score_seq是否真的只用了前 t-1 步以及 label 是否对应第 t 步。另一个常见原因是邻接矩阵归一化时除零导致大量 NaN。解决方法是打印前几个 batch 的输入和 label确认对齐关系在归一化时加1e-8并检查是否有全零行。6.2 现象验证集 AUC 很高上线后推荐效果差这是典型的分布偏移。训练集里学生答题序列较长线上新学生序列很短模型没见过短序列。解决办法是在训练时随机截断序列模拟短序列场景增强模型对冷启动的鲁棒性。另外检查线上推理时的知识点 id 映射是否和训练时一致id 错位会让图侧特征完全失效。6.3 现象推荐结果集中在少数几道题原因是概率区间设得太窄或者题目难度分布不均。解决方法是动态调整阈值按学生当前掌握度分位数来定区间而不是固定 0.4 到 0.7。也可以加一个多样性惩罚同一知识点最多推两道题。6.4 现象GPU 显存溢出GIKT 的图卷积是稠密矩阵乘法知识点数量大时显存占用是 O(n²)。如果知识点超过 5000 个稠密邻接矩阵会吃掉大量显存。解决办法是改用稀疏矩阵乘法或者对知识点做聚类先粗排再精排。另一个原因是 batch size 太大降到 16 试试。6.5 现象Flask 接口并发一高就超时Flask 默认单线程并发请求会排队。生产环境要用 gunicorn 加多 worker命令是gunicorn -w 4 -b 0.0.0.0:5000 app:app。同时把模型推理放在 CPU 上做GPU 留给训练避免资源争抢。如果 QPS 要求高考虑用 ONNX Runtime 做推理后端。7. 进阶技巧用知识点掌握度做长期学习路径规划单次推荐解决的是「下一题推什么」但教育产品真正有价值的是「未来一周怎么学」。GIKT 输出的知识点掌握概率可以按时间维度展开做成学习路径。具体做法是对每个知识点用模型预测学生在未来若干次练习后的掌握概率变化找出当前掌握度低但提升空间大的知识点优先安排。这个预测不需要重新训练模型只需要把模拟的答题序列喂进去观察概率变化。def simulate_path(model, student_seq, k_seq, target_k, adj, steps5): model.eval() probs [] seq student_seq.clone() for i in range(steps): with torch.no_grad(): p model(seq, k_seq, adj) probs.append(p.item()) # 假设答对追加一条记录 seq torch.cat([seq, torch.tensor([[1.0]])], dim1) return probs这段代码模拟学生连续答对同一知识点题目时掌握概率的变化曲线。如果曲线上升快说明这个知识点容易补如果上升慢说明需要更多练习或前置知识点没掌握。实际使用时把target_k对应的题目序列喂进去对比不同知识点的提升斜率斜率大的优先安排。验证这套方法是否有效可以做一个 A/B 实验对照组按章节顺序推题实验组按 GIKT 路径推题两周后对比知识点覆盖率提升和答题正确率变化。我自己的经验是实验组在中等难度题上的正确率提升更明显但需要至少两周数据才能看出统计显著。参数上steps一般取 5 到 10太少看不出趋势太多模拟误差累积。另外模拟时假设答对是一种理想化实际可以按当前概率采样得到更真实的分布。最后说一个我踩过的坑不要用测试集的学生数据去调推荐阈值那样阈值会过拟合到测试集。正确做法是从训练集里切一部分做验证阈值只在验证集上定。这个习惯帮我省了很多次线上翻车。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进