ARTICLE DETAIL

资讯详情

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

知识追踪模型BKT、IRT、DKT:原理、实现与工程落地

知识追踪模型BKT、IRT、DKT:原理、实现与工程落地 做在线教育产品、智能题库或者自适应学习系统的人迟早会撞上同一个问题怎么判断屏幕那头的人到底会不会这个知识点不是靠他自己勾选已掌握这种自评——那个数据基本上没法用学生要么高估自己要么为了跳过练习故意点会。真正能拿来做决策的是踩在后台日志里的那一串答题记录什么时间、做了哪个知识点、答对还是答错。知识追踪Knowledge Tracing干的就是这件事它拿学生的历史作答序列当输入去估计学生此刻对每个知识点的掌握程度并预测下一题答对的概率。主流的技术路线有三条BKT、IRT、DKT。BKT 用隐马尔可夫模型刻画会/不会两个状态之间的迁移IRT 用项目反应理论把学生能力和题目难度放到同一把尺子上量DKT 则直接把作答序列当成时间序列喂给循环神经网络。这篇内容适合三类人看正在做自适应推题、想快速上手一个可用模型的工程同学做教育数据挖掘研究、需要横向对比模型的研究者以及产品侧想搞明白系统凭什么说我孩子这道题该练的人。我会把三条路线的原理、参数含义、实现代码、评估口径和我自己踩过的坑一次性讲清楚尽量让人看完就能动手。1. 先搞清楚知识追踪到底在预测什么1.1 从一个具体的产品场景说起假设你手上有一条日志学生 A 在一元二次方程求根这个知识点下连续做了五道题结果是对、错、对、对、错。现在他准备做第六道你希望系统在推题之前先算出一个概率比如 0.72然后根据这个概率决定是继续推同类题巩固还是跳到下一个知识点。这个 0.72 就是知识追踪模型的输出。注意它输出的不是学生能力值这种静态量而是随作答序列动态更新的状态量——每做一题状态就刷新一次。这一点很关键因为它决定了模型必须支持增量更新学生做完一题后台要能在毫秒级把这个学生的知识点状态重算一遍而不是每天跑一次批处理。很多团队一开始用离线模型跑得挺开心上线才发现自己需要的是在线推断能力于是返工。我的建议是在选型阶段就把单次更新耗时当成硬指标而不是最后才补。另外还要明确一点知识追踪预测的对象是特定学生在特定知识点上的作答表现不是笼统的学习能力。粒度选错后面所有的评估指标都会失真。1.2 三个模型其实在回答三个不同层次的问题很多人把 BKT、IRT、DKT 当成同一件事的三种实现其实它们的建模视角差别很大。BKT 关注的是单个知识点内部的状态迁移它假设学生对这个知识点的掌握是一个随机的二元变量会随着练习从不会变成会偶尔也会答错或者蒙对。IRT 关注的是学生和题目之间的双向交互它假设答对概率由学生的潜在能力和题目的难度、区分度共同决定同一道题对不同能力的学生有不同的区分效果。DKT 关注的是跨知识点的序列依赖它不再假设知识点之间独立而是让网络自己从历史序列里学出做错了 A 会影响 B 的表现这种隐含关系。用一句更直白的话概括BKT 在回答他练够了吗IRT 在回答这道题对他而言有多难DKT 在回答他接下来大概会怎样。这三个问题在业务上都有用只是用途不同。选推题策略的时候你更依赖 BKT 的状态做题目质量分析和考试等值的时候你更依赖 IRT 的参数做长序列预测和冷启动转移的时候你更依赖 DKT。搞清楚这个层次差异后面的技术选型就不会纠结。1.3 建模前必须明确的几个前提假设不管用哪条路线有几条隐含假设你得心里有数不然线上效果和离线指标对不上时你会找不到原因。第一作答记录要有明确的知识点归属而且这个归属在全站是统一的——如果同一个知识点在题库里挂了三个不同的标签 IDDKT 的输入维度会爆炸BKT 会退化成三个互不相干的模型。第二要假设答对是能力驱动的而不是被题目描述歧义、图片加载失败、误触这些噪声主导的实际操作中必须先做日志清洗。第三要假设知识点在一段时间内是稳定的如果教研每周都在改标签体系那模型就得跟着重训。第四也是最容易被忽略的一条模型预测的是概率不是确定结论任何把它当成判定学生不会的硬规则都会在真实场景里翻车。我见过有产品直接把预测概率低于 0.5 就判为未掌握并强制重练结果家长投诉说孩子会做却一直被要求重复练习本质是把概率阈值当成了事实标签。这些前提在立项文档里写清楚比后面调参省事得多。2. 三种主流方案的原理拆解与选型逻辑2.1 BKT两个状态四个参数描述一次练习发生了什么BKT 的模型结构非常克制。对每一个知识点它设置一个二元隐状态 L等于 1 表示学生已经掌握等于 0 表示还没掌握。整个模型只有四个参数P(L0) 是初始掌握概率P(T) 是转移概率也就是每次练习后从不会变成会的概率P(G) 是猜测概率指没掌握的学生蒙对的概率P(S) 是失误概率指已经掌握的学生答错的概率。作答过程可以这样理解学生带着当前状态去答题答对或答错被观测到然后状态按 P(T) 发生一次迁移。预测下一题答对的公式是 P(correct) P(L) × (1 − P(S)) (1 − P(L)) × P(G)前半部分是真会且没失误后半部分是不会但蒙对。观测到结果后再用贝叶斯公式把 P(L) 更新一次然后再叠加转移概率。这一套推导下来其实非常直观而且可解释性极强——你完全可以拿这四个参数跟教研解释这个知识点 P(T) 只有 0.08说明学生练几道题根本学不会得换讲法P(G) 高达 0.35说明这批题里选择题太多蒙对的干扰严重。这种能跟业务对话的模型在落地初期价值特别大因为大家还在建立对系统的信任。2.2 BKT 的参数估计为什么必须用 EM 而不是直接解方程BKT 的隐状态观测不到所以没法用简单的极大似然直接求解标准做法是 EM 算法也就是 Baum-Welch 的前向后向那一套。E 步用当前参数估计每个时刻处于掌握状态的后验概率M 步用这些后验概率去更新四个参数来回迭代到收敛。听起来机械但实际做的时候有几个坑必须提前防一是 EM 只保证收敛到局部最优随机初始化十次得到十组参数是常态我一般会跑 20 次随机初始化然后按似然值挑最好的二是如果某个知识点的作答样本太少参数会飘到很离谱的值比如 P(G) 估计成 0.9那就完全没有意义必须设样本量下限三是要给参数加约束比如 P(G) P(S) 1否则模型会出现未掌握时更容易答对这种反直觉的解。另外学生答对概率的边界情况也要处理当预测概率接近 0 或 1 时浮点误差会让后验更新除零代码里必须加一个极小的 epsilon 保护。这些细节论文里通常一笔带过但真写代码时会一个个冒出来。2.3 IRT把学生和题目放进同一个坐标系项目反应理论的核心思想是把答对概率写成学生能力和题目参数的函数。最常见的是 2PL 模型P(correct) 1 / (1 exp(−a × (θ − b)))。其中 θ 是学生能力通常标准化到均值 0、标准差 1 的量尺上b 是题目难度和 θ 同一个量尺a 是区分度值越大这道题在难度附近区分学生的能力越强。还有 1PL也叫 Rasch 模型把 a 固定成常数只估难度3PL 再加一个猜测参数 c用来修正选择题的低能力学生蒙对问题。IRT 最迷人的地方是参数不变性题目难度和区分度是题目自带的属性不随样本变化而改变所以你可以用一批学生的数据把题目参数标定好之后面对新学生时只需要估计他的 θ 就行计算量极小。这对考试测评、题库质量分析非常友好。但它有个硬条件单维性也就是一批题目必须测同一个潜在能力如果混着代数和几何一起估难度参数会互相污染最后谁都解释不通。2.4 DKT让网络自己学知识点之间的依赖DKT 的思路是把作答序列直接当成时间序列建模。输入是一串向量每个时刻的向量包含两部分当前题目的知识点 one-hot 编码以及这道题答对与否。输出是该时刻之后对各个知识点的预测概率。网络通常用 LSTM 或 GRU中间可以有若干层最后接一个 sigmoid 输出层。它最大的优势是不需要人工假设知识点之间的依赖关系网络会自己从数据里挖出来。Piech 等人在原始论文里给过一个很经典的例子模型能从做错了三位数乘法推断出学生可能也做不好两位数乘法这种跨知识点的信息传递是 BKT 和 IRT 天然做不到的。但代价也很明显参数量大、需要的数据量大、可解释性差而且原始论文本身也指出 DKT 存在过拟合问题——对于出现次数很少的知识点模型倾向于记住训练集里的具体表现泛化到新学生身上反而更差。后来的 DKT 加入了重构损失和一致性正则来缓解这个问题思路是要求相邻时刻的状态变化平滑同时用当前状态去重构当前输入逼网络学出更稳定的表示。2.5 三条路线的横向对比与选型建议维度BKTIRTDKT建模粒度单知识点独立学生与题目交互全知识点序列参数规模每知识点 4 个每题 2 到 3 个加每生 1 个数万到数百万数据需求量小几百条可跑中需覆盖各能力段大通常十万级以上可解释性强强弱在线更新成本极低一次前向即可低只估能力值中需跑一次前向跨知识点建模不支持不支持支持主要用途掌握度追踪、推题题库标定、能力测评长序列预测、行为建模基于这张表我的实操建议是如果知识点粒度清晰、每个知识点的作答样本在几百到几千之间先上 BKT性价比最高如果核心诉求是题库质量分析和考试等值选 IRT如果作答序列很长、知识点之间存在明显的先修关系、并且有足够的数据量再考虑 DKT。还有一种常见的做法是混合使用——用 IRT 标定题目难度作为特征喂给 DKT 增强输入表达或者用 BKT 的输出作为 DKT 的辅助监督信号。这类混合方案在近几年的公开研究里出现过不少效果通常比单模型稳定。3. 从零跑通三个模型数据准备与代码实现3.1 数据格式与预处理的具体做法不管哪个模型底层的输入格式都是同一套三元组学生 ID、知识点 ID、答题结果。我一般整理成这样的宽表user_id skill_id correct timestamp u_1001 k_023 1 1698765432 u_1001 k_023 0 1698765490 u_1001 k_101 1 1698765540预处理环节有几个必须做的动作。第一是去重和排序同一个用户同一时间戳的重复上报要剔掉并按时间严格升序。第二是过滤异常比如单题作答耗时低于 1 秒的大概率误触、同一知识点连续出现上百次的大概率脚本刷题这些噪声会严重污染参数估计。第三是做知识点映射把所有标签统一到一个稳定的整数 ID 空间这一步最好落成一张维表方便后续追溯。第四是划分数据集这一步特别重要一定要按用户划分不能按记录随机划分。因为同一个学生的作答记录之间存在强相关性随机划分会让训练集和测试集共享同一批学生的行为模式AUC 会虚高好几个百分点上线后原形毕露。我一般按 8:1:1 分用户并保证每个知识点在测试集里至少有几十条记录否则评估结果不可信。提示划分完数据集后务必统计一下训练集和测试集的知识点覆盖率。如果某个知识点在测试集中完全没出现过模型在这个知识点上的表现就是未定义的别把它算进平均指标里。3.2 BKT 的前向预测与参数估计实现先看最核心的前向推断这段代码决定了线上每次作答后怎么更新状态import numpy as np def bkt_update(obs_seq, p_l0, p_t, p_g, p_s, eps1e-9): 返回每一步作答前对当前题的答对概率预测 p_l p_l0 preds [] for correct in obs_seq: p_correct p_l * (1 - p_s) (1 - p_l) * p_g preds.append(p_correct) if correct 1: post p_l * (1 - p_s) / max(p_correct, eps) else: p_wrong p_l * p_s (1 - p_l) * (1 - p_g) post p_l * p_s / max(p_wrong, eps) p_l post (1 - post) * p_t return preds这里的顺序很讲究先用当前状态预测本题再用观测结果更新后验最后叠加转移概率三步不能颠倒。如果你先转移再预测序列的第一步就会丢掉初始掌握概率的信息预测会系统性偏低。参数估计用 EM实际项目里我一般直接上 pyBKT 这个库接口写得比较清楚from pyBKT.models import Model model Model(seed42, num_fits20) model.fit(datadf, skillsskill_id) preds model.predict(datadf) auc model.evaluate(datadf, metricauc)注意num_fits20这个参数它控制随机初始化的次数是应对 EM 局部最优的直接手段。我实测过同一个知识点跑一次和跑二十次AUC 能差 0.03 到 0.05这个差距在推题策略上是能感知到的。代价是训练时间线性增长所以通常只在离线标定阶段跑多次把参数固化下来线上只做前向更新。3.3 IRT 的参数标定与能力估计IRT 我倾向于用现成的库比如girth或py-irt自己从零写 EM 很容易在数值稳定性上翻车。但如果要理解过程可以先看 2PL 的似然函数长什么样import numpy as np def p_correct(theta, a, b): return 1.0 / (1.0 np.exp(-a * (theta - b))) def neg_loglik(params, X, mask): n_stu, n_item X.shape theta params[:n_stu] a params[n_stu:n_stu n_item] b params[n_stu n_item:] logit a[None, :] * (theta[:, None] - b[None, :]) p 1.0 / (1.0 np.exp(-logit)) p np.clip(p, 1e-6, 1 - 1e-6) ll X * np.log(p) (1 - X) * np.log(1 - p) return -np.sum(ll * mask)X 是学生×题目的作答矩阵mask 用来标记哪些位置真的有作答记录。为什么要用 mask因为作答矩阵极度稀疏一个学生通常只做了题库的百分之一没有 mask 的话模型会把没做过当成做错了参数直接跑飞。这就是 IRT 在稀疏数据上的核心难点也是为什么样本量大且作答分布均匀的考试数据更适合 IRT。实际标定时还有个规范动作把 θ 和 b 的均值固定为 0标准差固定为 1否则模型会有无穷多组等价解比如把所有 θ 加一个常数、所有 b 也加同一个常数似然不变参数就不可辨识了。这个约束叫量尺固定是 IRT 的标配。3.4 DKT 的网络结构与训练要点DKT 的输入构造是最容易被写错的地方我先把结构说明清楚。假设知识点总数为 K那么在时刻 t输入向量是一个 2K 维的拼接前 K 维是当前题目知识点的 one-hot后 K 维是这道题答对了的 one-hot答对就是 [1,0]答错就是 [0,1]。输出层的维度是 K第 k 维表示下一题如果考知识点 k答对的概率。训练时的目标是对应的下一题的实际对错。import torch import torch.nn as nn class DKT(nn.Module): def __init__(self, n_skills, hidden128, dropout0.2): super().__init__() self.n_skills n_skills self.lstm nn.LSTM(2 * n_skills, hidden, batch_firstTrue) self.drop nn.Dropout(dropout) self.fc nn.Linear(hidden, n_skills) def forward(self, x): h, _ self.lstm(x) return torch.sigmoid(self.fc(self.drop(h)))训练时用二元交叉熵但一定要注意 mask一个 batch 里每条序列长度不同padding 出来的位置必须排除在损失之外否则模型会学到一堆无意义的填充模式。构造输入的代码大致如下def build_sequence(skills, corrects, n_skills): T len(skills) x torch.zeros(T, 2 * n_skills) y torch.zeros(T) y_mask torch.zeros(T) for t in range(T - 1): x[t, skills[t]] 1.0 x[t, n_skills (0 if corrects[t] 1 else 1)] 1.0 y[t] corrects[t 1] y_mask[t] 1.0 return x, y, y_mask超参数上隐藏层 128 到 256 是常见区间层数一层通常够用两层以上在小数据集上很容易过拟合。学习率从 1e-3 起调配合早停。我还会监控训练集和验证集的 AUC 差距如果超过 0.05就说明过拟合了这时候要么加 dropout要么减少隐藏层维度要么直接增加数据。原始论文里提到 DKT 在小样本知识点上过拟合严重实践中我建议把出现次数低于某个阈值比如 100 次的知识点合并或者剔除宁可少几个预测目标也不要让噪声样本拖垮整个网络。3.5 评估指标怎么选才不会被自己骗知识追踪最常用的指标是 AUC它衡量的是模型把答对排在答错前面的能力对类别不平衡不敏感所以很适合作答数据。其次是 ACC 和 RMSE前者是阈值 0.5 下的准确率后者衡量预测概率和实际 0/1 结果之间的平方误差。我的建议是主看 AUC辅看 RMSEACC 只作为参考因为答题正确率本身往往偏高ACC 容易被多数类主导。计算 AUC 时有个容易被忽略的点要用模型在做这道题之前的预测而不是做完之后的。如果你用后验状态去预测当前题就构成了信息泄漏AUC 能虚高到 0.9 以上。我一开始就犯过这个错离线指标漂亮得不像话上线后一塌糊涂排查了两天才发现问题出在预测和更新的顺序上。另外报告结果时最好按知识点分组统计平均值会被高频知识点主导掩盖掉长尾上的糟糕表现。4. 踩坑记录与常见问题排查4.1 数据层面的坑八成的问题出在这里我做过几个知识追踪项目最后复盘下来绝大部分效果不达预期都能追溯回数据。第一个高频问题是知识点粒度过细或者过粗。粒度过细比如把一元二次方程拆成十几个子技能每个子技能样本量都不足BKT 参数估计不出来DKT 的输入维度还特别大粒度过粗比如把整个初中代数当一个知识点模型学出来的状态没有区分度推题也没法精准。我的经验是一个知识点在一个月内至少要有几百次有效作答否则就该往上合并。第二个问题是标签迁移教研改了知识点体系但历史数据还用老标签模型训练时看到的是两套 ID效果自然崩。这个必须通过标签版本管理解决每次变更都要留映射表。第三个问题是时间戳异常客户端时间不准、离线补报导致顺序错乱这会直接破坏序列建模的输入顺序。我们的做法是服务端统一打时间戳客户端时间只作为参考并且检测时间倒序的记录直接丢弃。4.2 模型层面的坑参数不可辨识与过拟合BKT 上最常见的是参数跑到边界。比如 P(S) 估计成 0意味着会了就绝不会错这在真实数据里显然不成立。出现这种情况通常是样本里某个知识点几乎没有答错记录——学生要么全对要么这个知识点本身就简单。解决办法是加先验约束或者用贝叶斯版本的 BKT 给参数加个 Beta 先验把估计值往回拉一拉。还有一种情况是 P(G) 和 P(T) 互相纠缠两者都能解释不会却做对了导致参数不可辨识这时候可以通过固定 P(G) 为一组经验值来打破僵局。IRT 上的坑主要是单维性假设被违反尤其是把不同学科的题目混在一起标定出来的难度参数没法解释。检验方法是做因子分析看第一因子解释的方差比例一般要求高于 20%。DKT 上的坑主要是过拟合和数据泄漏前面已经提过还有一个不太被注意的是序列截断长度的选择。序列太长显存吃不下太短又丢掉远期信息我一般截到 200 步并且优先保留最近的记录因为远期行为的预测价值本来就低。4.3 常见问题速查表现象可能原因排查方向处理方式离线 AUC 很高但线上差按记录随机划分、信息泄漏检查划分方式与预测更新顺序改按用户划分预测用更新前状态BKT 参数跑到 0 或 1样本不足或约束缺失统计每个知识点的样本量与正负例比例加 Beta 先验设样本量下限IRT 难度参数全挤在一起单维性不满足或量尺未固定做因子分析检查题目来源按学科拆分标定固定量尺DKT 训练集 AUC 远高于验证集过拟合、知识点样本少对比两者曲线统计长尾知识点增大 dropout合并长尾知识点预测概率普遍偏高猜对率高、题目太简单统计整体正确率与 P(G)引入猜测参数调整题库难度状态更新延迟高全量重算而非增量前向检查服务是批处理还是流式改造成增量前向更新这张表基本上覆盖了我遇到过的主要问题。补一句实操心得排查时先别急着调模型把数据分布、样本量、作答顺序这三件事确认一遍能解决大半问题。模型本身出问题的概率其实没那么高。注意任何一次改动之后都要重新跑一遍完整的离线评估并和线上 A/B 结果对齐。我见过不止一次因为改了知识点映射表导致离线指标和线上表现完全脱节的情况。5. 工程落地与后续扩展的几点经验5.1 冷启动和在线更新怎么做才不别扭新用户没历史数据模型给不出有意义的预测这是所有知识追踪系统都要面对的问题。我的处理方式是分阶段作答记录少于 10 条时直接用知识点维度的平均正确率作为兜底预测同时不做个性化推题只按教学大纲顺序走10 到 50 条时用 BKT 估计状态因为它的初始掌握概率可以从群体先验里借力即使个人数据少也能给出合理值超过 50 条之后再考虑切换到 DKT。这个分阶段策略在工程上也好实现因为在线更新成本从低到高排列用户量大的时候可以用廉价模型兜住底部流量。在线更新方面BKT 只需要一次前向计算状态可以存在 Redis 里每次作答后读出来更新再写回去延迟在毫秒级IRT 如果题目参数已经标定好新用户上线只需要用 MLE 或 EAP 估一个能力值成本也很低DKT 麻烦一些需要保存用户最近的一段序列每次更新跑一次前向序列长的时候延迟会上来可以用缓存隐藏状态的方式优化——LSTM 的 h 和 c 是可以保存下来接着往下算的不必每次从头重跑整条序列。5.2 可解释性怎么补上让模型能被教研接受BKT 和 IRT 天然可解释真正麻烦的是 DKT。产品经理和教研老师一定会问系统凭什么说这道题他该练如果你只能说模型算出来的这个功能就推不动。我试过几种补救办法。第一种是事后解释用输入的梯度或者注意力权重去看当前预测主要受哪几个历史作答影响然后把最相关的记录列出来给教研看。第二种是用 ITR 或者 BKT 的输出作为代理DKT 只用来修正排序最终展示给用户的理由还是来自可解释模型。第三种是加辅助任务比如在训练时让 DKT 同时预测每个知识点当前的掌握状态用 BKT 的估计做监督这样中间层的表示会带上一些可解释的语义。实测下来第二种最省事效果也最稳毕竟业务真正需要的往往不是完全理解模型而是有一个能自圆其说的依据。第一种适合做内部诊断工具第二种适合直接面向用户第三种更适合研究探索。5.3 可以从这三个模型再往前走的方向如果基础版本已经跑稳还有几个方向值得试。一个是在 BKT 基础上做知识点之间的层级建模比如把先修关系编码成约束让学生掌握 A 之后再更新 B 的状态这在知识图谱比较清晰的学科里效果明显。另一个是给 DKT 的输入加上更多特征比如作答耗时、提示使用次数、题目难度参数这些信息在原始 DKT 里是被丢掉的但对预测帮助很大我试过把耗时离散化后拼进输入向量AUC 有小幅提升。再一个是把三个模型的输出做集成用简单的加权平均或者逻辑回归做融合层这个做法工程成本低而且能吃掉各模型的互补信息通常比单模型稳定。最后如果是多学科场景可以考虑跨学科共享底层表示、上层分学科微调这样冷启动学科也能享受到其他学科的数据红利。最后分享一个我踩过的坑当作收尾。早期我很迷信离线 AUC觉得只要指标刷上去线上就一定能打。结果有一次模型 AUC 从 0.78 提到 0.82推题的点击率反而降了。后来才发现提升主要来自高频知识点长尾知识点的表现其实变差了而用户感受到的正是长尾部分。从那以后我养成了一个习惯每次评估都按知识点分组看指标分布重点盯 P10 和 P25 分位数而不是只看平均值。知识追踪说到底是个服务学生的工具模型在长尾上的一点改善落到具体学生身上可能就是一次不再被重复折磨的练习。
返回列表