☰
GIKT深度知识追踪:从交互日志到个性化习题推荐的Python实践
2026/10/3 9:56:53 网站建设 项目流程

简介:这是一套基于GIKT深度知识追踪的个性化习题推荐系统完整工程,面向计算机专业毕业设计、课程实践与期末大作业等场景,解决从算法原理到系统部署的全链路需求。系统采用深度学习方法构建学生知识状态动态画像,据此生成个性化习题推送策略,附带的Python源码已通过多轮测试,执行稳定;部署指南覆盖环境配置、前后端联调等关键环节。压缩包共143个文件、约10.85MB,内容包括Python后端核心模块(py/pyc)、Vue前端页面(vue/js)、模型权重与训练中间结果(pth/npy/npz)、实验数据(csv/xlsx)以及配置文件(json/yaml/xml),并含Flask-Vue项目结构、推荐效果测试脚本与知识追踪相关笔记,数据样例与Notebook组合可辅助快速验证推荐效果。当前已有53人学习下载,适合需要高质量毕业设计参考、期末大作业模板或教育技术研究基础的学习者。

1. 为什么个性化习题推荐要上 GIKT 深度知识追踪

个性化习题推荐里最闹心的一环,不是推荐算法本身,而是模型根本不知道学生此刻掌握了什么。基于 GIKT 深度知识追踪的个性化习题推荐系统,正是把知识追踪和推荐串成一条流水线的 Python 工程方案:GIKT 用图结构把知识点之间的依赖关系统一建模,沿着答题序列持续更新学生的掌握状态,再把这种状态翻译成“下一题推什么”。我见过不少团队把召回、排序做得花里胡哨,结果一上 AB 实验,新增点击全被没有知识状态的对照组吊打。这套方案适合已经有一定规模的在线练习产品、题库系统或者自适应学习平台,也适合想尽快跑通“知识追踪 + 推荐”全流程的算法工程师。

2. 构建 GIKT 的输入:交互日志清洗与知识点依赖图生成

GIKT 和普通序列模型最大的差别,是它还要一张“知识点依赖图”。所以动手写模型之前,先要把原始练习日志变成两样东西:按学生切好的交互序列,以及一张表达知识点先决/共现关系的邻接矩阵。这一步做不好,后面模型结构再对也白搭。

2.1 从原始交互表到训练样本:id 对齐与序列截断

以最常见的题库导出格式为例,一张交互表通常至少有 user_id、order_id、skill_id、correct 四个字段。order_id 是全局作答顺序,用来还原每个学生的答题时间线。我的习惯是先排序、再过滤缺失知识点、最后把 id 映射成从 0 开始的连续整数,这一步非常关键,因为 PyTorch 的 Embedding 层不接受乱序大 id。

import pandas as pd raw = pd.read_csv("data/assistments-2009-2010.csv") # 1) 按用户和时间序整理 raw = raw.sort_values(["user_id", "order_id"]).reset_index(drop=True) raw = raw.dropna(subset=["skill_id"]) # 2) 连续化 id raw["u_idx"] = raw["user_id"].astype("category").cat.codes raw["s_idx"] = raw["skill_id"].astype("category").cat.codes raw["y"] = raw["correct"].astype(int) print(raw[["u_idx", "s_idx", "y"]].head())

逻辑说明:先排序是为了保证后面切序列时顺序不乱;dropna 把没有知识点标注的脏样本直接剔除;astype("category").cat.codes会在 pandas 内部完成去重和编码,比手动建字典省事,而且结果稳定。映射之后,s_idx 的最大值就是知识点总数 N,后面模型里的 Embedding 需要用到这个数。

接下来把交互记录切成“每个学生最多一条序列”的样本。不同学生的做题长度差异很大,有的十几条,有的上千条,GIKT 里常见的处理方式是截断到固定长度,比如保留最近 50 次作答。

MAX_LEN = 50 def build_sequences(df, max_len=MAX_LEN): """按学生聚合,保留最近 max_len 条作答""" seqs = [] for _, g in df.groupby("u_idx"): g = g.tail(max_len) # 保留离当前时刻最近的数据 seqs.append({ "skill_seq": g["s_idx"].tolist(), "correct_seq": g["y"].tolist(), }) return seqs train_seq = build_sequences(raw) print(train_seq[0])

参数说明:max_len 我一般取 30~50,太短会丢失早期学习趋势,太长容易让 GRU 的梯度传到后面就衰减了,训练也慢。tail(max_len) 是业务上的一个假设:预测当前时刻只需要最近一段交互,这个假设在真实练习场景下基本成立,因为知识点掌握状态是随时间变化的。

2.2 生成知识点依赖图:共现统计与邻接矩阵构建

GIKT 的图有两条路可以构建。如果题库本身有标注知识点先决关系(比如“因式分解”是“一元二次方程”的先决知识点),直接把它转成邻接矩阵最可靠;大多数题库没有这个标注,这时就用“共现图”做近似,统计同一个学生序列里两个知识点在固定窗口内同时出现的频率。窗口共现的意义是:学生刚做完知识点 A 又去做 B,很可能是因为 A 没掌握或者 B 依赖 A。

from collections import Counter import scipy.sparse as sp def build_cooccurrence_graph(df, window=5, min_cnt=3): n_skills = df["s_idx"].max() + 1 counter = Counter() for _, g in df.groupby("u_idx"): skills = g["s_idx"].tolist() for i, s in enumerate(skills): left = max(0, i - window) right = min(len(skills), i + window + 1) for j in range(left, right): if i != j: counter[(s, skills[j])] += 1 row, col, val = [], [], [] for (a, b), cnt in counter.items(): if cnt >= min_cnt: row.append(a) col.append(b) val.append(cnt) adj = sp.coo_matrix((val, (row, col)), shape=(n_skills, n_skills)).tocsr() # 对称化:A 指向 B 与 B 指向 A 都保留 adj = adj.maximum(adj.T) return adj adj = build_cooccurrence_graph(raw) print(adj.shape, adj.nnz)

参数说明:window 控制共现范围,5 是我试下来比较稳的默认值;min_cnt 用来过滤偶发共现,防止把噪声当依赖,数据稀疏时降到 2,数据量大时用到 5 都没问题。对称化是因为我们无法从共现频率判断依赖方向,保留双向关系最保守。最后得到的是 scipy 的稀疏矩阵,模型里直接用 torch.spmm 做图传播就不用转稠密矩阵了,否则知识点上千之后内存会先崩。

2.3 按用户切分的训练/验证集划分

切分是这一章最容易翻车的地方。很多新手直接对行做随机切分,同一个学生的作答会同时出现在训练和验证里,造成严重的数据泄漏,验证集 AUC 虚高得离谱。正确做法是按用户划分:先分出 20% 的用户做验证,其余做训练,再保证验证集的用户在训练阶段完全不可见。

import numpy as np np.random.seed(42) n_users = raw["u_idx"].nunique() all_users = np.arange(n_users) np.random.shuffle(all_users) n_val = int(n_users * 0.2) val_users = set(all_users[:n_val].tolist()) train_users = set(all_users[n_val:].tolist()) train_raw = raw[raw["u_idx"].isin(train_users)] val_raw = raw[raw["u_idx"].isin(val_users)] print(f"train users: {len(train_users)}, val users: {len(val_users)}")

注意,GIKT 的图最好在这两类样本之外单独构建一次,比如基于全量数据构建图关系,但序列窗口统计只使用训练集,这样能避免验证集知识“渗透”进图的边。我通常把 build_cooccurrence_graph 里的 df 换成 train_raw,图中的边就只能反映训练集里的共现规律。

3. 编写 GIKT 模型核心代码:图传播、序列编码与预测头

GIKT 的主体结构是两个 Encoder:一个用 GCN 把知识点依赖图编码成稳定的知识点表征,另一个用 GRU 把学生的答题序列编码成动态掌握状态。最后把两者合并,预测当前习题的正确概率。这一章直接给完整可跑的简化实现,代码按项目内常见的 models/gikt.py 组织。

3.1 模型骨架与知识点嵌入

先定义模型类和基础层。GIKT 有一个细节:知识点 Embedding 既作为 GCN 的输入特征,又要在序列编码阶段被查询,所以它必须同时服务于两个模块。

import torch import torch.nn as nn import torch.nn.functional as F class GIKTModel(nn.Module): def __init__(self, n_skills, n_hidden=128, n_layers=2, dropout=0.5, gru_layers=1): super().__init__() self.n_skills = n_skills self.n_hidden = n_hidden # 知识点嵌入:训练 GCN 和序列编码共享 self.emb_skill = nn.Embedding(n_skills, n_hidden) # 负答操作嵌入:答错时的交互向量 self.emb_fail = nn.Embedding(n_skills, n_hidden) # 两个 Encoder self.gcn_layer = self._build_gcn(n_hidden, n_layers, dropout) self.gru = nn.GRU(input_size=n_hidden, hidden_size=n_hidden, num_layers=gru_layers, batch_first=True, dropout=dropout if gru_layers > 1 else 0.0) # 预测头:状态 + 当前知识点表征 -> logit self.pred_fc = nn.Linear(n_hidden * 2, 1) self.dropout = nn.Dropout(dropout) def _build_gcn(self, hidden, layers, dropout): """把多层 GCN 组装成 ModuleList""" return nn.ModuleList([ nn.Sequential( nn.Linear(hidden, hidden), nn.ReLU(inplace=True), nn.Dropout(dropout), ) for _ in range(layers) ])

逻辑说明:emb_skill 和 emb_fail 是两个独立的 Embedding,这样答对与答错会被编码成不同的向量轨迹,GRU 才能区分“这个知识点一遍过”和“这个知识点反复错”两种状态。GCN 层我用 Linear+ReLU+Dropout 做一个轻量实现,替换掉复杂的 graph conv 写法,降低新手理解成本。n_hidden 控制所有表征维度,全项目保持同一个数,后面拼接时才不会出现维度不一致。

3.2 图正则化与 GCN 图传播

GCN 直接吃邻接矩阵是不行的,因为原始邻接矩阵的行和往往不等于 1,会导致传播后知识点表征的尺度漂移。标准做法是先做对称归一化:D^-1/2 * A * D^-1/2。用 scipy 在预处理阶段算好,存成稀疏张量,每次训练只做一次前向传播。

import scipy.sparse as sp import numpy as np def normalize_adj(adj): """D^-1/2 * A * D^-1/2""" adj = adj + sp.eye(adj.shape[0], format="csr") # 加自环 d = np.array(adj.sum(axis=1)).flatten() d_sqrt_inv = np.power(d, -0.5) d_sqrt_inv[np.isinf(d_sqrt_inv)] = 0.0 d_mat = sp.diags(d_sqrt_inv) norm_adj = d_mat @ adj @ d_mat return norm_adj.tocoo() norm_adj = normalize_adj(adj) # 转成 torch 稀疏张量 idx = torch.LongTensor(np.vstack([norm_adj.row, norm_adj.col])) val = torch.FloatTensor(norm_adj.data) adj_t = torch.sparse_coo_tensor(idx, val, torch.Size(norm_adj.shape))

参数说明:加自环是为了让每个知识点在传播后保留自身信息,这也是 GCN 的标准做法。注意np.power(d, -0.5)在 d=0 时会产生 inf,必须用np.isinf归零,否则后面 torch.spmm 直接报 NaN。边界节点在稀疏数据里很常见,尤其是出现次数少的知识点。

GCN 前向传播代码挂在模型里:

def forward_gcn(self, x, adj_t): for layer in self.gcn_layer: x = torch.spmm(adj_t, x) # [N, d] 图传播 x = layer(x) return x

这里的 x 是 emb_skill.weight,也就是所有知识点的 embedding 矩阵。GCN 每层都做一次“邻居表征加权求和 + 非线性变换”,两层之后,每个知识点的表征就包含了它一步和两步邻居的信息。这就是 GIKT 优于 DKT 的关键:DKT 的知识点关系全靠隐式学习,GIKT 直接把它显式写进图结构里,尤其在长尾知识点上不容易跑偏。

3.3 序列编码与掌握概率预测

模型前向传播的输入是三样:答题序列里的知识点 id、答题对错标签、归一化后的邻接张量。一步到位看代码。

def forward(self, skill_seq, correct_seq, adj_t): """ skill_seq: [B, T] 知识点 id 序列 correct_seq: [B, T] 0/1 对错序列 adj_t: [N, N] 归一化稀疏邻接矩阵 """ B, T = skill_seq.size() # 1) 所有知识点通过 GCN 得到结构增强表征 skill_rep = self.forward_gcn(self.emb_skill.weight, adj_t) # [N, d] # 2) 取出本序列涉及的知识点表征 cur_skill = skill_rep[skill_seq] # [B, T, d] # 3) 答对用 skill_rep,答错用 emb_fail,组成交互向量 correct = correct_seq.float().unsqueeze(-1) # [B, T, 1] interact = correct * cur_skill + (1 - correct) * self.emb_fail(skill_seq) # 4) GRU 编码完整交互历史 state, _ = self.gru(interact) # [B, T, d] # 5) 拼接当前知识点表征与历史状态,预测正确率 logit = self.pred_fc(torch.cat([state, cur_skill], dim=-1)).squeeze(-1) return logit # [B, T]

逻辑说明:第 3 步用 correct 做了软开关,答对那一步输入的是“该知识点的结构表征”,答错输入的是“失败表征”,GRU 就能从轨迹里区分“同样是三次作答,顺序是 对-对-错 还是 错-错-对”。第 5 步把历史状态和当前知识点表征拼起来做二分类,是因为预测正确率不仅取决于历史掌握度,也取决于当前题对应哪个知识点,两者缺一不可。整套代码跑通之后,logit 再经过 sigmoid 就是模型预测的答对概率。

4. 训练、评估与推荐策略:从预测概率到下一道习题

模型结构确定之后,训练和评估协议决定这个模型能不能在真实场景里立住。这一章给训练循环、切分评估协议,以及最后怎么把预测概率翻译成推荐列表。

4.1 训练循环与梯度裁剪

GIKT 的序列用 GRU 编码,梯度容易在时间步上爆炸,所以梯度裁剪是标配。损失函数用二分类交叉熵,只计算有真实作答的位置,padding 位置必须屏蔽。

import torch.nn.functional as F def train_one_epoch(model, loader, optimizer, adj_t, device="cuda"): model.train() total_loss = 0.0 for batch in loader: skill_seq = batch["skill_seq"].to(device) # [B, T] correct_seq = batch["correct_seq"].to(device) # [B, T] mask = batch["mask"].to(device) # [B, T] logit = model(skill_seq, correct_seq, adj_t) # [B, T] loss = F.binary_cross_entropy_with_logits( logit, correct_seq.float(), reduction="none" ) # 只统计有效位置 loss = (loss * mask).sum() / mask.sum().clamp(min=1.0) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() return total_loss / max(len(loader), 1)

注意 mask 要在 DataLoader 的 collate_fn 里同步生成,padding 位置 mask=0。裁剪的 max_norm 我一般取 5.0,太小会让模型学不动,太大起不到保护作用。

4.2 评估协议:按练习计算 AUC 与 ACC

评估时同样只统计有效位置。AUC 能反映排序能力,ACC 能反映预测的绝对正确率,两个都要看,因为推荐场景更依赖 AUC,而“这个学生到底会不会”更依赖 ACC。

from sklearn.metrics import roc_auc_score, accuracy_score @torch.no_grad() def evaluate(model, loader, adj_t, device="cuda"): model.eval() y_true, y_pred = [], [] for batch in loader: skill_seq = batch["skill_seq"].to(device) correct_seq = batch["correct_seq"].to(device) mask = batch["mask"].to(device) logit = model(skill_seq, correct_seq, adj_t) prob = torch.sigmoid(logit) y_true.extend(correct_seq[mask].cpu().tolist()) y_pred.extend(prob[mask].cpu().tolist()) auc = roc_auc_score(y_true, y_pred) acc = accuracy_score(y_true, (np.array(y_pred) > 0.5).astype(int)) return auc, acc

4.3 把掌握概率变成推荐列表

知识追踪模型输出的不是“推荐分数”,而是“每个习题的答对概率”。推荐系统要把它变成可落地的排序策略,我常用的方法是给每个知识点聚合一个“掌握度”:在该知识点最近的 N 次作答中取预测概率均值,然后推荐“掌握度低于及格线但又不是完全不会”的知识点对应习题。

def recommend_next_exercise(model, user_seq, user_corrects, adj_t, candidate_pool, device="cuda"): """user_seq: 最近答题知识点 id 列表""" model.eval() with torch.no_grad(): skill_seq = torch.LongTensor([user_seq]).to(device) correct_seq = torch.LongTensor([user_corrects]).to(device) logit = model(skill_seq, correct_seq, adj_t) probs = torch.sigmoid(logit)[0].cpu().numpy() # [T] # 聚合到知识点:最近作答的平均掌握概率 mastery = {} for s, p in zip(user_seq, probs): mastery[s] = mastery.get(s, []) + [p] for s in mastery: mastery[s] = float(np.mean(mastery[s])) # 推荐最薄弱的知识点,再在该知识点下选题目 weak = sorted(mastery.items(), key=lambda x: x[1])[:5] candidates = candidate_pool[weak[0][0]] # 知识点对应的习题池 return candidates[:10], weak

关键点:推荐的“最近发展区”一般落在 0.4~0.7 之间,完全没掌握(<0.3)的题推给学生很容易挫败,掌握度接近 1 的题再推没有学习价值。candidate_pool 的构建方式因题库而异,记住一个原则:先按知识点定位薄弱项,再在习题池里筛难度匹配的题,而不是直接拿全量题库排序。

5. GIKT 实战避坑:5 个让模型翻车的常见问题

这章写我在这类项目里反复踩过的坑,按“现象 → 原因 → 解决”记录。每一个都值得在动手前先看一遍。

5.1 长序列截断后预测全崩

现象:用完整序列训练时指标正常,部署后发现线上预测值和离线差距很大;检查发现线上用户历史很长,截断到最近 50 条之后,很多早期关键作答直接丢失。

原因:评测时验证集用户的平均序列长度可能只有 20,模型没见过长序列,而线上真实用户动辄几百条,截断策略不一致导致分布偏移。

解决:训练和预测必须使用同一种截断策略。我后来把 MAX_LEN 统一为 50,并且在验证集里故意保留一部分超过 100 条作答的用户做压力测试,确认模型在长序列上不会漂移。

提示:简单粗暴地把所有序列都保留,只做 padding 不截断,会让 GRU 在 200 步以后梯度衰减严重,训练时间也翻倍。优先选择截断 + 压力测试。

5.2 图正则化参数调不好,模型反而欠拟合

现象:GIKT 的图传播加了很强的 L2 正则后,训练 loss 降不下去,AUC 比不上不加图的普通 GRU 基线。

原因:知识点的共现图本身含噪,尤其是用窗口共现构建的图,很多边是“偶然一起出现”而不是真正的先决关系,强正则会把有效信号也压掉了。

解决:我一般把图相关的正则权重初始化到 1e-3,每次训练先看训练集 AUC 是否持续上升,若 5 个 epoch 不动就降一个量级;另外在构建图时提高 min_cnt 阈值,用 5 以上过滤低质量边,比单纯调正则更有效。

5.3 测试集随机切导致 AUC 虚高

现象:上线前离线 AUC 0.82,上线后实际推荐效果很差,点击率提升几乎为零。

原因:数据行数级随机切分导致同一个学生的作答同时出现在训练和验证集,模型“见过”这个学生的部分答案,验证指标严重失真。

解决:严格按用户切分,且保证验证集用户在训练阶段完全不存在。我后来连图构建都只用训练集统计,验证集仅用来评估,指标直接下降了近 0.1,但真实效果和线上表现一致了。

5.4 部署时 skill_id 对不上导致预测结果错位

现象:离线脚本一切正常,部署到推荐服务后,同一个学生同一批数据,预测结果和离线对不上,且错误集中在某些知识点上。

原因:离线预处理把 skill_id 重编码成 0 到 N-1 的连续 id,部署接口收到的是题库原生 skill_id,没有查映射表就直接喂进模型,id 错位导致 embedding 张冠李戴。

解决:训练完把 id 映射表保存成 json,部署时加载同一份映射。

import json skill_mapping = dict(enumerate( raw["skill_id"].astype("category").cat.categories.tolist() )) # 保存映射:skill_id -> s_idx with open("skill_mapping.json", "w") as f: json.dump({str(v): k for k, v in skill_mapping.items()}, f)

部署时收到前端传的 skill_id,先查这份 json 得到 s_idx,再进模型。这个坑最隐蔽,也最容易在测试环境漏掉,因为本地数据集的 skill_id 恰好是连续数字,线上不是。

5.5 稀疏矩阵原地修改导致训练数据被污染

现象:训练到第二个 epoch 时 loss 突然变成 NaN,第一个 epoch 还是正常的。

原因:我在数据预处理时复用了同一个邻接矩阵变量,某次在模型 forward 里做了adj_t = adj_t + eye等原地操作,稀疏张量的 data 被反复累加,数值越滚越大直接溢出。

解决:所有对 adj 的归一化、加自环、对称化操作全部放在预处理里完成,模型 forward 只读不改。torch 稀疏张量对原地操作的支持不稳,宁可多复制一份也不要图省事。

import copy adj_t_infer = copy.deepcopy(adj_t) # 给推理单独留一份

6. 部署到推荐服务:模型导出、API 封装与上线前验证

训练完成后,模型要变成线上可调用的推荐服务。我习惯用 FastAPI 做一层薄封装,因为它的异步能力和 pydantic 校验正好够用,又不需要引入额外框架。部署前第一件事是把模型权重和之前保存的 skill_mapping.json 打包在一个目录里,保证回滚时版本对应。

torch.save(model.state_dict(), "models/gikt_weights.pt") # 训练用的邻接矩阵也存下来 torch.save(adj_t, "models/adj_t.pt")

线上接口只接收最近一次作答序列,推荐服务内部负责映射 id、查图、推理和取候选题库。

from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class RecommendRequest(BaseModel): user_id: int skill_seq: list[int] correct_seq: list[int] @app.post("/recommend") def recommend(req: RecommendRequest): s_idx = [mapping[str(s)] for s in req.skill_seq] prob, weak = recommend_next_exercise( model, s_idx, req.correct_seq, adj_t_infer ) return {"prob": prob, "weak_skills": weak}

上线前务必用最近一周的真实日志回放一遍,对比新旧模型给同一用户推荐的重叠度;重叠度低于 30% 时先查 id 映射和序列截断,再怀疑模型本身。

我自己的习惯是每次训练都保留一份 weights、一份 mapping、一份图结构、一份数据预处理脚本,四个文件同版本号打包。这套流程帮我省过无数次“三个月后模型要回滚,但根本不知道当时的 id 映射是哪份”的尴尬。希望帮到你。

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

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

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

立即咨询