☰
GIKT深度知识追踪:图网络与交互信号驱动的习题推荐模型
2026/9/28 14:37:44 网站建设 项目流程

简介:一套基于GIKT深度知识追踪的个性化习题推荐系统Python实现方案,面向计算机科学与技术、人工智能等专业学生,适用于毕业设计、期末大作业或机器学习课程实践。系统利用深度学习模型分析学习者历史答题序列,动态追踪知识掌握状态,并据此生成个性化习题推荐,功能覆盖数据预处理、模型训练、推荐算法实现及结果可视化等完整流程。资源包共72个文件,以Python源文件、Vue前端组件、JavaScript脚本、SQL数据文件为主,另有配置文件、图表与说明文档,压缩包约10.97MB,目录分层清晰,便于定位与二次开发。目前已有58人下载学习。项目中包含经过调试的源代码、配套操作指南、超参数优化策略和模型评估指标,方案经过学术导师审核并获优秀评价,提供可复现的教育数据挖掘实验基准,能帮助读者快速搭建可运行的推荐原型,理顺从数据处理到前端展示的完整技术链路。

1. GIKT深度知识追踪:把“错题”也变成图上的信号,而不是噪声

做过在线习题推荐的人基本都会撞上同一个瓶颈:协同过滤只知道学生做过什么、对错如何,却说不出“这个学生到底卡在哪个知识点上”。于是大家转向知识追踪(Knowledge Tracing),其中最常被拿来当基线的 DKT 用 RNN 读答题序列,GKT 则把知识点画成一张图,让状态在图上传播。GIKT(Graph-based Interactive Knowledge Tracing)在 GKT 的基础上补上了“交互”这条腿——它不只把知识点之间的关系建出来,还把“答对/答错”作为两种不同的信号分别注入图传播,从而更接近真实掌握度的变化过程。这篇内容适合正在做自适应学习、题库推荐、学情分析,并且准备用 Python 落地一版可训练、可评估模型的人。你会看到从数据清洗、知识点图构建、PyTorch 模型实现,到最终把掌握度换算成推荐分数的完整路径。

2. GIKT为什么能追上真实掌握度:图结构、交互边与状态更新的设计

2.1 为什么 DKT 和 GKT 不够:序列模型看不到知识点关系、静态图看不到作答结果

DKT 的核心是用 RNN/LSTM 把学生的答题记录当成一条时间序列,每个时刻输入当前题目的知识点编号和作答对错,输出一个对所有知识点的掌握度向量。它在小规模数据上跑得很快,但有个天然缺陷:知识点之间是相互独立的,RNN 只能通过隐状态隐式地“猜测”知识点之间的关系。比如学生做错了“一元二次方程求根”,DKT 知道这个学生在这道题上不行,但它不会主动把这次错误传播到“判别式”“配方法”这些强相关知识点上——除非训练数据里恰好出现了大量这样的共现。

GKT 引入了先验知识点图,让每个时间步的隐藏状态先沿图结构做一次消息传递,再做更新。图结构解决了“知识点关系”的问题,但 GKT 的传播过程通常对所有边一视同仁:一道题答对了和答错了,产生的都是“该学生在这个知识点上有反应”这个消息。这在实际学习过程中并不成立,答对一道题说明该知识点大概率已掌握,答错一道题说明存在漏洞,这两种反馈对知识状态的修正方向几乎是相反的。把二者混在同一个图传播里,会让最后的掌握度估计整体向中间值塌缩。

GIKT 的做法是把“交互”作为和“图结构”并列的一等公民。每一道题作答后,先根据对错生成不同的消息,再把这个消息写到对应知识点节点上,随后才沿知识点图扩散。扩散之后,邻居节点的状态更新带上了“这个学生刚在相邻知识点上栽过跟头”的上下文信息。这也解释了为什么 GIKT 在题目覆盖知识面广、知识点关联紧密的数据集上,通常比 DKT 和 GKT 的 AUC 高出一截。

2.2 知识点关系图:人工定义、共现统计、还是端到端学习

知识点图是整个 GIKT 模型的骨架,建图方式直接决定模型上限。我实际用下来有三种建图路线,适合不同阶段的项目。

第一种是人工定义依赖图,由教研老师把知识点之间的前置关系标注出来,比如“分数运算”指向“分式方程”,“勾股定理”指向“三角函数”。这种方式最干净,可解释性最好,但是维护成本高,一个学科几十个知识点还能手工维护,扩展到几百个知识点时几乎不可能靠人去标全。

第二种是共现统计法,纯粹从数据中挖。统计同一个学生在相邻答题记录里出现的知识点对,两个知识点在同一条答题序列中共同出现的次数超过阈值,就认为它们之间存在边;也可以用 Jaccard 相似度计算两个知识点被同一批学生作答的重合度。这种方法的优点是零人工成本,缺点是统计出来的边可能是噪声,比如某次考试卷面固定把两个无关知识点放在相邻位置,统计就会给它们连上假边。

第三种是端到端可学习图,把邻接矩阵当作可训练参数,让模型在训练过程中自己调整边的权重。这种方案最灵活,但需要较大的数据量,而且容易过拟合到训练集上。对于大多数中小规模项目,我的建议是:先拿共现统计图跑通 baseline,同时把人工标注的强依赖边以高权重叠加进去,形成“先验+数据”的混合邻接矩阵。GIKT 的交互消息通路对图质量比较敏感,图太稀疏会让消息根本传不出去,图太稠密又会让所有知识点状态趋同,所以在建图后一定要检查节点度分布,避免出现大批度数为 0 的孤立节点。

2.3 交互信息的三种引入方式:边类型、交互向量、门控调制

在 GIKT 的框架下,“交互”具体怎么进入模型,有不同做法。最简单的是边类型方案:把正确作答和错误作答分别定义为图上两种边,构建两个独立的消息变换矩阵,各自处理后再合并到目标节点。这种做法的优点是实现简单、训练稳定,也是我推荐作为第一版落地的方案。

第二种是交互向量方案:为“答对”和“答错”分别初始化一个可学习向量,把当前作答的知识点嵌入与交互向量拼接或相加,再经过一个线性层生成消息。相对边类型方案,交互向量的表达更灵活,因为它可以和知识点嵌入做更细粒度的组合,代价是参数量稍有增加。

第三种是门控调制方案,把交互结果作为门控信号,控制状态更新时“保留多少旧状态、写入多少新信息”。这个做法最接近真实记忆机制:答对一道题,可能只是巩固了已有知识,状态不该剧烈变化;答错一道题,说明原有知识结构出了问题,需要较大的状态修正。门控方案通常在数据量充足时效果最好,但对学习率和初始化比较敏感,新手容易在这里翻车。

我的看法是:不要一上来就追求最复杂的门控方案。先用边类型方案把整体训练链路、数据流和评估脚本跑通,再逐步换成交互向量或门控。这样出了问题,你能清晰地判断是交互注入方式的问题,还是上游数据、下游评估的问题。

3. 先把原始答题记录洗成模型能吃的序列:格式、切分与知识点图构建

3.1 最小可用数据字段:student_id、exercise_id、knowledge_point、response

GIKT 需要的数据并不复杂,最少四列就能训练:学生 ID、习题 ID、知识点 ID、作答结果。很多团队一开始会试图把题目文本、选项内容、作答用时全部塞进模型,结果数据预处理做了一周,模型效果并没有变好。对于第一版,我只保留这四列,外加一个时间戳列用于排序。

拿到原始 CSV 后的第一步是清洗。作答结果必须是 0/1,缺失值直接剔除,同一个学生同一道题重复作答的记录只保留最后一次。知识点编号需要统一映射成从 0 开始的连续整数,因为后面建邻接矩阵和嵌入层都要求索引连续。习题 ID 在这一版里其实用不上,模型预测的是“学生对某一知识点的掌握概率”,而不是“学生能不能做对这一道具体题目”,但保留习题 ID 有助于后面做难度校准和推荐去重。

import pandas as pd df = pd.read_csv("answer_log.csv", encoding="utf-8") df = df.dropna(subset=["student_id", "exercise_id", "knowledge_point", "response"]) df["response"] = df["response"].astype(int) kp_list = df["knowledge_point"].unique().tolist() kp2id = {kp: i for i, kp in enumerate(kp_list)} df["kp_id"] = df["knowledge_point"].map(kp2id) df = df.sort_values(["student_id", "timestamp"]).reset_index(drop=True) print(df[["student_id", "exercise_id", "knowledge_point", "kp_id", "response"]].head())

这段代码把原始日志映射成模型输入的最底层结构。kp2id是知识点到连续索引的字典,后续构建邻接矩阵和嵌入层时都要用到它;sort_values按学生和时间戳排序,保证后面的序列切分是按真实答题顺序进行的。注意这里timestamp可能是字符串格式,建议先统一成datetime类型,否则跨天记录排序会出错。

3.2 先把数据洗成三张表:知识点索引、邻接矩阵、交互序列

在动手写模型之前,我习惯先把中间产物固化成三张表:知识点索引表、邻接矩阵、交互序列数组。这样做的好处是,后续调模型时不需要每次都重新跑一遍数据清洗,而且便于用可视化工具检查图结构和序列分布。

知识点索引表就是从知识点原文到kp_id的映射表,可以直接从上面的kp2id转成 DataFrame 存成 CSV。邻接矩阵按 2.2 节的思路构建,这里给出共现统计法的代码。共现的统计窗口和阈值很关键,窗口太大会把无关知识点连起来,阈值太低会得到稠密图。

import numpy as np from itertools import combinations def build_cooccur_graph(df, kp_num, window_size=20, min_co=5): co_matrix = np.zeros((kp_num, kp_num), dtype=np.float32) for stu_id, group in df.groupby("student_id"): kps = group["kp_id"].values for i in range(len(kps)): # 只统计当前题目往前 window_size 条记录内的共现 start = max(0, i - window_size) for j in range(start, i): a, b = int(kps[j]), int(kps[i]) if a == b: continue co_matrix[a, b] += 1.0 co_matrix[b, a] += 1.0 adj = (co_matrix >= min_co).astype(np.float32) # 加自环,避免节点在传播时丢失自身信息 adj = adj + np.eye(kp_num, dtype=np.float32) return adj adj = build_cooccur_graph(df, len(kp2id), window_size=20, min_co=5) print("孤立节点数:", int((adj.sum(axis=1) == 1).sum()))

共现矩阵co_matrix统计的是“同一个学生在一个滑动窗口内做过这两个知识点”的次数。min_co=5表示两个知识点至少在 5 个窗口内共同出现才建边,这个值需要根据数据规模调整:数据量大时可以调高到 10~20,数据量小时降到 2~3,否则图会太稀疏。加自环是必须的,它保证了孤立节点至少还能更新自己。这里我顺带打印孤立节点数,这是我每次建图后都会看的一个硬指标——孤立节点占比超过 10%,模型的 AUC 基本不会好看。

3.3 序列切分与左 padding:为什么 max_seq_len 取 30~60

GIKT 的训练样本是一段答题历史和一个预测目标。我采用的做法是滑动窗口:对每个学生,从第二条记录开始,每次取前max_seq_len条记录作为历史,预测这一条记录的作答结果。这样一条学生记录能产生几十条训练样本,数据利用率高。

max_seq_len的取值直接影响训练速度和效果。取得太短,模型看不到足够久远的知识状态;取得太长,早期信息早就被后续作答冲淡了,白白增加计算量。我一般取 30~60,具体看平均答题序列长度。序列很短的学生(少于 10 条记录)产生的样本极少,这类学生在样本里占比过高时,可以考虑把最短序列长度下限设为 5。

def build_sequences(df, max_seq_len=50): seqs, resp_seqs, targets, target_resps, masks = [], [], [], [], [] for stu_id, group in df.groupby("student_id"): kp_seq = group["kp_id"].values resp_seq = group["response"].values if len(kp_seq) < 2: continue for i in range(1, len(kp_seq)): start = max(0, i - max_seq_len) x_kp = kp_seq[start:i].tolist() x_resp = resp_seq[start:i].tolist() y_kp = int(kp_seq[i]) y_resp = int(resp_seq[i]) pad_len = max_seq_len - len(x_kp) mask = [0] * pad_len + [1] * len(x_kp) seqs.append([0] * pad_len + x_kp) resp_seqs.append([0] * pad_len + x_resp) targets.append(y_kp) target_resps.append(y_resp) masks.append(mask) return (np.array(seqs), np.array(resp_seqs), np.array(targets), np.array(target_resps), np.array(masks))

这里target_kp是当前要预测的知识点编号,target_resp是真实作答结果,也就是训练时的标签。左侧 padding 用 0 填充,知识点 0 在嵌入层里需要单独留一个padding_idx,避免它和真实知识点 0 混淆。mask在模型前向里用来过滤 padding 位置,一般有两种用法:一是直接在模型里跳过 mask 全为 0 的时间步,二是在 loss 计算时把 padding 位置权重设为 0。这里建议用第二种——代码更简单,而且 PyTorch 的BCEWithLogitsLoss支持传入weight张量。

4. 用 PyTorch 实现 GIKT 模型:图交互卷积层、状态更新与训练循环

4.1 模型结构总览:状态矩阵、消息通路、图传播

模型的核心是一个按时间步循环更新的知识点状态矩阵。具体来说,每个学生有一张N×D的状态表,N是知识点数量,D是隐藏维度,每一行代表该生在某一个知识点上的掌握状态。每次作答后,先根据答案对错生成消息,写入当前知识点对应的那一行,再把整张状态表沿邻接矩阵做一次图传播,最后用目标知识点对应的状态向量预测作答正确率。

这个设计里有三个关键参数:隐藏维度D、交互向量维度I、邻接矩阵A。D决定模型容量,一般取 64~128;I是“答对/答错”这个信息的编码维度,取 16~32 就够用;邻接矩阵在训练前固定,不参与梯度更新。整个模型的参数量主要来自知识点嵌入层和图传播层的两个线性变换,相比 Transformer 类模型轻量得多,单卡 GPU 训练没什么压力。

4.2 GIKT 核心模块代码:交互消息与图传播

下面给出 GIKT 的核心模块代码。这个实现采用“两种边 + 两种消息变换”的方案:答对和答错分别走不同的线性层生成消息,这样模型能显式学到“对/错”两种反馈对知识状态的不同影响。

import torch import torch.nn as nn import torch.nn.functional as F class GIKTCell(nn.Module): """一个时间步里的交互消息生成与图传播""" def __init__(self, hidden_dim, interaction_dim): super().__init__() self.msg_correct = nn.Linear(hidden_dim + interaction_dim, hidden_dim, bias=False) self.msg_wrong = nn.Linear(hidden_dim + interaction_dim, hidden_dim, bias=False) self.gate = nn.Linear(hidden_dim * 2, hidden_dim) self.update = nn.GRUCell(hidden_dim, hidden_dim) def forward(self, h_state, kp_emb, inter_emb, resp, adj): # kp_emb: [B, D],当前作答知识点的嵌入 # inter_emb: [B, I],当前作答结果(对/错)的交互向量 feat = torch.cat([kp_emb, inter_emb], dim=-1) # [B, D+I] msg = torch.where(resp.unsqueeze(1) > 0, self.msg_correct(feat), self.msg_wrong(feat)) # [B, D] # 消息写入当前知识点节点 B, N, D = h_state.shape kp_idx = kp_idx.unsqueeze(1).unsqueeze(2).expand(B, 1, D) h_state = h_state.scatter_add(1, kp_idx, msg.unsqueeze(1)) # 图传播:目标节点聚合邻居状态,adj 是 N×N h_agg = torch.einsum("bnd,nm->bmd", h_state, adj) # 门控融合:保留一部分原状态,吸收一部分邻居信息 gate = torch.sigmoid(self.gate(torch.cat([h_state, h_agg], dim=-1))) h_state = gate * h_state + (1 - gate) * h_agg return h_state

上面的代码被刻意简化成“一个 Cell”,实际训练时我会把这个 Cell 放进一个按时间步循环的模块里。scatter_add把生成的消息累加到当前作答知识点对应的状态行上,累加而不是覆盖,是为了保留该知识点之前的状态痕迹。然后einsum做一步图传播,把邻居节点的状态加权汇入自身。门控融合里gate的维度是[B, N, D],取sigmoid后逐元素控制新旧信息的比例。

这个 Cell 的缺点是kp_idx变量没有作为参数传入,你在自己实现时需要把当前作答知识点索引作为输入。下面给出完整的时间步循环模型。

class GIKT(nn.Module): def __init__(self, num_kps, hidden_dim, adj, interaction_dim=16): super().__init__() self.num_kps = num_kps self.hidden_dim = hidden_dim self.kp_emb = nn.Embedding(num_kps + 1, hidden_dim, padding_idx=0) self.inter_emb = nn.Embedding(2, interaction_dim) self.cell = GIKTCell(hidden_dim, interaction_dim) self.pred_head = nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1), ) self.register_buffer("adj", torch.tensor(adj, dtype=torch.float32)) def forward(self, kp_seq, resp_seq, mask, target_kp): B, T = kp_seq.shape N = self.num_kps h_state = torch.zeros(B, N, self.hidden_dim, device=kp_seq.device) for t in range(T): kp_emb = self.kp_emb(kp_seq[:, t]) # [B, D] inter_emb = self.inter_emb(resp_seq[:, t]) # [B, I] h_state = self.cell(h_state, kp_emb, inter_emb, resp_seq[:, t], self.adj) # 取目标知识点的最终状态做预测 h_target = h_state.gather( 1, target_kp.unsqueeze(1).unsqueeze(2).expand(B, 1, self.hidden_dim) ) # [B, 1, D] logit = self.pred_head(h_target).squeeze(-1) # [B, 1] return logit

kp_emb的 padding_idx 设为 0,所以 padding 位置的知识点嵌入是 0 向量,不会对状态更新产生有效信号。但前面说 padding 会影响循环,所以真正训练时一般会把长序列截断到最短长度,或者用 mask 控制循环步数。我这里保留 mask 参数是为了接口完整,实际使用时如果全部样本都比较长,可以在预处理阶段直接去掉 padding,只保留每个 batch 内的最大真实长度,效率更高。

4.3 训练循环与超参数:batch_size、学习率、梯度裁剪

训练部分相对常规,但有几个参数值得单独说明。首先 loss 用BCEWithLogitsLoss,因为模型的输出是 logit 而不是概率,数值稳定性比先sigmoid再算交叉熵好。优化器我用 Adam,初始学习率 1e-3,配合梯度裁剪max_norm=5.0。GIKT 的消息通路涉及图传播,梯度经过邻接矩阵多次乘法后容易爆炸,梯度裁剪可以说是必需品。

from torch.utils.data import TensorDataset, DataLoader import torch.optim as optim seqs, resp_seqs, targets, target_resps, masks = build_sequences(df, max_seq_len=50) seqs = torch.LongTensor(seqs) resp_seqs = torch.LongTensor(resp_seqs) targets = torch.LongTensor(targets) target_resps = torch.FloatTensor(target_resps) masks = torch.FloatTensor(masks) dataset = TensorDataset(seqs, resp_seqs, masks, targets, target_resps) loader = DataLoader(dataset, batch_size=256, shuffle=True, num_workers=4) model = GIKT(num_kps=len(kp2id), hidden_dim=64, adj=adj, interaction_dim=16) opt = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5) loss_fn = nn.BCEWithLogitsLoss() for epoch in range(30): model.train() total_loss = 0.0 for batch in loader: kp_seq, resp_seq, mask, target_kp, target_resp = batch logit = model(kp_seq, resp_seq, mask, target_kp) loss = loss_fn(logit, target_resp.view(-1, 1)) opt.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) opt.step() total_loss += loss.item() print(f"epoch {epoch + 1}, loss: {total_loss / len(loader):.4f}")

batch_size=256是一个比较稳的起点,如果显存有余裕可以提到 512;序列长度 50、隐藏维度 64 时,512 的 batch 在 8GB 显存的卡上跑得动。我这里把weight_decay设为 1e-5,作用不大,但能防止过拟合。训练时建议每轮在验证集上计算 AUC 并保存最佳模型,不要只看训练 loss 下降就以为模型在变好。

5. GIKT 训练与落地的五个必踩坑:从梯度爆到推荐稀碎

5.1 知识点图出现孤立节点,loss 从头到尾不降

现象:训练启动后 loss 基本不降,或者降得非常慢,验证集 AUC 一直在 0.5 附近徘徊。排查时会发现模型输出经常收敛到全 0 或全 1。

原因:邻接矩阵里存在大量全 0 行(不算自环的话),这些知识点节点在做图传播时收不到任何邻居消息,状态只能靠自身更新。如果训练样本里这类孤立知识点出现频率又高,模型就没法从它们身上学到有效特征。

解决:建图后第一时间打印孤立节点数。如果是共现统计建的图,调低min_co阈值或增大window_size;如果仍然有孤立节点,至少给每个节点随机连 2~3 个最相似的知识点作为“兜底边”。我一般还会检查邻接矩阵里的最大度,超过 50 的稠密节点会把传播平均化,这类也要处理。

5.2 正确/错误消息不平衡,错误边学到的是噪声

现象:训练完成后单独看msg_wrong的输出范数,发现它远小于msg_correct,或者两者几乎一样。

原因:大多数在线学习数据里,答对样本占 70% 以上,答错样本偏少。两条消息通路共用同一个 optimizer 和 loss,模型会自然倾向把答错通路“关小”,因为只要它对 loss 的贡献足够小,整体 loss 就能降。这会导致模型实际上退化成了 GKT,交互信息形同虚设。

解决:训练时按 batch 统计对错比例,对少数类样本做 loss 加权。具体做法是把BCEWithLogitsLoss的pos_weight设为负样本占比除以正样本占比。也可以用最简单的方式——在数据预处理时对答错样本做少量过采样,但注意不要在同一学生的连续序列里重复插入同一条记录,那会破坏时间依赖。

5.3 padding 的 0 被当成真实作答,短序列学生预测全部跑偏

现象:序列长度不足max_seq_len的学生,预测结果明显比长序列学生差,而且 padding 越多越差。

原因:左侧 padding 的知识点嵌入为 0,交互向量inter_emb会把 padding 位置的 0 也作为“作答结果 0”来编码。0 在交互向量表里实际上代表“答错”,于是 padding 位置被模型误解成“这个学生在这个知识点上答错了”。

解决:最可靠的做法是不做 padding,训练时每个 batch 只保留本 batch 内最长序列长度,模型内部按这个动态长度循环。如果必须 padding,就在循环里加上if mask[batch_idx, t] == 0: continue的判断。我实际项目里两种都用过,动态长度方案实现并不复杂,推荐直接采用。

5.4 直接拿掌握概率当推荐分,热门题目永远排在前面

现象:模型验证 AUC 达到 0.78 以上,但上线后推荐列表几乎全是学科热门知识点对应的题,学生反馈“推荐的题我都会,没意思”。

原因:掌握概率低的知识点大多分布在少数难点上,这些知识点本身出现在题库里的频率也高;而冷门知识点即使学生没掌握,因为样本少,模型估计的掌握概率也不会低。直接用1 - p作为推荐分,相当于把热门难点推给了所有人。

解决:推荐分必须加入题库侧信息。一个可用的组合是score = α * (1 - p) + β * log(1 + 1/difficulty) - γ * exposure_times。difficulty可以由题库自带,也可以按历史作答正确率统计;exposure_times是该学生最近见过这个知识点的次数,用来做去重和疲劳控制。α、β、γ 的比例用离线评估调,一般 α 在 0.5~0.7 之间,β 和 γ 从 0.1 开始往上提。

5.5 只盯 AUC,忽略了推荐侧的 Hit@K 和 NDCG@K

现象:模型跑了一个月,AUC 挺好看,但推荐列表的点击率、完成率没有明显提升。

原因:AUC 衡量的是“把答对和答错两类的排序能力”,它把每条答题记录当作独立样本。而推荐场景关心的是“推荐列表前 K 题里是否包含真正该练的题目”,以及“该练的题目是否排在前面”。两者相关,但不完全是一回事。

解决:评估时分两条线走。知识追踪侧看 AUC,推荐侧看 Hit@K 和 NDCG@K。具体做法是:对每个学生,把他接下来要做的题目当作 ground truth,模型生成 Top-K 推荐列表,看这题是否命中以及排名位置。K 先取 5 和 10 两个值。如果 AUC 高但 Hit@5 低,大概率是推荐排序部分出了问题,而不是知识追踪模型的问题。

6. 把掌握度变成习题推荐:从评分公式到离线验证

6.1 推荐评分公式:掌握度、难度、新颖度的组合

GIKT 模型输出的掌握概率p本身不能直接当推荐分,这是第 5 章反复强调的。我用过一个比较稳的评分公式,供你参考:

def build_rec_score(p_mastery, difficulty, exposure_times, alpha=0.6, beta=0.3, gamma=0.2, topk=10): need = 1.0 - p_mastery # 掌握度缺口,越大越该练 diff_penalty = np.log1p(np.array(difficulty)) # 越难得分越高 novelty = 1.0 / (1.0 + np.array(exposure_times)) score = alpha * need + beta * diff_penalty + gamma * novelty return np.argsort(-score)[:topk]

这里alpha=0.6意味着掌握度缺口仍然是主导因素,但不是唯一因素。difficulty用log1p压缩量纲,避免超难题完全压过掌握度信号;novelty是曝光次数的反函数,曝光越多推荐分越低。三个系数不是拍脑袋定的,我会在离线评估里用小网格搜索:α 取 0.4~0.7,β 取 0.2~0.4,γ 取 0.1~0.3,每个组合跑一次 Hit@5,取最高的那一组。

6.2 把候选知识点批量送进模型,避免逐题前向的耗时

推荐场景里需要考虑“这个学生接下来适合练哪个知识点”,而不是只预测一个固定知识点。最笨的办法是遍历题库里所有知识点,每个知识点都做一次完整的前向推理,这样计算量是候选数 × 序列长度的循环,学生量一大就扛不住。

我的做法是把候选知识点合并成一个 batch,共享同一段历史序列。具体做法是对每个推荐候选k,构造一条样本(相同的历史序列, target_kp=k),一次 forward 就能拿到所有候选知识点的掌握概率。PyTorch 的批次计算天然支持这种共享前段、不同后段的输入结构,实际耗时只比单次预测增加约 20%。

验证时用留一法:对每个学生,取最后一条作答记录作为测试目标,前面的所有记录作为历史序列。训练阶段不能把这条测试记录混进训练集,否则就是数据泄漏。跑通这个验证流程之后,我建议再做一个 A/B 小实验:对照组用“正确率排序”推荐,实验组用“GIKT 掌握度排序”推荐,观察一周的题均完成率和重做率。这一步能直接证明 GIKT 带来的价值是否值得投入。

最后说一个我的个人习惯:所有超参数第一次跑通后,我会把模型输出和中间状态保存一份 numpy 存档,包括知识点嵌入、状态矩阵、每个学生最近 50 条记录的预测概率。后续不管是调整推荐策略、做学情分析还是排查线上问题,这些存档都能让你少走很多弯路。GIKT 不是那种装上就能用的黑匣子方案,但当你把数据、图、交互三条链路都打通,它给出的掌握度确实比多数基线更接近真实学情。希望帮到你。

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

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

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

立即咨询