基于GIKT深度知识追踪与Flask+Vue的习题推荐系统
2026/9/14 18:56:19 网站建设 项目流程

简介:这是一份基于GIKT深度知识追踪模型的习题推荐系统完整工程,面向教育数据挖掘研究者、推荐系统开发者以及希望复现知识追踪项目的学生。项目采用Flask提供后端接口、Vue构建前端界面,涵盖从MySQL数据库初始化、数据预处理、模型预训练到最终系统调用的完整链路。压缩包共60个文件,约9.79MB,主要以Python后端脚本(20个py)、Vue组件(13个vue)、JavaScript逻辑文件(7个js)及SQL数据库脚本为核心,另含JSON配置、说明文档、图片素材等辅助内容,结构清晰便于按模块学习。目前已有235人学习下载,适合用于课程设计、毕业设计或科研基线对比。借助随包提供的使用说明和数据库脚本,可快速搭建本地环境,掌握GIKT模型在真实习题推荐场景中的落地方式。

1. GIKT深度知识追踪模型凭什么接管习题推荐

做习题推荐系统,第一件事不是选协同过滤,而是预测学生对每道题的真实掌握程度。GIKT(Graph-based Interactive Knowledge Tracing)把知识点前置依赖建成有向图,用图注意力网络融合历史答题序列,实时输出每个知识点的掌握概率。相比 DKT 它显式利用知识点关联,比贝叶斯知识追踪少一步人工状态转移。

工程落地时,Flask 负责把模型包成 REST 推荐接口,Vue 负责答题与展示,数据库存知识点、习题和作答日志。下面按数据准备、模型推理、接口封装、前端联调、效果验证的顺序,讲一条能直接抄作业的路径。

2. GIKT 模型原理与数据准备:把答题记录变成图序列

2.1 知识点关系图与图注意力网络的工作方式

GIKT 全称 Graph-based Interactive Knowledge Tracing,Graph 指的不是把学生当节点,而是把知识点当节点、把"先修关系"当边。比如"因式分解"是"一元二次方程"的前置知识点,图上就有一条从前者指向后者的边。建模时每个知识点维护一个 embedding,答题序列里每出现一次交互 (q_t, a_t),就通过图注意力把该知识点的邻居节点历史表现聚合起来作为当前图信号。

图注意力与普通图卷积的差别在于加权方式:邻居对当前节点的贡献不是平均的,而是由注意力分数决定。学生在"因式分解"上连错三次,这个信号会被放大并沿边传到"一元二次方程",使后者掌握概率同步下调;反过来,如果"一元二次方程"连续做对,图信号又会抑制"因式分解"的负向传导。最后图信号与 LSTM 对答题序列编码的隐状态拼接,过两层全连接输出掌握概率。相比之下 DKT 只把知识点当独立 one-hot 输入,跨知识点的学习迁移完全靠隐式学习,数据量小时很难学出这种关系,这就是 GIKT 在深度知识追踪任务上 AUC 更高的根本原因。

2.2 数据表设计:知识点、习题、答题记录落库

落地系统至少要四张表:知识点表存名称与前置关系,习题表存文本、所属知识点与难度,答题记录表存每次作答的对错与时间,学生知识点状态表存 GIKT 输出的掌握概率,供推荐接口直接读取。建表脚本如下:

CREATE TABLE knowledge_point ( kp_id INT PRIMARY KEY AUTO_INCREMENT, kp_name VARCHAR(64) NOT NULL, subject VARCHAR(32) NOT NULL, prereq_ids VARCHAR(255) COMMENT '前置知识点id,逗号分隔' ); CREATE TABLE exercise ( ex_id INT PRIMARY KEY AUTO_INCREMENT, kp_id INT NOT NULL, content TEXT NOT NULL, difficulty FLOAT DEFAULT 0.5, answer TEXT, INDEX idx_kp (kp_id) ); CREATE TABLE answer_log ( log_id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, ex_id INT NOT NULL, is_correct TINYINT NOT NULL COMMENT '1正确 0错误', answer_time DATETIME NOT NULL, INDEX idx_student (student_id, ex_id) ); CREATE TABLE student_kp_state ( student_id INT NOT NULL, kp_id INT NOT NULL, mastery FLOAT NOT NULL, update_time DATETIME NOT NULL, PRIMARY KEY (student_id, kp_id) );

prereq_ids 用逗号分隔而非关联表,是为了建图时一次查出直接 split;对几百个知识点的规模,这种反规范化完全可控。difficulty 存 0~1 浮点数,不参与 GIKT 训练,但会参与排序的难度加权。answer_log 用 BIGINT 主键并建 (student_id, ex_id) 联合索引,因为单学生年答题量能到万级,推荐接口每个请求都要按学生查最近记录。student_kp_state 是缓存表,训练后把掌握概率写进去,推荐时先读缓存,命中就不必实时推理。

2.3 GIKT 训练核心参数与保存约定

GIKT 的训练脚本和 Flask 应用要分目录管理,训练产物只输出三个文件:模型权重、知识点 id 到图节点索引的映射、邻接矩阵。Flask 推理端只依赖这三份文件,不引入训练代码。用 PyTorch 实现时,模型类至少包含 GAT 聚合层、LSTM 序列层和输出全连接层,前向输入 shape 是 (batch, seq_len, feature_dim)。训练侧几个关键参数如下:

参数推荐值说明
embedding_dim64知识点与交互表示的向量维度,数据量小不超过 128
hidden_size128LSTM 隐层维度,过大在短序列上容易过拟合
gat_heads4图注意力多头数,输出取多头平均
dropout0.2图卷积输出与全连接层之间
batch_size128按单条序列长度调整,显存不足降到 64
lr1e-3Adam 优化器,第 10 个 epoch 衰减为 5e-4

损失函数用二分类交叉熵,评估用 AUC 与 ACC。序列长度统一截到 50,超过截断、不足用掩码填充。训练完执行 torch.save(model.state_dict(), "checkpoints/gikt_model.pt"),同时把知识点映射写成 JSON、邻接矩阵存成 npy。这里最容易翻车的坑是:训练时的知识点集合和建表导入的知识点不一致,推理端加载邻接矩阵时 index 越界。我在训练前会先跑一遍一致性校验,统计习题表的 kp_id 集合和 graph_adj.npy 的节点数,不一致直接中止训练。

2.4 推理端的目录组织

我一般把 Flask 应用缩到这样一个最小结构,方便打包部署:

gikt-flask/ app.py model_loader.py services/recommend.py databases.py checkpoints/gikt_model.pt checkpoints/kp_map.json checkpoints/graph_adj.npy

model_loader.py 只负责三件事:torch.load 权重、把邻接矩阵转成稀疏张量、把 kp_map 载入内存。services/recommend.py 里写排序策略,不与模型文件耦合,后续换排序公式不用动模型加载逻辑。

3. Flask 后端:把 GIKT 模型包装成可复用的推荐 API

3.1 Flask 项目结构与模型懒加载

Python Flask 开发时最常见的误区是模块导入阶段就执行 torch.load。flask run 默认带 reloader,会起两个进程加载应用模块,模型被读两遍,显存直接翻倍。正确做法是把模型加载放进全局单例,第一次请求触发,之后所有请求复用。下面的最小实现里,get_model 内部判断全局变量,未加载才读权重,/health 接口可以用来确认模型状态。

from flask import Flask, jsonify from model_loader import load_gikt_model app = Flask(__name__) app.json.ensure_ascii = False # 中文习题内容原样返回 _model = None def get_model(): global _model if _model is None: _model = load_gikt_model("checkpoints/gikt_model.pt") return _model @app.get("/health") def health(): return jsonify({"status": "ok", "model_loaded": _model is not None})

app.json.ensure_ascii = False 很关键,不设置的话 Flask 返回的 JSON 里中文全会变成 \uXXXX 转义,Vue 端渲染出来是一堆转义符。Flask 2.2 及以前用的是 app.config["JSON_AS_ASCII"] = False,2.3 起移到 app.json.ensure_ascii,老项目升级时这是最常见的兼容性报错。开发阶段可以 debug=True,但联调完成部署时务必关掉,reloader 会导致模型被重复加载。

注意:如果接口已经能返回 JSON,但中文一直是 \uXXXX,先查 Flask 大版本再决定改 app.config 还是 app.json,改错位置不报错但不生效。

3.2 推荐接口设计:输入学生历史,输出习题列表

推荐接口我用 POST /api/recommend,避免 GET 把 student_id 暴露在访问日志里。请求体传 student_id 与 top_k,后端先取该学生最近 30 条答题记录,没有记录就走冷启动,有记录则交给 GIKT 推理掌握概率,再按排序策略选 top_k 习题返回。

@app.post("/api/recommend") def recommend(): payload = request.get_json(silent=True) or {} student_id = payload.get("student_id") top_k = int(payload.get("top_k", 10)) if not student_id: return jsonify({"code": 400, "msg": "student_id is required"}), 400 history = fetch_history(student_id, limit=30) if not history: return jsonify({"code": 200, "data": cold_start_pool(top_k)}) mastery = get_model().predict(history) ranked = rank_exercises(mastery, top_k) return jsonify({"code": 200, "data": ranked})

predict 内部完成三步:把 (ex_id, is_correct) 序列映射到知识点索引,拼接图注意力和 LSTM 编码,输出每个知识点的掌握概率。这里要注意 history 必须按 answer_time 升序,顺序错了 GIKT 的时序编码就是乱的。fetch_history 的 SQL 里要 ORDER BY answer_time DESC LIMIT 30,取回后反转成时间正序再喂模型。冷启动池我固定取难度 0.45~0.55 的题目,保证新学生先做中等难度题,而不是被系统直接推最难的。

3.3 排序策略的选择:纯概率还是难度加权

排序是推荐逻辑里最值得调的一层,习题推荐系统里常用三种策略:

策略排序分适用场景
薄弱优先1 - mastery考前复习,专注补弱
难度加权(1-mastery)*0.7 + (1-|difficulty-mastery|)*0.3日常练习,避免推最简单题
遗忘混合score * exp(-days/7)冲刺阶段,把久未复习的知识点提前

薄弱优先的缺陷很明显:按 1-mastery 一直排,结果全是简单题,因为简单知识点最容易拿到低概率;难度加权用 mastery 与 difficulty 的距离修正排序,难度接近学生当前水平、且掌握度低的题目排前面;遗忘混合适合有考试日期的场景,days 取该知识点最后一次做错距今天数。三种策略我都实现成 recommend.py 里的独立函数,接口层通过 payload 里的 strategy 参数选择,线上切换不用改模型。

3.4 跨域、连接池与部署

前后端分离开发时,Vue 跑 5173、Flask 跑 5000,跨域是第一道坎。用 Flask-CORS 扩展,白名单只写前端地址:

from flask_cors import CORS CORS(app, resources={r"/api/*": {"origins": ["http://localhost:5173"]}})

不要用 origins="*",否则任何网页都能从浏览器直接调接口。生产部署用 gunicorn 起多 worker,每个 worker 各持一份模型副本,worker 数设成 2~4 比较稳,避免频繁上下文切换。数据库用 SQLAlchemy scoped_session 并把 pool_pre_ping 打开,MySQL 的 wait_timeout 默认 8 小时,空闲连接被服务端切断后,pool_pre_ping 会在取连接时先探活,否则你会看到隔夜后接口偶发 500。每个推荐请求打一条日志,记录 student_id、耗时、返回的 kp_id 列表,这是后面做效果验证的数据基础。

4. Vue 前端:对接 GIKT 推荐接口并形成答题反馈闭环

4.1 创建 Vue 项目、配置代理与路由

前端基于 Vue 3 + Vite,创建项目用 npm create vue@latest,选上 Router。npm 安装依赖时注意 Node 版本,Vite 7 要求 Node 18 以上,装完直接 npm run dev 起服务。联调阶段我建议优先用 Vite 代理而不是 CORS:代理请求经同源转发,浏览器里看不到跨域,还能顺便隐藏后端地址。

// vite.config.js import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { "/api": { target: "http://localhost:5000", changeOrigin: true, }, }, }, });

代理配好后,前端所有请求都以 /api 开头,Flask 那边 CORS 白名单可以不配,生产环境再用 nginx 反向代理把 /api 转到 Flask。路由上我建议把学生 id 放进路径参数而不是 query:/home/1001 比 /home?studentId=1001 更利于分享,刷新页面后也不丢失状态。vue-router 里配置如下:

// router/index.js import { createRouter, createWebHistory } from "vue-router"; const router = createRouter({ history: createWebHistory(), routes: [ { path: "/", redirect: "/login" }, { path: "/login", name: "login", component: () => import("@/views/Login.vue") }, { path: "/home/:studentId?", name: "home", component: () => import("@/views/Home.vue") }, ], }); router.beforeEach((to) => { const token = localStorage.getItem("token"); if (to.name !== "login" && !token) return { name: "login" }; });

4.2 推荐列表渲染与答题回写

推荐接口返回结构约定为 { code, data: [{ ex_id, content, kp_name, difficulty }] }。axios 实例统一封装,请求拦截器注入 token,响应拦截器把 HTTP body 解开,组件里拿到的就是业务包:

// api.js import axios from "axios"; import router from "./router"; const http = axios.create({ baseURL: "/api", timeout: 15000 }); http.interceptors.request.use((config) => { const token = localStorage.getItem("token"); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); http.interceptors.response.use( (resp) => resp.data, (error) => { if (error.response?.status === 401) { localStorage.removeItem("token"); router.push({ name: "login" }); } return Promise.reject(error); } ); export const fetchRecommend = (studentId, topK = 10) => http.post("/recommend", { student_id: studentId, top_k: topK }); export const submitAnswer = (studentId, exId, correct) => http.post("/answer", { student_id: studentId, ex_id: exId, is_correct: correct ? 1 : 0, });

submitAnswer 里不传 answer_time,由后端生成,避免客户端时钟不准污染训练数据。组件里点击"做对了""做错了"按钮后,先调 submitAnswer 再调 fetchRecommend 刷新列表,这样 GIKT 下一轮推理的输入序列已经包含刚才的作答,学生能直观看到推荐列表随作答变化,这也是判断深度知识追踪模型是否生效的最直接方式。

<template> <div v-for="item in exercises" :key="item.ex_id" class="exercise-card"> <p>{{ item.content }}</p> <span>考点:{{ item.kp_name }} 难度:{{ item.difficulty.toFixed(1) }}</span> <button @click="mark(item, true)">做对了</button> <button @click="mark(item, false)">做错了</button> </div> </template> <script setup> import { ref, onMounted } from "vue"; import { useRoute } from "vue-router"; import { fetchRecommend, submitAnswer } from "@/api"; const route = useRoute(); const studentId = ref(route.params.studentId || 1); const exercises = ref([]); async function mark(item, correct) { await submitAnswer(studentId.value, item.ex_id, correct); exercises.value = (await fetchRecommend(studentId.value)).data; } onMounted(async () => { exercises.value = (await fetchRecommend(studentId.value)).data; }); </script>

拦截器已经把 resp.data 解出来了,所以组件里 (await fetchRecommend(...)).data 拿到的是 { code, data } 业务包里的 data。很多新手在这里解出两层 data 后数组取不到,先检查拦截器是否 return 了 response.data,再检查后端返回结构是否包了 code 字段。

4.3 Token 拦截与前后端联调

路由守卫与拦截器配合,才能覆盖完整的 vue 项目实战场景:直接输入 /home/1001 访问时,beforeEach 发现没有 token 就重定向到 /login;token 过期时,接口返回 401,拦截器清掉本地 token 并跳登录。注意后端必须同时校验 token 有效性,前端守卫只是减少无效请求的体验优化。联调阶段最容易出问题的状态码对照如下:

状态码含义前端处理
200推荐成功渲染列表
400student_id 缺失提示参数错误
401token 失效清 token 跳登录
502模型推理超时或 worker 崩溃提示服务繁忙并禁用按钮,防止重试风暴

502 需要特别说明:GIKT 首次推理时要加载权重,如果加载放在请求内且超过网关超时时间,nginx 会先于 Flask 返回 502。解决方式是启动后先打一次 /health 预热,或把加载提前到 gunicorn 的 post_fork 钩子里。前端拿到 502 后我用一个 3 秒的 setTimeout 再重试一次,而不是允许用户疯狂点击。

提示:npm 安装依赖时如果出现 ERESOLVE 错误,多半是 Node 版本与 Vite 要求不匹配,先切到 Node 18 LTS 再重新 install。

5. 用 AUC、命中率与难度系数验证 GIKT 推荐效果

5.1 离线评估怎么切数据

GIKT 训练完,第一件事是离线评估,切分方式直接影响指标可信度:按学生分组,每个学生按时间把答题记录前 80% 划进训练集、后 20% 划进测试集,不能随机切。随机切会让模型在测试集里看到同一学生的未来作答,AUC 虚高 0.05 以上。评估报告看两个指标:AUC 反映模型区分会做与不会做的能力,0.75 以上合格;ACC 反映整体预测准确率,0.68~0.72 都属于正常。另外按知识点分桶算 AUC,能快速找到被模型系统性高估或低估的知识点,这两个知识点往往就是推荐列表里反复出现、学生却仍做错的题目来源。

5.2 线上命中率与冷启动观测

离线指标好不代表推荐有效,线上我常用命中率验证:取最近一周的答题日志,用前 80% 做模拟推荐,看推荐列表里的题目是否出现在学生后 20% 的真实作答中,命中率超过 15% 说明排序与真实行为一致。冷启动单独统计:新学生前 10 次推荐里难度 0.4~0.6 的题目占比。如果推荐结果全是简单题,说明难度加权没生效;如果全是难题,说明冷启动池的难度范围设得太宽,需要把 0.45~0.55 的取值区间收窄。冷启动期间的推荐记录不要进训练集,否则会把随机作答当成真实水平。

5.3 一个可落地的对比技巧:双列表看覆盖率与难度

最后给一个立刻能用的对比方法:同一批学生、同一份掌握概率,分别用"薄弱优先"和"难度加权"各跑一遍推荐,统计两版列表的知识点覆盖数和平均难度。覆盖数相差 3 个以上,说明图结构传递的跨知识点信息确实参与了排序;平均难度如果长期低于 0.4,说明排序被薄弱优先带偏,把难度加权系数从 0.3 往上调到 0.4 再看一周。调参只动 recommend.py 里的权重常量,不重训模型、不重新部署模型文件,线上可随时回滚。更直观的做法是把两版列表并排渲染在 Vue 页面上做人工对比,一次性看十个学生的推荐差异比看任何指标都清晰。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询