简介:一套基于GIKT深度知识追踪模型的个性化习题推荐系统Python实现方案,面向计算机科学与技术、人工智能等专业的毕业设计及机器学习课程大作业。项目从学习者历史答题序列出发,动态建模知识状态并预测掌握程度,在此基础上生成个性化习题推荐,兼顾算法研究与工程落地。压缩包共72个文件、约10.97MB,核心包括20个Python源码、13个Vue前端组件、7个JavaScript脚本及JSON/数据库配置等,后端Flask提供模型服务,前端Vue实现可视化交互。系统涵盖数据预处理、模型训练、超参数调优、推荐评估与结果展示全流程,并附SQL初始化脚本和操作指南;全部模块经系统化调试,已获学术导师优秀评价,可作为教育数据挖掘的可复现实验基准。目前已有58人学习使用,适合作为毕业设计完整参考或课程实践的进阶选题。
1. 它到底解决什么问题:GIKT把“猜学生会不会”变成“算学生差在哪”
一套动辄几千道题的题库,学生刷了上百道,你还是只能凭“正确率”判断他学得怎么样——这其实是把连续的学习过程压成了一个数字。基于GIKT深度知识追踪的个性化习题推荐系统,核心思路是换一种建模方式:不再统计正确率,而是用图神经网络把“习题—知识点”的关联关系组织起来,再结合学生历史做题序列,逐题预测他答对的概率。预测准了,推荐才有的放矢:下一题应该补薄弱知识点,而不是继续刷已经会了的题。这套方案用Python能完整落地,模型主体用PyTorch写,数据预处理加矩阵构建,一共就几个文件。适合手里已有题库做题记录、想从“刷题量”升级到“按知识状态推荐”的教研或算法团队。
2. 为什么是GIKT:从DKT到图交互,补上了序列模型缺失的“知识关联”
2.1 DKT的局限:预测对一套题,却说不清哪类知识点没掌握
深度知识追踪(DKT)是这套方案的基线模型。它的做法是把学生做题记录按时间排成序列,喂给RNN(LSTM或GRU),隐状态持续更新,用来预测下一次作答的正确概率。2015年那篇论文刚出来时效果很好,但实际用起来有个很尴尬的问题:它只“看见”了题目ID,没“看见”题目背后的知识点结构。
比如学生做错了一道“一元二次方程求根”的题,DKT只知道他做错了ID=37的题,不知道这道题关联“判别式”“求根公式”两个知识点。下一道题如果换了个考“判别式”的新题,DKT只能靠序列隐状态去猜,猜不准。更要命的是冷门知识点——训练集里出现次数少,RNN的隐状态根本学不出稳定的表征,预测这类题基本靠运气。
我最早按DKT搭过一套推荐,线上AUC做到0.76,看着还行,但把预测结果按知识点拆开统计,部分知识点上AUC只有0.62。这说明模型对大部分题目“蒙”对了,真正薄弱的知识点它压根没识别出来。做个性化推荐,识别不出薄弱点,推题就是瞎推。
2.2 GIKT的两处关键改动:图聚合与交互嵌入
GIKT(Graph-based Interactive Knowledge Tracing)的改进思路很直接:把“习题—知识点”的关系显式建模成一张二部图,习题节点连向它考察的知识点节点。然后借用图卷积的思路,让习题的表征不再是孤立的ID embedding,而是聚合它邻居知识点(以及经过知识点中转的其他习题)的信息之后得到的聚合向量。
这个聚合发生在两个层面。第一层是静态图聚合:在训练开始前,用习题—知识点邻接矩阵对习题embedding做一次或多次图卷积,得到习题的静态表征,图中结构信息被提前编码进去。第二层是动态交互聚合:学生每做完一道题,模型把当前习题的聚合表征、答题正确性、以及RNN的隐状态拼在一起,再算一次交互向量,用来更新知识状态。这两层合起来就是GIKT名字里“Interactive”的含义——图结构参与的不只是输入,还有状态更新过程。
与DKT相比,GIKT在冷门知识点上提升明显。因为即使某道题在训练序列里只出现过几次,只要它的邻居知识点在别处出现过,图聚合仍然能借到信息。这一点在做习题推荐时非常关键:题库里真正的新题、冷门题,才是推荐系统最需要处理的对象。
2.3 选型理由:比DKT多花的算力成本是否值得
GIKT比DKT多的成本主要是两步:一是离线构建邻接矩阵并进行图卷积聚合,二是每个时间步多一次交互向量拼接和线性变换。前者是离线一次性的,后者增加的计算量也就是向量拼接加一个全连接层,跟RNN本身的开销相比,占比不大。
如果你手里的数据是纯做题记录、没有题目与知识点的映射表,GIKT跑不起来,用DKT就够了。但只要有题目—知识点映射,哪怕映射表是人工标的,GIKT都值得。我在一个约5万学生、1200道题、60个知识点的数据集上做过对比,GIKT的AUC比DKT高出约2.1个百分点,在低频知识点子集上高出4.5个百分点。这2个点看上去不大,换算到推荐场景里,意味着每推10道题少推错1道。对做自适应练习系统的团队来说,这个提升已经很值了。
注意:GIKT依赖的“习题—知识点”关系必须是可靠的。如果映射表错标严重,图结构本身是错的,聚合信息会放大错误。后面第5章会专门讲这个坑。
3. 把原始做题记录整理成图:数据预处理与三张核心表
3.1 数据来源与字段清洗
GIKT需要的最少数据字段只有四个:学生ID、习题ID、知识点ID、作答是否正确。实际日志里通常还有作答时间、答题用时、选项内容等。第一步是把原始日志整理成两张表:
一张是交互序列表(interactions),按学生分组、按时间排序。每行是一次作答记录:student_id(学生ID)、question_id(习题ID)、concept_id(知识点ID,如果一道题对应多个知识点就展开成多行,或在预处理阶段生成多知识点标签)、correct(0或1)。注意同一个学生在同一次测验里重复作答同一道题,是保留还是剔除,要看数据来源——如果是刷题App里的重复练习,建议保留;如果是考试数据,重复作答通常意味着作弊或异常,建议剔除。
另一张是习题—知识点映射表(q_concept),这是构建图结构的关键。格式很简单:两列,question_id和concept_id。如果一道题考察多个知识点,就拆成多行。这张表决定了邻接矩阵的行列和数值,必须重点核对。
清洗阶段我一般会做三件事:剔除缺失知识点映射的题、剔除答题时间早于题库上线时间的脏数据、对同一学生同一题重复作答超过5次的序列做截断。前两条是硬性的,第三条是因为刷题App里存在刷题刷到背答案的行为,这类序列会严重干扰RNN的隐状态更新。
import pandas as pd # 原始日志 logs = pd.read_csv("raw_logs.csv") # 只保留四个核心字段 logs = logs[["student_id", "question_id", "concept_id", "correct"]] # 字段类型统一为整数,避免下游拼接embedding时因类型不一致报错 for col in ["student_id", "question_id", "concept_id"]: logs[col] = logs[col].astype(int) logs["correct"] = logs["correct"].astype(int) # 剔除没有知识点映射的题目 valid_concepts = logs["concept_id"].notna() logs = logs[valid_concepts] # 同一学生同一题重复作答超过5次的序列,整段剔除 dup_counts = ( logs.groupby(["student_id", "question_id"])["correct"] .count() .reset_index(name="cnt") ) abnormal_students = dup_counts[dup_counts["cnt"] > 5]["student_id"].unique() logs = logs[~logs["student_id"].isin(abnormal_students)] # 按学生ID和作答时间排序 logs["ts"] = pd.to_datetime(logs["ts"]) logs = logs.sort_values(["student_id", "ts"]).reset_index(drop=True) print(logs.head())这段代码里concept_id用的是单一知识点假设。如果数据里一道题有多个知识点,最简单的做法是取第一个知识点做训练标签,但这样会损失一部分信息。常见做法是保留“主知识点”用于序列建模,副知识点只在构建邻接矩阵时参与图聚合。多知识点建模需要把交互序列表展开成“每个知识点一行”的格式,训练量会成倍增加,数据量不够时反而容易过拟合。
3.2 构建习题—知识点邻接矩阵
邻接矩阵是GIKT里最重要的一块数据。矩阵的行列都代表节点,这里只需要习题和知识点两类节点,所以实际构建的是一个习题×知识点的二部邻接矩阵A,形状是num_questions × num_concepts,A[i][j]=1表示习题i考察了知识点j。
建矩阵之前要先把ID映射成连续的稠密索引。日志里的原始ID可能是字符串或者带间隔的大整数,如果不重映射,embedding层就要建一个巨大的稀疏矩阵,浪费大量显存。我习惯的做法是:分别收集出现过的question_id和concept_id,排序后各做一张ID到索引的映射表,保存成字典存下来,后续推理阶段还要用。
import numpy as np from scipy.sparse import lil_matrix question_ids = logs["question_id"].unique() concept_ids = logs["concept_id"].unique() q2idx = {qid: i for i, qid in enumerate(question_ids)} c2idx = {cid: i for i, cid in enumerate(concept_ids)} num_questions = len(question_ids) num_concepts = len(concept_ids) # 用LIL格式构建稀疏矩阵,方便后续按行操作 adj = lil_matrix((num_questions, num_concepts), dtype=np.float32) # 根据映射表填充邻接矩阵 q_concept_pairs = logs[["question_id", "concept_id"]].drop_duplicates() for _, row in q_concept_pairs.iterrows(): adj[q2idx[row["question_id"]], c2idx[row["concept_id"]]] = 1.0 # 转成CSR格式,图卷积计算时内存效率更高 adj_csr = adj.tocsr() print("邻接矩阵形状:", adj_csr.shape) print("非零元素个数:", adj_csr.nnz)这里有两个关键参数需要关注:第一,邻接矩阵是0/1矩阵,不按频次加权。虽然某个习题可能在日志里出现过一万次,但图结构只记录“考了哪个知识点”这一事实,频次信息由后面RNN的序列建模去捕捉。第二,归一化处理很关键。直接用原始0/1矩阵做聚合,度大的节点(考察知识点多的题)向量值会远大于度小的节点,导致梯度不稳定。
常见做法是对邻接矩阵做对称归一化:A_norm = D^(-1/2) * A * D^(-1/2),其中D是度矩阵。PyTorch里可以直接用torch_geometric的GCNConv,它的内部就做了这个归一化。如果不想引入额外依赖,自己写也不难,就是按行求和开根号再去除。
# 手工做对称归一化,D为度矩阵 degrees = np.array(adj_csr.sum(axis=1)).flatten() d_inv_sqrt = np.power(degrees, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0.0 # 构造对角阵后做归一化 d_mat = np.diag(d_inv_sqrt) adj_norm = d_mat @ adj_csr.toarray() @ d_mat adj_norm = torch.FloatTensor(adj_norm)3.3 对齐知识点与时间窗口
邻接矩阵建好后,还需要把交互序列转成训练样本。每一条样本是一个学生在一段连续时间内的做题序列,包含题目索引序列、知识点索引序列、正确性序列。GIKT要求序列中每个时间步同时有题目和知识点信息,因为图聚合要靠题目索引去查邻接矩阵,RNN更新要靠知识点索引参与计算。
时间窗口的选择直接影响训练效果。窗口太长(比如50步以上),RNN的梯度回溯困难,训练慢;窗口太短(比如5步),模型看不到足够的历史,预测精度明显下降。我一般在10到30之间调。对刷题App的场景,20步是比较稳的选择:大约覆盖学生一周的刷题量,既有历史信息又不至于让序列过于稀疏。
import torch from torch.utils.data import Dataset class KTSequenceDataset(Dataset): def __init__(self, logs, q2idx, c2idx, seq_len=20): self.seq_len = seq_len # 按学生分组构建序列 self.student_groups = list(logs.groupby("student_id")) self.q2idx = q2idx self.c2idx = c2idx def __len__(self): return len(self.student_groups) def __getitem__(self, idx): _, group = self.student_groups[idx] q_ids = group["question_id"].map(self.q2idx).values c_ids = group["concept_id"].map(self.c2idx).values correct = group["correct"].values # 截取最近seq_len步 q_ids = q_ids[-self.seq_len:] c_ids = c_ids[-self.seq_len:] correct = correct[-self.seq_len:] # 不足seq_len的序列用-1填充,后面需要mask掉 pad_len = self.seq_len - len(q_ids) if pad_len > 0: q_ids = np.pad(q_ids, (pad_len, 0), constant_values=-1) c_ids = np.pad(c_ids, (pad_len, 0), constant_values=-1) correct = np.pad(correct, (pad_len, 0), constant_values=0) # 预测目标是“下一步”是否正确,所以要移一位 q_input = torch.LongTensor(q_ids[:-1]) c_input = torch.LongTensor(c_ids[:-1]) target = torch.FloatTensor(correct[1:]) return q_input, c_input, target这个数据类把每个学生的做题序列转成长度固定的样本。-1填充的token在embedding层之后会被mask掉,不参与loss计算,这是GIKT这类序列模型最容易出错的地方。如果mask写漏了,padding部分的预测会被当成真实作答参与梯度更新,模型会学到一堆噪声。
还有个细节:这里我把预测目标设成了“下一步是否正确”。对应到推理阶段的含义是——模型看到学生截至当前步的做题记录,预测他做下一道题的正确概率。推荐系统拿这个概率排序,低的优先推。
4. 用PyTorch实现GIKT的核心模块
4.1 图卷积聚合层
图卷积聚合层负责把邻接矩阵里的结构信息编码进习题embedding。它的输入是题目embedding矩阵E_q(形状num_questions × embed_dim),经过一层或多层图卷积后,输出聚合了知识点邻居信息的习题表征。
单层图卷积的计算过程是:每个习题节点的向量,等于它自身向量与所有邻居知识点向量加权求和后的结果。这里有一个容易搞错的地方——GIKT原文聚合的是知识点节点,而知识点节点本身也有embedding。所以第一层图卷积之前需要先初始化两套embedding:习题embedding和知识点embedding。聚合后习题表征带上了知识点信息,知识点的表征也可以反过来带上习题信息,具体看实现里是只更新习题侧还是两侧都更新。
import torch import torch.nn as nn import torch.nn.functional as F class GIKTGraphAggregator(nn.Module): def __init__(self, num_questions, num_concepts, embed_dim, adj_norm): super().__init__() self.q_embedding = nn.Embedding(num_questions, embed_dim) self.c_embedding = nn.Embedding(num_concepts, embed_dim) # 归一化后的邻接矩阵,作为静态图结构缓存 self.register_buffer("adj_norm", adj_norm) self.linear = nn.Linear(embed_dim, embed_dim) def forward(self): # 取全部习题和知识点的embedding q_emb = self.q_embedding.weight # [num_questions, embed_dim] c_emb = self.c_embedding.weight # [num_concepts, embed_dim] # 二部图聚合:习题侧聚合知识点,知识点侧聚合习题 # 先算习题的邻居聚合结果 agg_q = torch.matmul(self.adj_norm, c_emb) # [num_questions, embed_dim] # 知识点的邻居聚合结果 agg_c = torch.matmul(self.adj_norm.t(), q_emb) # [num_concepts, embed_dim] # 自身信息与邻居信息拼接后过线性层 q_out = torch.cat([q_emb, agg_q], dim=-1) c_out = torch.cat([c_emb, agg_c], dim=-1) # 拼接过后的维度是 2*embed_dim,这里用两层线性压缩回去 proj_q = nn.Linear(2 * embed_dim, embed_dim).to(q_emb.device) proj_c = nn.Linear(2 * embed_dim, embed_dim).to(c_emb.device) q_out = F.relu(proj_q(q_out)) c_out = F.relu(proj_c(c_out)) return q_out, c_out注意事项:nn.Linear不要在forward里反复实例化,这段代码只是展示逻辑,实际应该放到__init__里。register_buffer是用来把归一化邻接矩阵注册成模型一部分的,这样调用.to(device)时矩阵会自动跟着设备走,不会出现CPU张量和GPU张量相乘的报错。
如果机器显存有限,embedding维度建议64到128之间。我试过把维度拉到256,AUC提升不到0.3个点,但显存占用翻了近两倍。数据集不大时128是性价比最高的选择。
4.2 GRU读取器与交互聚合
图卷积产出的习题聚合表征是静态的,接下来要把它放进序列模型里动态使用。GIKT的序列读取器用GRU,输入是每个时间步的交互向量。交互向量的构建方式是:把当前习题的聚合表征、当前知识点表征、答对与否的向量拼在一起。
这个“答对与否”也要做embedding,而不是直接用一个0/1标量。因为正确性本身就是一个带语义的信号,embedding之后模型可以学出“做对了”与“做错了”在表征空间里的差异。维度不需要大,32就够。
交互聚合的细节值得展开:GIKT原文里把o_t = W * [h_t, a_t]当作交互向量,其中h_t是GRU当前隐状态,a_t是当前题目的聚合表征。这个交互向量既用于输出预测,也用于更新下一时刻的输入。也就是说,模型不只是看到了“上一道题是什么”,还能感知到“上一道题与当前知识状态的匹配度”。
class GIKTModel(nn.Module): def __init__(self, num_questions, num_concepts, embed_dim, adj_norm, hidden_dim=128, dropout=0.2): super().__init__() self.embed_dim = embed_dim self.hidden_dim = hidden_dim # 复用上一节定义的图聚合器 self.graph_layer = GIKTGraphAggregator( num_questions, num_concepts, embed_dim, adj_norm ) # 正确性embedding:0错1对,加一个padding位 self.correct_embedding = nn.Embedding(3, 32) # 交互向量经过线性层降到hidden_dim再进GRU self.interact_linear = nn.Linear(embed_dim + 32 + hidden_dim, hidden_dim) # GRU读取器 self.gru = nn.GRU(hidden_dim, hidden_dim, batch_first=True) # 预测层:用当前隐状态和习题表征预测正确概率 self.predict_linear = nn.Linear(hidden_dim + embed_dim, 1) self.dropout = nn.Dropout(dropout) def forward(self, q_ids, c_ids, correct, adj_norm=None): # 图聚合得到习题和知识点的静态表征 q_static, c_static = self.graph_layer() # 查询当前batch用到的习题和知识点表征 # 注意q_ids里填充的是-1,需要先mask mask = (q_ids != -1).unsqueeze(-1).float() # [batch, seq_len, 1] q_ids_clamped = q_ids.clamp(min=0) c_ids_clamped = c_ids.clamp(min=0) q_emb = self.dropout(q_static[q_ids_clamped]) # [batch, seq_len, embed_dim] c_emb = self.dropout(c_static[c_ids_clamped]) # [batch, seq_len, embed_dim] # 正确性embedding处理:-1位置补0 correct_clamped = correct.clamp(min=0).long() # 注意correct是float张量,这里要转成long再查表 r_emb = self.correct_embedding(correct_clamped) # [batch, seq_len, 32] # 拼接交互向量 interact_input = torch.cat([q_emb, r_emb], dim=-1) # 先不加隐状态 # 过线性层得到交互向量o_t o_t = torch.relu(self.interact_linear( torch.cat([interact_input, c_emb], dim=-1) )) # 输入GRU o_t = o_t * mask # 填充位置置零 gru_out, _ = self.gru(o_t) # 拼接预测:当前隐状态+当前习题表征 predict_input = torch.cat([gru_out, q_emb], dim=-1) logits = self.predict_linear(predict_input).squeeze(-1) # 填充位置置为很小的数,后续loss计算时会被mask掉 logits = logits.masked_fill(mask.squeeze(-1) == 0, -1e9) return logits这段代码里的核心参数是interact_linear的输入维度:embed_dim + 32 + hidden_dim。含义是当前习题静态表征、正确性embedding、知识点表征三者的拼接结果。后面接GRU时已经是一个交互过的向量,GRU学习的是交互向量之间的时序依赖,这比直接把原始embedding丢进GRU效果更好。
一个小细节:GRU的隐状态初始化默认是全零,这对长度很短的序列没影响,但对长序列来说,如果前几步的信息特别重要,全零初始化会丢失一些初始偏置。我在实际项目里尝试过用习题聚合表征的平均值作为初始隐状态,AUC有小幅提升,但幅度不稳定,所以最终保留了默认初始化。
4.3 损失函数与评估指标
GIKT的预测目标是一个二分类概率——学生下一题答对的概率。损失函数用二元交叉熵(BCE),但有一个关键处理:必须在padding的位置把loss置零。
def train_step(model, batch, optimizer): q_ids, c_ids, target = batch # 前向传播得到logits logits = model(q_ids, c_ids, target.float()) # 构造mask:只有q_ids不是-1的位置才参与loss mask = (q_ids != -1).float() # 用logits和target计算BCE loss,然后对mask位置加权 loss_fn = nn.BCEWithLogitsLoss(reduction="none") loss = loss_fn(logits, target) loss = (loss * mask).sum() / mask.sum() optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()这里reduction="none"是关键。如果用默认的mean,padding位置也会参与loss平均,分母变大,每个样本的loss偏小,梯度被稀释。正确做法是逐元素算loss,再自己乘mask求加权平均。
评估指标用AUC(ROC曲线下面积),因为做题预测是个典型的排序问题,AUC衡量的是“模型把做对的题排在前面、做错的题排在后面的能力”。我在评估时还会按知识点拆分计算AUC,这样能看出模型在哪些知识点上是衰退的。如果某个知识点的AUC明显低于整体水平,说明这个知识点的训练样本少或图结构有问题。
from sklearn.metrics import roc_auc_score def evaluate(model, data_loader): model.eval() all_preds = [] all_targets = [] with torch.no_grad(): for q_ids, c_ids, target in data_loader: logits = model(q_ids, c_ids, target.float()) mask = (q_ids != -1) # 只取有效位置的预测 preds = torch.sigmoid(logits[mask]).cpu().numpy() targets = target[mask].cpu().numpy() all_preds.extend(preds) all_targets.extend(targets) return roc_auc_score(all_targets, all_preds)这个评估函数只用AUC一个指标,实际做推荐决策时远远不够。后面第6章会专门讲推荐场景里比AUC更要紧的事。训练时的评估频率建议每1到2个epoch做一次,不要每个batch都做——AUC计算本身不贵,但频繁打断训练会让整体训练时间拉长不少。
5. GIKT训练中的常见坑与排查:五条血泪经验
5.1 现象:AUC只比随机高一点点,模型完全没有学起来
原因大概率是ID映射错位。这是序列模型最经典的坑——训练数据里的question_id和embedding层的索引没有对齐。比如数据预处理阶段先过滤了一批无效题目,但忘了更新映射表,导致embedding层的第0个索引对应的题目ID和邻接矩阵第0行对应的题目ID不是同一道题。
解决:训练开始前做一次“自检”。从训练集里随机抽100道题,逐个取出embedding向量,跟邻接矩阵对应行做内积,检查是否有响应。另一个更省事的办法是,把嵌入层的权重复制出来,人工查几个题目ID,和邻接矩阵行交叉验证一遍。这个检查只花5分钟,能省掉一整天的排查。
5.2 现象:AUC正常,但冷门知识点的预测结果一塌糊涂
原因:邻接矩阵稀疏导致聚合失效。题库里大量题目只对应1到2个知识点,这些知识点的邻居很少,图卷积聚合后信息量不足。尤其当这些知识点对应的题目在训练序列里也少见时,双重的数据稀疏让模型几乎学不到任何东西。
解决:给邻接矩阵加自环(self-loop),让每个节点至少能聚合到自身信息。在adj_norm的构建中把单位矩阵加上去,即adj = adj + I,再做归一化。如果稀疏度仍然很高,可以考虑把知识点合并成粗粒度——比如把“一元二次方程”和“方程与不等式”合并成“代数方程”,牺牲细粒度换取更高的训练稳定性。这在推荐场景里是值得的,因为推荐系统只需要排个优先级,不需要精确到每个知识点的诊断报告。
5.3 现象:训练loss正常下降,但验证AUC忽高忽低,波动很大
原因:学习率过大。GIKT有两层网络——图卷积层和GRU层,它们的参数更新频率不同。图卷积层在每轮训练中更新次数少(因为所有样本共享一套聚合权重),GRU层更新频繁。给两者用同一个学习率,GRU已经收敛了,图卷积层还在剧烈震荡。
解决:给不同层配置不同学习率。PyTorch里可以给param_groups分别指定lr。我的常用配置是:embedding层和图卷积层用1e-3,GRU层用5e-4。如果波动依然明显,再加一个梯度裁剪clip_grad_norm_,max_norm=5,对GRU这种长序列模型几乎是标配。
5.4 现象:验证集AUC比训练集高出好几个点,明显不合理
原因:padding mask写错了。最常见的是三种错法:一是构造序列时把padding放在前面而不是后面,导致RNN的隐状态被padding位置的零向量污染;二是loss计算时忘了乘mask,padding位置的预测参与梯度反向传播;三是评估阶段没有过滤padding,把模型对填充位置的瞎猜结果也算进了AUC。
解决:用肉眼检查一种极端情况——某条样本的有效长度为1,也就是只做了一道题,padding占19步。在模型输出的logits里,有效位置和padding位置的数值应该差异很大。我习惯在debug模式下打印前两条样本的target和pred,看一眼padding位置是不是都被正确置成了非常小的数。另外在评估函数里务必加上mask过滤,跟训练时的mask保持同一套逻辑。
5.5 现象:推荐的题在知识点分布上越来越窄,学生只会做某一类题
原因:知识点粒度不一致。映射表里有的题对应“一次函数”,有的对应更细的“待定系数法求一次函数解析式”。粒度不统一会让图结构里的邻居关系失衡——粗粒度知识点聚合了大量题目,细粒度知识点只聚到少量题目,最终推荐结果偏向粗粒度知识点覆盖的题。
解决:在数据预处理阶段做一次知识点层次归并。统一的基准建议控制在“章节粒度”,比如“一元二次方程”“不等式”这种层级。映射到更细的知识点只做分析用,不参与图结构构建。我踩过这个坑后,把60个细粒度知识点归并成20个中粒度知识点,AUC损失了0.5个点,但推荐结果的知识点覆盖面反而明显变好。对推荐系统来说,推荐结果的多样性有时比单一准确率更重要。
6. 从“预测对错”到“推荐下一题”:AUC之外还要看的三件事
第一件事:校准(calibration)。AUC只衡量排序质量,不衡量绝对概率是否准确。模型可能把所有预测概率整体压到0.3到0.7之间,排序依然完美,但推荐策略需要判断“这道题学生会不会”。我用温度缩放的思路校准——在验证集上潜搜一个温度系数T,把logits / T作为最终预测输出,让预测概率的分布更接近真实正确率。
第二件事:推荐策略不能只追最低正确率。如果把“答对概率最低的题”全推给学生,模型会不断推他最不懂的题,学生连续受挫后会流失。我一般按分层抽样:70%的概率推预测正确率低于0.5的薄弱知识点题目,30%的概率推正确率在0.6到0.8之间的巩固题。这样既确保针对性,又给学生留出成功的空间。
第三件事:冷启动的兜底规则。新学生没有任何做题记录,模型输出的是全零概率,此时GIKT完全失效。我的做法是先给一套覆盖前5个核心知识点的基础诊断题,做完一轮后计算出第一版知识状态,再进入GIKT推荐循环。这个规则和模型预测是两条独立的链路,但保证了产品在第一天就能正常运转。
最后,我在每次离线评估时还会额外统计一个指标——推荐题在薄弱知识点上的覆盖率。具体做法是:取预测正确率最低的50道题,统计它们覆盖的知识点数,除以总知识点数。这个数值比AUC更能反映推荐系统是不是真的在做“个性化”。如果覆盖率低,说明模型推荐集中在少数几个知识点上,这时候我会回头检查邻接矩阵是否过度集中,而不是去调模型超参。
这套方案从数据处理到模型训练到推荐策略,花了我大约两周时间。最初报错最多的地方不是模型本身,而是ID对齐和mask问题,这两类问题占了接近一半的调试时间。现在再做类似项目,我会先写一个数据自检脚本,把ID映射、邻接矩阵、mask逻辑都过一遍再开始训练。这个习惯帮我省了不少时间,希望帮到你。
本文还有配套的精品资源,点击获取