ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于GIKT知识追踪的习题推荐系统:从模型到Flask接口实战

基于GIKT知识追踪的习题推荐系统:从模型到Flask接口实战 简介本资源为基于深度知识追踪GIKT模型的习题推荐系统设计与实现源码面向计算机、人工智能、大数据、数学及电子信息等专业正在完成课程设计、期末大作业或毕业设计的学生也适合希望深入理解知识追踪与推荐算法结合的技术学习者。压缩包共59个文件约9.46MB以20个Python后端文件、13个Vue前端组件、7个JavaScript脚本为主另含JSON配置、PNG/JPEG图像、NPY数据文件及CSV数据集等前后端分离结构清晰便于按模块阅读与调试。项目采用Flask作为后端服务Vue构建前端界面完整呈现从知识追踪建模到习题推荐输出的实现链路。目前已有126人学习下载可作为掌握GIKT模型落地、推荐系统架构设计与前后端联调的实践参考帮助读者理解深度知识追踪在个性化习题推荐中的具体应用方式与工程组织思路。1. 从一份毕业设计源码说起GIKT 习题推荐系统到底在做什么很多做教育方向毕业设计的同学第一次看到「基于深度知识追踪GIKT模型的习题推荐系统」这个题目时第一反应是把它当成一个普通的增删改查后台——学生表、题目表、做题记录表再加一个「推荐」按钮。真跑起来才发现推荐结果要么是随机抽题要么永远推同一批简单题学生做两道就失去兴趣。问题不在前端页面而在「知识追踪」这四个字系统得先估计学生当前对每个知识点的掌握程度再据此挑出「跳一跳够得着」的题。GIKTGraph-based Interaction-aware Knowledge Tracing是知识追踪里比较有代表性的一类模型它把学生和题目的交互序列建模成图结构用图神经网络聚合「这道题涉及哪些知识点、这个学生之前在这些知识点上表现如何」再输出下一题答对的概率。习题推荐系统则是在这个概率之上做决策答对概率太高说明太简单太低说明太难理想区间是 0.60.8 之间。这套源码通常用 Python 写模型和推荐逻辑Flask 提供接口Vue 做前端展示是典型的「算法 Web」毕业设计组合。这篇文章面向三类人正在做同类毕设、需要一套能跑通的最小实现的人想搞清楚知识追踪和推荐怎么接起来的人以及已经拿到源码但卡在环境、数据格式、模型加载上的人。下面按「模型怎么立住 → 数据怎么组织 → 服务怎么搭 → 坑在哪 → 怎么验证」的顺序讲代码和参数都给到能直接抄的程度。2. GIKT 模型与推荐逻辑先搞清楚预测的是什么2.1 知识追踪的输出不是「分数」是「答对概率」很多人把知识追踪理解成「预测学生下次考试考多少分」这是错的。GIKT 这类模型的输出维度是「题目 × 知识点」上的答对概率通常是一个 01 之间的浮点数。假设学生做过 20 道题涉及 8 个知识点模型会给出这个学生在每个知识点上的掌握向量再结合下一道题的知识点分布算出答对概率。为什么用概率而不是直接给推荐列表因为推荐需要可解释的中间量。如果只输出「推荐题 A、B、C」你没法调参也没法向答辩老师解释「为什么是这三道」。有了概率推荐规则可以写成过滤掉学生已做过的题在剩余题目里选答对概率落在 [0.6, 0.8] 区间的按知识点覆盖度排序。这个区间不是拍脑袋教育测量里叫「最近发展区」太低会挫败太高没提升。GIKT 相比 DKT、DKVMN 这些经典模型多了一步「交互图」的构建。它不只看学生自己的答题序列还看「同一道题被哪些学生做过、他们的表现如何」用图卷积把相似学生的信息聚合进来。所以在数据组织上除了学生-题目-对错三元组还需要题目-知识点映射表否则图建不起来。2.2 用 Python 把交互序列转成模型输入模型跑不起来十有八九是输入格式不对。GIKT 的输入一般是一个三维张量[batch_size, sequence_length, feature_dim]。下面这段代码把原始答题记录转成模型能吃的序列是整套系统里最容易被忽略但最关键的一步。import numpy as np import pandas as pd def build_sequence(df, max_len50, num_skills20): df 列: student_id, skill_id, correct, timestamp 返回: X [N, max_len, num_skills*2], y [N, max_len] df df.sort_values([student_id, timestamp]) X_list, y_list [], [] for sid, group in df.groupby(student_id): seq group[[skill_id, correct]].values # 截断或补齐到 max_len if len(seq) max_len: seq seq[-max_len:] pad max_len - len(seq) # 每一行是 one-hot 知识点 对错标记拼成 2*num_skills 维 feat np.zeros((max_len, num_skills * 2), dtypenp.float32) label np.zeros(max_len, dtypenp.float32) for i, (skill, corr) in enumerate(seq): idx pad i feat[idx, int(skill)] 1.0 # 知识点位置 feat[idx, num_skills int(skill)] float(corr) # 对错位置 label[idx] float(corr) X_list.append(feat) y_list.append(label) return np.array(X_list), np.array(y_list)逻辑说明num_skills * 2是因为每个知识点占两个位置一个表示「这题考了这个知识点」一个表示「这个知识点上答对还是答错」。补齐放在前面而不是后面是为了让最近的答题记录落在序列末尾模型做注意力时能重点看近期行为。max_len50是常见取值太长显存吃不住太短丢历史信息50 在多数公开数据集上够用。参数说明max_len建议按数据集里学生平均答题数的 1.5 倍取num_skills必须和题目-知识点映射表里的最大 ID 对齐差一个就会数组越界。如果数据集里知识点是字符串先做一层LabelEncoder映射成 0 开始的整数别直接用原始 ID。2.3 推荐规则怎么写才不像随机抽题模型输出概率后推荐逻辑本身不复杂但细节决定体验。下面是一个可直接用的推荐函数核心是「概率区间 知识点覆盖 去重」三层过滤。def recommend(model, student_seq, candidate_items, top_k5): student_seq: 该学生历史序列 [1, max_len, 2*num_skills] candidate_items: 候选题目列表每题含 skill_id 和 item_id prob model.predict(student_seq)[0] # [max_len, num_skills] # 取最后一个时间步的知识点掌握度 mastery prob[-1] # [num_skills] scored [] for item in candidate_items: skill item[skill_id] p mastery[skill] # 该知识点答对概率 if 0.6 p 0.8: scored.append((item[item_id], p, skill)) # 按知识点去重每个知识点最多推 2 题 seen, result {}, [] for iid, p, skill in sorted(scored, keylambda x: -x[1]): if seen.get(skill, 0) 2: result.append(iid) seen[skill] seen.get(skill, 0) 1 if len(result) top_k: break return result逻辑说明mastery取最后一个时间步代表学生「当前」的掌握状态不是历史平均。概率区间 [0.6, 0.8] 是经验值实际可以按学生水平动态调——新学生放宽到 [0.5, 0.85]老学生收紧到 [0.65, 0.75]。按知识点去重是为了避免推 5 道全考同一个知识点的题那样覆盖度太差。参数说明top_k一般 510前端一屏能展示完seen字典里的上限 2 可以改成 1 或 3取决于知识点粒度。如果候选题目里没有落在区间内的要有兜底逻辑——选概率最接近 0.7 的而不是返回空列表否则前端会显示「暂无推荐」体验很差。3. 数据组织与 Flask 接口把模型接进 Web 服务3.1 数据表设计三张核心表加一张映射表毕业设计里数据表设计得乱后面模型和接口都会跟着乱。最小可用的表结构是四张学生表、题目表、答题记录表、题目-知识点映射表。答题记录表是模型训练和推理的数据源字段至少要有student_id、item_id、skill_id、correct、timestamp。timestamp不能省序列顺序全靠它。题目-知识点映射表容易被合并进题目表但如果一道题涉及多个知识点合并就会导致一行存多个 ID查询和建模都麻烦。常见做法是单独一张item_skill表item_id和skill_id多对多。GIKT 建图时需要「题目-知识点」二部图这张表就是图的边集。提示知识点 ID 一定要从 0 开始连续编号。如果数据库里是自增主键但从 1 开始模型里num_skills要加 1否则最后一个知识点永远取不到。3.2 Flask 接口加载模型只做一次Flask 默认是同步阻塞的如果在每个请求里都load_model接口响应会慢到没法用。正确做法是在应用启动时加载一次模型挂到全局变量或 Flask 的app.config上。下面是一个最小可用的接口示例。from flask import Flask, request, jsonify import numpy as np import tensorflow as tf app Flask(__name__) MODEL_PATH models/gikt.h5 model tf.keras.models.load_model(MODEL_PATH, compileFalse) app.route(/api/recommend, methods[POST]) def api_recommend(): data request.get_json() student_id data.get(student_id) # 从数据库取该学生历史序列转成模型输入 seq load_student_sequence(student_id) # [1, max_len, 2*num_skills] if seq is None: return jsonify({code: 404, msg: 无答题记录}), 404 items load_candidate_items() rec recommend(model, seq, items, top_k5) return jsonify({code: 0, data: rec}) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)逻辑说明compileFalse是因为推理不需要优化器和损失函数加载时跳过能省几秒。load_student_sequence里要做和训练时完全一致的预处理——同样的max_len、同样的补齐方向、同样的知识点编码任何一处不一致都会导致概率失真。debugFalse在生产环境必须关否则改代码自动重启会反复加载模型。参数说明host0.0.0.0让局域网内其他机器能访问前端联调时用得上port默认 5000如果被占用改成 5001。返回结构里code用 0 表示成功、非 0 表示失败前端好判断。如果模型是 PyTorch 存的.pt把load_model换成torch.load加model.eval()其余逻辑一样。3.3 Vue 前端怎么调这个接口前端不需要理解模型只需要把student_id发过去、把返回的题目列表渲染出来。用axios发 POST 请求注意 Flask 默认不处理跨域开发阶段要么在 Vue 里配代理要么在 Flask 加flask-cors。// Vue 组件里调用推荐接口 import axios from axios export default { data() { return { recommendList: [] } }, methods: { async fetchRecommend() { try { const res await axios.post(/api/recommend, { student_id: this.$store.state.studentId }) if (res.data.code 0) { this.recommendList res.data.data } else { this.$message.warning(res.data.msg) } } catch (e) { this.$message.error(推荐服务暂时不可用) } } } }逻辑说明student_id从 Vuex 或路由参数取不要写死。返回的data是题目 ID 列表前端再拿 ID 去查题目详情或者让后端直接返回题目对象少一次请求。try/catch必须加模型服务挂了前端不能白屏。参数说明开发时在vue.config.js里配devServer.proxy把/api转发到http://localhost:5000避免跨域。生产环境用 Nginx 做反向代理前端静态文件和/api走同一个域名同样绕开跨域。4. 环境搭建与本地跑通从零到能出推荐结果4.1 Python 环境版本和依赖别踩错GIKT 源码通常依赖 TensorFlow 或 PyTorch这两个对 Python 版本有要求。TensorFlow 2.x 支持 Python 3.73.10PyTorch 支持范围更宽。如果本机装了 Python 3.12很可能装不上老版本 TensorFlow报一堆编译错误。稳妥做法是用 conda 建一个 3.8 或 3.9 的环境。conda create -n gikt python3.9 conda activate gikt pip install tensorflow2.10.0 flask2.2.0 pandas numpy scikit-learn pip install flask-cors逻辑说明tensorflow2.10.0是最后一个支持 Windows 原生 GPU 的版本也是毕业设计里最常用的版本兼容性好。flask2.2.0和flask-cors搭配不会出现版本冲突。如果源码用的是 PyTorch把第一行换成pip install torch1.13.0其余不变。参数说明-n gikt是环境名可以改成项目名。装完后用python -c import tensorflow as tf; print(tf.__version__)验证能打印版本号才算成功。如果报DLL load failed多半是缺 Visual C 运行库装一个vc_redist.x64.exe即可。4.2 数据准备公开数据集和自造数据GIKT 论文里常用的数据集是 ASSISTments 和 EdNet这些数据集体量不小但格式和源码里的读取脚本不一定对得上。如果源码自带data/目录和预处理脚本优先用自带的。如果没有可以自己造一份小数据先把流程跑通。import pandas as pd import numpy as np np.random.seed(42) n_students, n_items, n_skills 100, 50, 10 records [] for sid in range(n_students): for t in range(np.random.randint(20, 60)): item np.random.randint(0, n_items) skill item % n_skills correct np.random.binomial(1, 0.6) records.append([sid, item, skill, correct, t]) df pd.DataFrame(records, columns[student_id, item_id, skill_id, correct, timestamp]) df.to_csv(data/synthetic.csv, indexFalse)逻辑说明skill item % n_skills保证每道题固定对应一个知识点方便验证推荐逻辑。correct用二项分布生成均值 0.6模拟中等水平学生。timestamp用循环变量保证同一学生的记录有序。参数说明n_students100、n_items50足够跑通流程真实训练要上千学生。np.random.seed(42)固定随机种子保证每次生成的数据一样方便复现问题。生成后检查一下df.groupby(student_id).size()如果有的学生记录太少少于 5 条模型学不到东西可以过滤掉。4.3 训练和推理两个脚本分开跑训练脚本负责读数据、建模型、存权重推理脚本负责加载权重、提供推荐。分开的好处是训练一次可以反复推理不用每次启动都重训。训练时注意把验证集分出来否则没法判断模型有没有过拟合。from sklearn.model_selection import train_test_split X, y build_sequence(df, max_len50, num_skills10) X_train, X_val, y_train, y_val train_test_split(X, y, test_size0.2, random_state42) model build_gikt_model(num_skills10, max_len50) # 源码里的建模函数 model.fit(X_train, y_train, validation_data(X_val, y_val), epochs20, batch_size32) model.save(models/gikt.h5)逻辑说明train_test_split按学生切分更严谨但按序列切分实现简单毕业设计够用。epochs20是常见起点看验证集 loss 不再下降就可以停。batch_size32显存不够就降到 16 或 8。参数说明validation_data必须传否则fit只打印训练 loss看不出过拟合。如果验证 loss 先降后升说明过拟合加Dropout或减epochs。保存用.h5格式加载方便用SavedModel格式则要改加载方式。5. 避坑与排查那些让系统跑不起来的细节5.1 现象接口返回的概率全是 0.5 左右原因模型没加载成功或者加载的是随机初始化权重。常见于load_model时路径写错但没报错或者保存时用了model.save_weights而加载时用了load_model两者格式不匹配。解决加载后立刻用一条已知数据推理打印输出。如果全是 0.5检查保存和加载是否配对——save配load_modelsave_weights配load_weights。路径用绝对路径或os.path.join拼别用相对路径Flask 启动目录不同会导致找不到文件。5.2 现象推荐结果里出现学生已经做过的题原因去重逻辑只按知识点去重没按题目 ID 去重。或者候选题目列表是从数据库全量取的没排除已做题。解决在recommend函数里加一层done_items集合从答题记录里取该学生做过的所有item_id候选题目先过滤掉这些。注意去重要在概率过滤之前做否则可能过滤完发现没题可推。5.3 现象Flask 启动报端口被占用原因5000 端口被其他程序占用macOS 上还可能是 AirPlay 接收器占着。解决换端口app.run(port5001)或者查占用进程lsof -i:5000后杀掉。前端代理配置里的端口要同步改否则请求发不出去。5.4 现象Vue 请求接口报 CORS 错误原因前端localhost:8080后端localhost:5000端口不同就是跨域。Flask 默认不加跨域头。解决装flask-cors在app Flask(__name__)后加CORS(app)。或者用 Vue 的devServer.proxy把/api转发到后端生产环境用 Nginx 统一域名。5.5 现象模型推理速度慢一个请求要好几秒原因每次请求都重新加载模型或者序列预处理用了 Python 循环逐条处理。解决模型全局加载一次。预处理用 NumPy 向量化别用for循环拼数组。如果还是慢把max_len从 50 降到 30推理时间能减一半精度损失在可接受范围。6. 进阶技巧让推荐结果经得起答辩追问6.1 用离线指标验证推荐质量答辩时老师一定会问「你怎么证明推荐是有效的」。光说「模型 loss 低」不够要拿推荐指标说话。最常用的是 AUC 和 RecallK。AUC 衡量模型区分答对答错的能力RecallK 衡量推荐列表里有多少是学生真正需要练的题。from sklearn.metrics import roc_auc_score def evaluate_auc(model, X_val, y_val): pred model.predict(X_val) # [N, max_len, num_skills] # 取每个时间步上实际知识点的答对概率 scores, labels [], [] for i in range(len(X_val)): for t in range(X_val.shape[1]): skill_vec X_val[i, t, :num_skills] if skill_vec.sum() 0: continue skill int(np.argmax(skill_vec)) scores.append(pred[i, t, skill]) labels.append(y_val[i, t]) return roc_auc_score(labels, scores)逻辑说明skill_vec是 one-hotargmax取出当前时间步考的知识点再从预测里取对应概率。labels是真实对错。AUC 在 0.7 以上算可用0.8 以上算不错。如果低于 0.6检查数据预处理和训练轮数。参数说明X_val和y_val要和训练时同一套预处理。如果验证集里有些时间步是补齐的skill_vec.sum()0要跳过否则会拉低 AUC。6.2 冷启动新学生没有历史记录怎么办新学生没有答题序列模型没法推理。常见做法是给新学生做一次「摸底测试」选 510 道覆盖不同知识点的题做完后就有了初始序列再走正常推荐流程。摸底题要选区分度高的别全选简单题。def cold_start_items(all_items, num_skills, k8): 每个知识点选一道中等难度题作为摸底 selected [] for skill in range(num_skills): candidates [it for it in all_items if it[skill_id] skill] if candidates: # 按难度中位数选 candidates.sort(keylambda x: abs(x[difficulty] - 0.5)) selected.append(candidates[0][item_id]) return selected[:k]逻辑说明difficulty是题目难度0.5 表示中等。按「离 0.5 最近」排序选最接近中等难度的题。每个知识点选一道保证覆盖度。k8是摸底题数量太多学生没耐心太少估不准。参数说明如果题目表里没有difficulty字段用历史答题正确率代替——正确率越接近 0.5 越有区分度。摸底结果直接写入答题记录表后续推荐就能用上。6.3 一个我踩过的坑知识点编码不一致训练时用LabelEncoder把知识点映射成 09推理时数据库里查出来是原始字符串忘了做同样映射结果skill_id传进去是字符串NumPy 索引报错或者取到错误位置。血泪经验是把编码器一起保存下来推理时加载同一个编码器别重新fit。import joblib # 训练时保存 joblib.dump(le, models/skill_encoder.pkl) # 推理时加载 le joblib.load(models/skill_encoder.pkl) skill_id le.transform([raw_skill])[0]这个坑不报错的时候最可怕——索引能取到值但取的是别的知识点的概率推荐结果看起来正常实际全错。验证方法是拿一个已知学生的数据手动算一遍概率和接口返回的对一下对不上就是编码问题。做这类系统我的习惯是先把「数据 → 模型 → 接口 → 前端」这条链路用最小数据跑通再换真实数据。链路通了后面都是调参和优化链路不通改哪都是玄学。希望帮到你。本文还有配套的精品资源点击获取
返回列表