ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

知识追踪三大路线:BKT、IRT、DKT原理与选型实战

知识追踪三大路线:BKT、IRT、DKT原理与选型实战 知识追踪Knowledge Tracing这个词第一次见到的人十有八九会把它当成知识管理或者文档检索。它其实干的是一件很具体的事一个学生在连续做题的过程中他脑子里某个知识点究竟从哪一刻开始由“不会”翻到“会”以及下一次碰到同一个知识点他做对的概率有多大。围绕这个问题圈子里逐渐形成了三条主流路线——BKT 走概率图的路子IRT 走心理测量的路子DKT 走深度序列建模的路子。三者诞生的年代、依赖的数据量、给出的结论形式都不一样很多刚入门的同学一上来就被这三组缩写绕晕不知道先啃哪个、项目里该用哪个。这篇文章我打算按“它们各自在解决什么问题、参数怎么推、代码怎么写、坑在哪里”的顺序讲一遍顺带把三者的分工边界和选型经验摊开说。适合正在做在线练习系统、自适应刷题、题库推荐或者带教育数据挖掘方向课题的人看也适合只想知道“我手上一万条答题日志能玩出什么花样”的朋友。1. 知识追踪到底在追什么从一道错题说起1.1 三个学生同样的错题结论完全不同先抛开所有模型名词设想一个最朴素的场景。班里有三个学生今天的随堂测里都做错了一道“一元二次方程求根”的题。如果只看“错”这个结果三个人没区别。但你知道的信息远不止于此学生甲前面五道同类型题全对这道错多半是算错学生乙前面五道里错了两道这道错不太意外学生丙前面五道全错那说明他大概率还没掌握判别式的用法。下一次出题时该给甲出一道更难的变式给乙出一道同难度的巩固题给丙退回最基础的配方练习。知识追踪要做的就是从答题序列里把这种差异量化出来输出一个随时间滚动的“掌握度”或者“答对概率”。这个数值化的过程本质上是在做两件事一是把每个学生的历史交互压缩成一个能反映当前状态的向量二是用这个向量去预测未来某道题的结果。这件事说起来容易落到工程上有几个硬约束绕不过去。第一条是数据形态绝大多数教育系统能拿到的就是三列——谁、答的哪道题或哪个知识点、对还是错外加一个时间戳。没有过程数据、没有草稿纸、没有眼动。第二条是冷启动新学生的第一条记录之前你什么都不知道模型必须能给出一个合理的先验。第三条是解释成本老师看到系统说“这个学生掌握度 0.37”一定会问“凭什么是 0.37”。这三条约束恰好也解释了为什么 BKT、IRT、DKT 会长期共存——它们在这三条上的取舍完全不同。我个人的观察是很多人一开始会误以为这是“三代技术迭代”的关系觉得 DKT 出来了前面两个就该淘汰了。实际做下来会发现完全不是这么回事。IRT 现在依然是标准化考试、题库标定、等值处理的主力工具因为它给出的能力值和难度值有明确的量纲意义换一套卷子还能比较BKT 在小规模、知识点粒度很细的场景下依然能打因为它的四个参数老师能看懂DKT 在数据量大、序列长、想榨干预测精度的时候才真正发挥优势但它对数据质量和评测方式极其敏感。把它们理解成三把不同用途的工具比理解成三个版本号要准确得多。1.2 三条技术路线各自的“看家本领”把分工摊开说更清楚。IRT 关心的是“横向可比”它把学生能力和题目难度放在同一把尺子上核心产出是每道题的区分度和难度参数以及每个学生的一个能力值 θ。它的前提假设是学生的能力在一次测验期间不变所以它天然是用来做“快照式”评估的比如一次月考、一次期末。BKT 关心的是“纵向演化”它显式地建模了“学”这个动作——学生做完一道题之后掌握状态可能发生跃迁这个跃迁用一个学习率参数 T 表达。所以 BKT 输出的是一条随时间变化的掌握曲线适合做逐题级别的动态追踪。DKT 关心的是“端到端拟合”它不给人类预设状态转移的公式直接丢一个循环神经网络进去让模型自己从序列里学出隐藏状态输出下一题答对的概率。这三者的输入输出其实可以互相喂。IRT 标定出来的题目难度和区分度可以当作特征喂给 DKTBKT 输出的知识点掌握度可以作为学生的静态画像特征喂给推荐系统或者 IRT 的能力先验。反过来DKT 的隐藏状态也可以拿去聚类反过来发现新的知识点依赖结构。真正做过项目的人往往最后用的是组合拳而不是单一模型。1.3 先把数据格式定下来后面少走一半弯路不管用哪个模型第一件事都是把答题日志整理成统一的交互三元组。我的习惯是整理成这样的结构。# 每条交互记录的标准结构 # student_id: 学生唯一标识 # item_id: 题目唯一标识 # kc_ids: 该题关联的知识点列表一题可多标签 # correct: 0 或 1 # timestamp: 作答时间秒级或毫秒级 interaction { student_id: S_00123, item_id: Q_00871, kc_ids: [KC_quadratic_discriminant], correct: 1, timestamp: 1717200000, }这里最容易出问题的两个地方一个是知识点切分也就是常说的 KC tagging一个是多知识点题目的归属处理。KC 切分粒度太粗比如把“函数”当成一个知识点那 BKT 的四个参数会糊成一团切得太细比如把“判别式大于零的求根”单独拆出来那每条 KC 序列都短得可怜参数根本拟合不出来。我的一般原则是保证每个 KC 至少有 200 到 500 条答题记录再考虑单独建模否则宁可向上合并一级。多知识点题目则有三种常见处理复制成多条记录一条 KC 一份、只取主 KC、或者建多标签模型。复制法最简单但会让统计量虚高主 KC 法最干净但会丢信息多标签法最完整但对模型要求最高。刚开始做我建议先上主 KC 法跑通闭环后面再考虑升级。注意如果日志里的时间戳是客户端上报的一定要先做一次时间清洗。学生把 App 后台挂着、隔天再打开会产生几条时间戳明显异常的记录这类脏数据对 DKT 这种对顺序敏感模型的影响远大于对 BKT 的影响。2. BKT四个参数撑起的贝叶斯推断2.1 模型假设与四个核心参数BKT 的全称是贝叶斯知识追踪它的骨架非常小对每一个知识点维护一个二值隐状态——掌握或者没掌握。这个状态看不见只能通过学生的答题结果去推断。整个模型由四个参数控制加上一个初始值一共五件事要定。参数含义典型取值范围直觉解释P(L0)初始掌握概率0.1 ~ 0.5第一次接触该知识点前已经会了的可能性P(T)学习率 / 转移概率0.05 ~ 0.3做完一道题之后从不会变成会的概率P(G)猜对概率0.05 ~ 0.3没掌握却答对的概率P(S)失误概率0.02 ~ 0.2掌握了却答错的概率四个参数里P(T) 是最有教育意义的一个它直接对应“这道题对学生有没有教学效果”。如果一个知识点的 P(T) 估计出来接近 0说明学生做多少道同类题都没在学那要么是题目太难超出了学生的最近发展区要么是解析环节缺失学生错了也不知道错在哪。我见过一个真实案例某口语练习模块的 P(T) 长期低于 0.03后来加了逐句纠音反馈P(T) 直接翻了三倍这个变化比任何业务指标都更早地反映了功能改进的有效性。这里有个必须提醒的假设边界BKT 默认学生一旦掌握就不会遗忘所以状态转移是单向的。这个假设在短时间内基本成立但如果你的场景跨度是几个月比如寒暑假之后回来接着练遗忘效应就非常明显这时候要么按学期分段建模要么引入一个遗忘参数让 P(T) 变成可以往回走的双向转移。后者会显著增加拟合难度不是必须的话我一般不建议动。2.2 一次答题之后信念值怎么更新BKT 的核心运算是两步先根据答题结果修正掌握信念再叠加学习效应。假设上一题结束时学生对某个 KC 的掌握概率是 P(L_{n-1})现在他做对了P(L | 答对) P(L_{n-1}) * (1 - P(S)) / [ P(L_{n-1}) * (1 - P(S)) (1 - P(L_{n-1})) * P(G) ]如果答错了P(L | 答错) P(L_{n-1}) * P(S) / [ P(L_{n-1}) * P(S) (1 - P(L_{n-1})) * (1 - P(G)) ]这一步的直觉是贝叶斯公式本身观察到“答对”这个证据要让“已掌握”这个假设的概率上升上升多少取决于“掌握了还会做错”的概率有多小。接下来第二步加上学习转移P(L_n) P(L | 观测) (1 - P(L | 观测)) * P(T)用代码写出来不到二十行很值得自己手撸一遍比看十页 PPT 都管用。def bkt_update(p_l, correct, p_t, p_g, p_s): p_l: 上一时刻掌握概率; correct: 0/1; 返回更新后的掌握概率 if correct 1: num p_l * (1 - p_s) den num (1 - p_l) * p_g else: num p_l * p_s den num (1 - p_l) * (1 - p_g) # 防止分母为 0实际工程里一定要加这个保护 p_obs num / den if den 1e-12 else p_l # 叠加学习效应 return p_obs (1 - p_obs) * p_t # 模拟一个学生连续答 5 题前 3 错后 2 对 p_l 0.2 seq [0, 0, 1, 1, 1] for step, c in enumerate(seq, 1): p_l bkt_update(p_l, c, p_t0.15, p_g0.2, p_s0.1) print(f第{step}题 结果{c} 掌握度{p_l:.4f})跑一遍你就能直观看到第一次答错掌握度反而轻微下降但幅度不大因为猜对概率 0.2 意味着“答错”这个证据并不算强连续错三次之后掌握度掉到很低然后第一次答对会有一个明显的向上跳这就是“从不会到会”的贝叶斯证据在起作用。这个动态过程如果只看公式很难有感跑代码是理解 BKT 最快的路径。2.3 参数怎么定初始化、EM 与一堆约束技巧参数估计是 BKT 落地的第一道坎。标准做法是 EM 算法或者暴力网格搜索。网格搜索听起来很土但在四个参数、每个参数取 20 个格点的场景下总共也就十六万种组合用向量化算一遍几分钟就跑完了而且能画出完整的似然曲面对理解参数的可辨识性非常有帮助。我自己在早期项目里几乎都是用网格搜索先摸一遍底然后再用 EM 精调这样心里有数。但网格搜索会遇到一个经典问题参数的联合可辨识性差。P(G) 和 P(L0) 在某些数据上会互相替代——数据一样时用“初始掌握度高、猜对概率低”和“初始掌握度低、猜对概率高”能拟合出一模一样的似然值。解决办法是加约束。我常用的几条约束经验是强制 P(G) P(S) 1否则模型会出现“答对降低掌握度”的反直觉行为这个约束几乎必加把 P(G) 上限定在 0.5 左右选择题四选一的场景理论猜对率就是 0.25标定出来远高于这个值通常说明知识点切分有问题对企业内部的题库如果出题时标注了题目难度可以用它来给 P(G) 设一个合理先验比如简单题的 P(G) 允许更高P(T) 给一个下限比如 0.01避免出现“学习率恒为 0”的退化解。参数初始化也有讲究。全部从 0.1 起步是最省事的做法但对 EM 这种容易陷入局部最优的算法来说多组随机初始化然后取似然最高的那组效果好得多。我一般跑 5 到 10 组随机初始化实测下来似然值的差异能有 5% 到 10%这部分性能几乎是白捡的。提示如果你的 KC 数量很多一定要做批量拟合。逐个 KC 调用 scipy 的优化函数几千个 KC 跑一晚上的情况我遇到过。把参数打包成矩阵用 numpy 向量化速度能提升两个数量级。2.4 BKT 的边界在哪里怎么改良BKT 最大的问题是它把每个知识点当成孤立的但实际上知识点之间有先修依赖。学生“不会”判别式很可能是因为“不会”配方法——但 BKT 看不到这层关系。针对这个学界做过不少扩展比如把知识点之间的依赖关系建模成贝叶斯网络让父节点的掌握状态影响子节点的先验也有把学生的能力当作一个跨 KC 的全局因子引入的。这些改良版的预测精度通常能提升两三个百分点代价是参数数量翻好几倍可解释性也明显下降。另一类常见改良是引入题目层面的随机效应。同一个知识点下有十道题难度不同标准 BKT 用同一套 P(G) 和 P(S) 去处理会把这些差异全部吸收进噪声里。把题目难度作为一个协变量让它去调制 P(G)是个性价比很高的改动基本不用改推断逻辑只是把常数换成 logit 函数。还有个实操中的坑BKT 的 P(S) 很容易被高估。原因是学生的失误往往不是随机的而是集中在某几类题上比如计算量大的题这些系统性失误会被 BKT 当成“没掌握”导致掌握度被系统性低估。我的做法是定期检查 P(S) 的分布如果某个 KC 的 P(S) 超过 0.3先去看是不是有几道题本身有问题而不是急着调模型。3. IRT把人和题放到同一把尺子上3.1 从经典测量理论的缺陷说起在 IRT 之前教育测量主要用经典测量理论核心指标就是得分率和区分度算法简单到不用写代码。但它有个致命缺陷学生的能力值和题目的难度值是绑死在同一套卷子上的。学生甲做 A 卷考了 80 分学生乙做 B 卷考了 78 分你没法说甲比乙强因为两套卷子难度不同同一道题在尖子生里区分度高在基础班里可能完全没有区分度这个差异也反映不出来。IRT 的出发点就是解决这个“样本依赖”和“题目依赖”的双向纠缠把学生能力和题目难度参数化到同一个潜在量表上一旦标定完成换卷子也能比较。IRT 的基本假设有三条单维性一次测验只测一种能力、局部独立性给定能力值后题目之间相互独立、单调性能力越高答对概率越高。这三条在真实数据上都会不同程度地被违反所以实际项目里必做的功课是检查假设——比如用因子分析看第一特征值占比占比够高才能说大致单维。3.2 1PL、2PL、3PL 的参数含义与选型IRT 有三代常用模型区别只在参数个数。模型公式参数适用场景1PL / RaschP 1 / (1 e^{-(θ - b)})难度 b题目质量统一、样本量偏小2PLP 1 / (1 e^{-a(θ - b)})难度 b、区分度 a主流选择题目异质性明显3PLP c (1 - c) / (1 e^{-a(θ - b)})加猜测参数 c低区分度选择题、样本量充足选型上我的经验是样本量低于 500 时老老实实用 1PL参数量少、估计稳定500 到 2000 之间可以上 2PL3PL 需要每个题目至少几百个作答记录才敢标定 c 参数否则估计出来的 c 会忽高忽低反而有害。另外一个细节是3PL 的猜测参数 c 在理论上应该接近选择题的随机猜对率如果你标定出来一道四选一的题 c 等于 0.6基本可以断定是抄答案或者题目本身有提示这是很好的题目质量筛查信号。Rasch 模型还有一个特别的优点它具备样本无关性和题目无关性严格意义上只有 Rasch 具备2PL、3PL 只是近似所以如果你的目标是把历年的考试分数做等值转换、做长期追踪Rasch 往往是更稳妥的选择。追求拟合优度就用 2PL、3PL追求跨年度可比性就用 Rasch这个取舍值得在做方案时明确写进文档。3.3 能力值估计MLE、EAP 与牛顿迭代实操题目参数标定完之后来一个新学生做题就要估他的能力值 θ。三种主流方法各有用武之地。最大似然估计把 θ 当作固定未知量最大化观测数据的似然函数。优点是计算直观缺点是当学生全对或者全错时MLE 会发散到正负无穷——这是必须处理的实际问题通常做法是设一个截断区间比如 θ 限制在 -4 到 4 之间。做法是给定初始值后用牛顿-拉夫森迭代二阶收敛速度快一般五六次就收敛。import numpy as np def irt_2pl_prob(theta, a, b): return 1.0 / (1.0 np.exp(-a * (theta - b))) def estimate_theta_mle(responses, a, b, lo-4.0, hi4.0, tol1e-6, max_iter100): responses: 0/1 数组; a, b: 对应题目的区分度与难度 theta 0.0 for _ in range(max_iter): p irt_2pl_prob(theta, a, b) # 一阶导 grad np.sum(a * (responses - p)) # 二阶导 hess -np.sum(a ** 2 * p * (1 - p)) if abs(hess) 1e-10: break step grad / hess theta_new theta - step theta_new min(max(theta_new, lo), hi) if abs(theta_new - theta) tol: theta theta_new break theta theta_new return theta期望后验估计EAP则把 θ 当作服从某个先验分布的随机变量输出后验均值。它的好处是天然不会发散——全对的学生会得到一个高但有限的能力值全错的学生同理。代价是需要假设一个先验通常用标准正态。我一般这样分工做题目质量分析、算信息函数用 MLE做学生的最终成绩报告用 EAP因为报告里的极端值必须有限。还有一类场景需要特别提一下计算机自适应测验。这种模式下学生做的每一道题都基于当前能力估计动态挑选最常用的选题策略是最大信息量法也就是在 θ 的当前估计值附近挑信息函数最大的题目。这里有个技术细节容易被忽略——能力估计要留“虚报”余量。因为自适应选题本质上是根据当前估计值挑最合适的题如果估计值本身有偏差后续选题就会跟着偏。常见应对是前几题用较宽的选题范围做“探测”或者使用带随机化的选题策略牺牲一点效率换估计的稳健性。3.4 题目参数标定与不变量检验标定通常用边际极大似然或者期望极大化工具上有 R 的 mirt、TAMPython 侧的话 py-irt 或者自己写 EM 都可以。标定完成之后有几项必做的体检拟合残差检查用标准化残差看每道题的观测 ICC 曲线和理论曲线是否贴合明显偏离的题要单独排查。信息函数每道题在哪个能力区间信息量最大用它来评估题库覆盖度。如果题库在 θ 等于 1.5 以上的区间信息量几乎为零说明高能力段缺题。项目功能差异分析这是必做项。如果某道题对不同群体的学生表现出系统性偏差比如某类背景的学生在同等能力下得分显著偏低那这道题很可能存在表述或内容上的不公平需要复核。局部独立性检验残差相关矩阵里如果出现成对高相关通常意味着有题目互相提示或者题目被归到了错误的能力维度。注意标定用的样本最好做一次清洗把作答时间过短的记录剔掉。实测中作答时间小于题目平均用时三成的记录里随机作答的比例显著偏高这些噪声会系统性地拉高猜测参数。3.5 IRT 与 BKT 的分工边界很多人会纠结这两个到底该用哪个。我的判断标准是看你的目标时间尺度。如果你要回答“这个学生现在的水平在全体里排什么位置”用 IRT。如果你要回答“这个学生在过去这一周里对某个知识点的掌握是怎么变化的”用 BKT。前者是横截面的快照后者是纵向的轨迹。两者的输出甚至能互相校验如果某个学生的 IRT 能力值很高但 BKT 显示他对某个核心 KC 的掌握度始终低于 0.3这就很值得看一看——要么是这个 KC 的题目有质量问题要么是这个学生的能力结构特别不均衡是个典型的偏科信号。还有一条实际经验IRT 需要的能力单维假设在“综合能力测评”场景下勉强成立但在“知识点细分诊断”场景下几乎必然被违反。这时候要么降维做多维 IRT计算量陡增要么干脆放弃 IRT 转而用 BKT 或 DKT 做诊断。我见过不少团队硬把 IRT 往知识点诊断上套最后得到的 θ 既不能反映综合水平也不能反映知识点掌握两头不靠。4. DKT让循环神经网络自己学出掌握状态4.1 输入编码交互元组、扩展 one-hot 与位置对齐DKT 的输入形式非常关键很多复现失败都栽在这一步。原始论文的做法是把每个时刻的交互编码成一个扩展 one-hot 向量假设有 N 个知识点向量长度是 2N前 N 位表示“答对了哪个知识点”后 N 位表示“答错了哪个知识点”。t 时刻输入 x_t网络的输出 y_t 是预测 t1 时刻答对的概率。注意这个错位关系一定要理清楚否则训练出来的模型 AUC 会莫名其妙地偏高因为你在用当前题的结果预测当前题。import numpy as np def encode_interaction(kc_idx, correct, n_kc): 把一次交互编码成 2N 维扩展 one-hot x np.zeros(2 * n_kc, dtypenp.float32) if correct 1: x[kc_idx] 1.0 else: x[n_kc kc_idx] 1.0 return x def build_dataset(records, n_kc): records: [(kc_idx, correct), ...] 按学生分组后的单条序列 返回输入 x (T, 2N) 与标签 y (T,) xs, ys [], [] for t in range(len(records) - 1): kc_idx, correct records[t] xs.append(encode_interaction(kc_idx, correct, n_kc)) ys.append(records[t 1][1]) # 预测下一题是否答对 return np.stack(xs), np.array(ys, dtypenp.float32)如果一道题关联多个知识点常见的处理有两种一种是把知识点组合当成一个虚拟 KC组合爆炸题库大时不可行另一种是输入用多个 KC 向量求和后归一化。后一种在工程上更实用我一般用后者。4.2 网络结构与损失函数为什么是 LSTM 加 SigmoidDKT 的网络主体就是一层或两层 LSTM隐层维度通常取 100 到 200输出层接一个线性变换加 sigmoid得到下一题答对的概率。损失函数是标准的二元交叉熵对所有时间步求和。import torch import torch.nn as nn class DKT(nn.Module): def __init__(self, n_kc, hidden128, layers1, dropout0.2): super().__init__() self.lstm nn.LSTM( input_size2 * n_kc, hidden_sizehidden, num_layerslayers, batch_firstTrue, ) self.dropout nn.Dropout(dropout) self.out nn.Linear(hidden, n_kc) def forward(self, x, hNone): # x: (B, T, 2N) h_seq, _ self.lstm(x, h) h_seq self.dropout(h_seq) # 输出 (B, T, N)第 t 位对应下一题答对概率 logits self.out(h_seq) return logits注意这里输出维度是 N 而不是 1因为预测的是“下一题答对”的概率而下一题是哪个知识点在 t 时刻还不知道所以要对所有 KC 都输出一个概率训练时再根据实际发生的下一个 KC 取对应的那一列计算损失。这是 DKT 复现中最容易搞错的地方之一丢掉这个细节模型就退化成了一个“预测当前题对错”的模型毫无意义。训练时的损失计算要配合掩码def masked_bce(logits, targets_kc, targets_correct, mask): logits: (B, T, N); targets_kc: (B, T); targets_correct: (B, T) 只在 mask 为 1 的位置计算损失 b, t, n logits.shape logits_flat logits.reshape(-1, n) kc_flat targets_kc.reshape(-1) # 取出实际发生的那个知识点对应的预测 picked logits_flat.gather(1, kc_flat.unsqueeze(1)).squeeze(1) loss nn.functional.binary_cross_entropy_with_logits( picked, targets_correct.reshape(-1).float(), reductionnone ) loss loss * mask.reshape(-1).float() return loss.sum() / mask.sum().clamp(min1.0)4.3 训练实操数据切分、掩码与正则DKT 有几个非常具体的训练细节处理不好结果会差得很远。先说数据切分。这里有一条铁律必须按学生切分不能随机切分交互记录。如果同一个学生的记录同时出现在训练集和测试集里模型可以通过记忆学生的隐藏状态来“作弊”AUC 能虚高十几个百分点。我见过不止一次复现结果 AUC 到 0.9 的情况换个按学生的切分方式立刻掉到 0.78就是这个原因。再说序列长度。学生的答题序列动辄几百上千条直接整条喂进去显存吃不消梯度也容易爆。常规做法是截断成固定长度比如 200或者按长度分桶把长度接近的序列放在同一个 batch 里减少 padding 浪费。padding 一定要配掩码否则模型会从大量的零向量里学到“答错”的伪规律。正则方面dropout 加在 LSTM 输出层之后效果比较明显隐层内部加 dropout 对收敛速度影响较大需要配合调学习率。另外早停是必须的DKT 在训练集上过拟合得非常快一般五到十个 epoch 之后测试集指标就开始下滑。我习惯用测试集 AUC 做早停指标耐心值设 3 到 5。4.4 DKT 最容易踩的四个坑第一个坑是可解释性。DKT 的隐藏状态是一百多维的稠密向量没人知道第 37 维代表什么。业务方要的是“这个学生掌握度多少”DKT 给不出来。变通做法是拿预测概率本身当掌握度的代理——预测下一题答对概率高就说明掌握得好这个说法大致成立但不够严谨因为它混合了题目难度和知识点难度。更好的做法是固定一个“标准题”用模型对标准题的预测概率作为掌握度代价是每个 KC 都要维护一道基准题。第二个坑是 Xiong 等人在复现研究中指出的问题DKT 相比简单基线比如把上一题结果和知识点编号做逻辑回归的优势并没有原始论文报告的那么大。其中一个重要原因是原始实验的数据切分存在信息泄漏。这件事对整个领域影响很大后来大家做 DKT 相关工作时都会格外小心地报告数据切分方式。我的建议是任何 DKT 结果都要配一个强基线做对照这个基线可以是 BKT也可以是带上一题结果的逻辑回归跑不赢基线的 DKT 基本没有上线的价值。第三个坑是冷启动。新学生的第一条记录DKT 只能用零向量作为隐状态初始值预测效果很差。常见缓解手段是用一个预训练的隐状态做初始值——具体做法是在历史数据上把每个学生序列跑一遍取最后一个隐状态作为该学生的“画像”新学生没历史就用全体均值。这个技巧在实际项目里效果相当明显尤其是前几题。第四个坑是负采样和样本不平衡。大部分教育数据的答对率在 0.6 到 0.8 之间正样本占多数直接训练会让模型倾向于全预测“对”。处理办法有二用带权重的交叉熵或者对负样本做过采样。我一般用前者权重设成负样本占比的倒数简单有效。4.5 后续改良版本速览DKT 之后出来了不少改良结构了解一下有助于选型。DKVMN 用键值记忆矩阵替代黑箱隐状态键矩阵存放知识点表示值矩阵存放掌握状态可解释性比原版好SAKT 引入自注意力机制直接建模“当前题和历史上哪些题相关”在序列较长时优势明显AKT 在注意力里加入了知识点之间的相似度先验进一步提升了长序列的表现。这些结构的代码在开源社区都能找到但复现时同样要注意数据切分和评测协议的一致性否则横向比较没有意义。5. 三者对比与落地选型一张表加几条经验5.1 参数规模、数据需求与可解释性对照维度BKTIRT2PLDKT每个知识点的参数量4 个题目级 2 个隐层上万不可分最低数据量建议每 KC 200 条以上每卷 500 人以上十万条以上交互输出逐题掌握曲线能力值 θ 与题目参数下一题答对概率可解释性高教师能看懂高测量学有明确量纲低隐状态黑箱冷启动表现中等靠先验良好靠先验分布差需要历史序列训练成本秒级到分钟级分钟级到小时级GPU 小时到天级典型 AUC 区间0.65 ~ 0.75不在同一评测体系通常看拟合指标0.75 ~ 0.85需要说明的是这三者的 AUC 不是完全可比的因为 IRT 通常不直接做逐题预测BKT 和 DKT 的预测目标也依赖具体的评测协议。如果你要把它们放一起比务必用同一份测试集、同一套数据切分规则、同一个预测目标。5.2 按数据量分档的选型建议我的经验是分三档来选。数据量在十万条交互以下直接上 BKT别犹豫这个阶段 DKT 大概率跑不赢 BKT而且调参成本高得多。数据量在十万到百万之间可以开始尝试 DKT但一定要同时保留 BKT 作为基线同时用 IRT 做题目质量筛查——这个阶段题目质量问题往往比模型问题影响更大。数据量在百万以上、且序列长度足够DKT 及其改良版才开始体现出优势这时候可以做多模型集成把 BKT 输出的掌握度作为额外特征喂进网络。另一条经验是关于业务目标的。如果目标只是“给学生推荐下一道合适的题”其实 BKT 加一个简单的选题策略就够了投入产出比远高于上深度模型。如果目标是“给学生生成一份诊断报告”那 IRT 加 BKT 的组合更合适因为它们输出的数值都能翻译成老师看得懂的话。只有当业务目标明确是“把预测精度推到极致”时DKT 的投入才划算。5.3 评测指标怎么选才能反映真实水平AUC 是最常用的指标但它对正负样本比例敏感而且不区分题目难度。我一般同时看三个指标AUC 看排序能力准确率看绝对表现RMSE 看概率校准——DKT 经常出现 AUC 不错但概率校准很差的情况预测 0.9 的题实际正确率只有 0.75这个在需要给学生展示具体概率数值的场景里是致命的。校准不好可以做 Platt 缩放或者等渗回归后处理成本很低效果立竿见影。还有一个经常被忽略的指标首答预测精度。也就是每个学生序列的前三到五题模型的预测准确率如何。这个指标直接对应冷启动体验很多教育产品的新用户流失就发生在前几题如果首答预测很差个性化推荐的体验就无从谈起。这个指标单独拎出来看往往会发现 DKT 的表现远不如总体 AUC 体现的那么好。6. 常见问题与排查技巧实录6.1 数据侧知识点切分与稀疏序列怎么处理第一个高频问题是知识点切分不合理导致所有模型都跑不出效果。判断方法很直接看每个 KC 的记录数分布。如果中位数低于 100说明切得太细如果头部几个 KC 占了 80% 以上的记录说明切得太粗或题库分布极不均衡。我的处理方式是把长尾 KC 按知识图谱的层级向上聚合聚到每个 KC 都有足够的样本为止同时保留原始 KC 标签作为特征供模型使用。第二个问题是序列过短。很多学生只做了三四道题就流失了这些短序列如果直接扔掉会引入选择偏差——留下来的都是活跃学生模型学到的规律只适用于活跃用户。我的做法是保留短序列但给它们单独的评估分组同时用一个全局先验做冷启动填充。具体来说新学生进来到第一个 KC 的掌握度不用 P(L0)而是用该 KC 在全体的平均掌握度作为起点实测能明显改善前几题的预测效果。第三个问题是重复作答。同一个学生反复做同一道题第二次、第三次的对错应该怎么处理如果当作独立记录会低估学习效应如果只保留首次会丢信息。我一般保留全部记录但在特征里加上“这是该学生的第几次作答”这个维度让模型自己学这个规律。对于 BKT则可以在同一题重复出现时跳过学习率的更新只做信念修正这样更符合直觉。提示数据清洗时务必检查一种隐蔽的错误——学生 ID 或题目 ID 在系统迁移时被重新分配过。这种错误在日志里表现为同一个 ID 的答题风格前后突变很难直接发现但会严重损害所有模型的效果。检查方法是统计每个学生的答题正确率方差方差异常大的个体值得抽样排查。6.2 建模侧参数不收敛、AUC 虚高与过拟合BKT 参数不收敛的最常见原因是数据里只有一个学生或者一个 KC 的样本导致似然函数是平的。解决办法是加先验或者把参数固定成经验值。另一个原因是 P(G) 和 P(S) 之和接近 1这时模型在两种极端解之间摇摆加上前面提到的约束就能解决。DKT 的 AUC 虚高前面已经说过主因是数据切分泄漏但还有两个容易忽略的来源。一是同一道题在同一时间被多个学生做过如果按记录随机切分模型可能通过题目流行度这类时间信号间接获得信息二是 DKT 的输入包含了当前 KC而下一个 KC 往往和当前 KC 高度相关模型可能只在学“KC 的转移概率”而不是学学生的掌握状态。排查方法是做一个消融实验把输入里的 KC 信息换成随机编号看 AUC 掉多少。如果掉得很少说明模型确实没学到需要的东西。过拟合方面除了早停和 dropout还有一个有效手段是限制隐层维度。我做过一组对比隐层从 256 降到 64测试集 AUC 基本没变但参数量降了一个数量级训练时间缩短一半以上过拟合现象也明显缓解。这个经验在大规模复现里挺通用的先从小模型开始只有在确认小模型是瓶颈时才往上加。6.3 上线侧延迟、冷启动与结果解释上线之后的问题和数据、建模阶段完全不同。延迟方面DKT 的推理必须做到毫秒级否则会影响答题体验。常规优化是缓存隐状态——每个学生每答完一题就把新的隐状态写进缓存下次推理时直接取出来作为初始状态只跑一步前向。这样单次推理的计算量就固定了跟历史长度无关。这个改动几乎是上线必备的用状态缓存和不用的差别是几十毫秒和几毫秒的区别。冷启动方面除了前面提到的用全局均值初始化还有一个技巧是用学生的注册信息做粗粒度分群比如按年级或者自评水平分组用组均值作为隐状态初始值。这个比全局均值好但要注意分群不能太细太细就又回到冷启动问题了。结果解释方面我踩过一个坑直接把 DKT 的概率暴露给老师老师的反应是“这个数字没有意义”。后来改成三段式表述——高、中、低三档每档配一句基于 BKT 掌握度的说明接受度立刻上来了。也就是说DKT 可以做预测引擎但对外展示最好还是走 BKT 或者简单规则的通道把可解释性交还给业务方。6.4 一张常见问题速查表现象可能原因排查方向处理建议BKT 学习率恒为 0KC 样本过少或题目过难看该 KC 的记录数与答对率合并 KC 或提高题目难度梯度BKT 掌握度反复横跳P(G) 与 P(S) 之和过大检查是否超过 1加约束或重新标定DKT 训练集指标好测试集差过拟合或切分泄漏检查切分是否按学生改切分方式并加早停DKT 全预测答对样本不平衡看正负样本比例加类别权重或负样本过采样IRT 区分度出现负值题目表述有歧义或答案错误逐题复核并看作答分布剔除或修订题目IRT 能力值全对时发散用了 MLE 且无截断检查极端作答的学生加截断或改用 EAP新学生推荐效果差冷启动无历史看首答预测精度用全局或分群均值初始化上线后延迟高每次推理重跑全序列看单次推理耗时缓存隐状态只跑一步最后分享一个我自己一直在用的小技巧做知识追踪项目时我会固定留出一组“人工构造的极端学生”作为回归测试集。比如一个全对的学生、一个全错的学生、一个前五题全错后面全对的学生、一个答一题隔三天的学生。每次调整模型或者参数先把这几个极端案例跑一遍看输出的掌握曲线是否符合直觉。这组测试不占多少时间但能抓住很多指标上看不出来的错——我遇到过好几次 AUC 提升但极端案例输出完全反常的情况如果没有这组测试这些问题会一直带到线上。模型指标是给评审看的极端案例的合理性才是给用户看的。
返回列表