☰
知识追踪KT实战:BKT、IRT、DKT对比与掌握度建模
2026/9/30 21:24:53 网站建设 项目流程

前阵子帮一个做在线题库的朋友复盘他们的「薄弱知识点推荐」功能,翻完评分逻辑我有点哭笑不得:他们把学生最近十道题的正确率当成掌握度,做对八道就算掌握,做对三道就推一堆基础题。听起来合理,但实际效果很差——一个学生连续做对五道简单题之后,系统就认定他会了,接着推的难题他全军覆没,学生的挫败感直接反映在次日的留存曲线上。

问题出在哪?正确率是「表现」,不是「掌握」。学生的知识状态是个看不见的隐变量,它随时间演化,又会被每一道题的作答反过来更新。知识追踪(Knowledge Tracing,简称 KT)要解决的就是这个隐变量推断问题:给定一个学生的历史答题序列,估计他此刻对每个知识点的掌握程度,并预测他在下一道题上的作答表现。这条技术路线大致分三支——BKT 走的是概率图模型,IRT 走的是测量学里的能力-难度标尺,DKT 则把 RNN 搬了进来。这三者经常被摆在一起比较,但它们的出身、假设、适用边界其实差得很远。

这篇东西写给两类人:一类是刚开始接触教育数据挖掘、想搞清楚这几个缩写到底在干什么的算法同学;另一类是做教学产品、被推荐系统坑过、想知道背后那套概率是怎么算出来的产品和技术负责人。我会尽量把公式讲到能自己手算一遍的程度,也会把我自己在真实数据上踩过的坑写透。

1. 知识追踪要解的那个"隐变量"到底是什么

1.1 从正确率到知识状态:滑动窗口为什么不够用

先做个思想实验。两个学生各做了十道题,都是七对三错,正确率一样。但学生 A 的前七道全对、后三道全错,学生 B 是前七道全错、后三道全对。如果只看正确率,这两个人没区别;可从教学角度看,A 更像是在某个知识点上出现了衰退或者疲劳,B 更像是刚学会。再做一件事就更清楚了:如果学生 B 后三道对的是同一类应用题,那他很可能是真的掌握了这一类题的解法。

滑动窗口正确率的根本问题在于,它把所有作答当成等权重的独立事件,既不区分题目难度(做对一道压轴题的证据量和做对一道口算题完全不一样),也不区分时间顺序(三周前做错和昨天做错,对当前状态的指示意义不同),更不建模"掌握"这个状态本身的变化。知识追踪模型的三个核心动作恰好对应这三个缺口:难度建模、顺序建模、状态转移建模。BKT 用转移概率建模状态变化,IRT 用能力-难度差建模难度,DKT 用循环神经网络的时间步机制把顺序信息编码进隐向量。

顺便说一句,这里有个容易混淆的概念:知识追踪和"知识图谱推理"完全不是一回事。前者是纵向的、针对单个学习者随时间的状态推断,后者是横向的、针对知识点之间关系的结构推理。真正的教学系统里两者常常配合使用——先用知识图谱算出当前这道题的前置知识点,再用知识追踪看这个学生的前置知识点掌握到什么程度,最后决定推什么题。搞清楚这个分工,后面选模型就不会拧巴。

1.2 三条路线的出身决定了它们的性格

BKT 的祖本是隐马尔可夫模型,诞生在上世纪九十年代末,最初用在智能导师系统里。它的世界观很朴素:每个知识点对应一个二元状态(掌握 / 未掌握),学生答题是带有噪声的观测,会猜对也会手滑。整套模型只有四个参数,好处是每个参数都能给老师解释清楚,坏处是它对"知识点之间互相影响"这件事完全无能为力。

IRT(项目反应理论)的出身是心理测量学,比 BKT 还老,最早用来做标准化考试的能力估计和分数等值。它的世界观是:学生的能力是一个连续标量 θ,题目的难度是另一个连续标量 b,作答正确概率由这两者的差决定。它擅长的是横向比较(一百个学生在同一份卷子上的相对水平),弱项是纵向追踪(同一个学生在一个学期里的能力变化)。

DKT 是 2015 年前后深度学习进入教育领域的产物,用 LSTM 或 GRU 直接吃答题序列,输出下一题答对的概率。它的世界观是:知识状态不需要人为定义,让网络自己学一个高维隐向量去表达。好处是拟合能力强、指标好看,坏处是可解释性差、训练成本高、在小数据上容易过拟合到没边。

1.3 有些场景你真的不需要知识追踪

我见过不少团队一上来就要上 DKT,理由是"深度学习效果好"。结果数据量只有几千条交互记录,跑出来的 AUC 还不如直接用题目平均正确率。判断标准很简单:如果你的系统只有几十个知识点、每个学生平均交互次数不到二十次、还没有明确的"推题"需求,那就别折腾了,用 IRT 或者更简单的 PFA(Performance Factors Analysis)就够了。

真正值得上知识追踪的场景通常有三个特征:知识点数量在几百到几千这个量级、学生的人均交互序列足够长(教育类产品里一般要求人均五十次以上有效作答)、系统需要做细粒度的下一步推荐。这三个条件同时满足的时候,模型带来的增益才能被用户感知到。我在一个职业技能考试产品上做过对比,同样是推题策略,基于 BKT 的掌握度估计相比基于正确率的启发式规则,用户七日留存提升了接近四个百分点,而换成 DKT 之后只再涨了不到一个点——但工程复杂度翻了三倍。这笔账怎么算,得看你的团队规模。

2. BKT为什么二十年还没过时:四个参数背后的贝叶斯推理

2.1 四个参数的物理含义与取值范围

BKT 里的每个知识点都独立维护一组参数,一共四个,我习惯按它们在推理流程里出现的顺序来记:

参数含义典型取值区间取值含义解读
P(L0)初始掌握概率0.05 ~ 0.4有多少比例的零基础学生其实已经会了
P(T)学习转移概率0.05 ~ 0.35从"不会"跨到"会"的单步概率
P(G)猜对概率0.05 ~ 0.3没掌握却蒙对的可能性
P(S)失误概率0.02 ~ 0.2掌握了却做错的可能性

P(L0) 反映的是先验,比如一个小学四则运算知识点,大部分学生的初始掌握概率会偏高;而一个高等数学知识点,P(L0) 基本接近下限。P(T) 是四个参数里最有教学价值的一个,它近似刻画了"练一道题能提升多少"。P(G) 和 P(S) 是噪声项,它们的存在让模型能容忍"答错了但其实是会的"和"答对了但其实不会的"这两种情况——这是 BKT 相比正确率统计最本质的优势。

提醒:P(G) 和 P(S) 有很强的耦合性。如果你只用两三道题的序列去拟合,模型很可能把所有参数都推到边界上,出现 P(G) 接近 1、P(S) 接近 0 这种"完全靠猜"的退化解。这不是 bug,是数据量不足导致的参数不可辨识。

2.2 一次答题之后,掌握概率是怎么被更新的

BKT 的一次更新分两步:先用贝叶斯公式根据观测结果修正当前的掌握概率,再叠加一次状态转移。假设学生在作答前系统给出的掌握概率是 P(L),作答结果是 correct。

答对时,用贝叶斯公式算后验:

P(掌握 | 答对) = [P(L) × (1 − P(S))] / [P(L) × (1 − P(S)) + (1 − P(L)) × P(G)]

答错时:

P(掌握 | 答错) = [P(L) × P(S)] / [P(L) × P(S) + (1 − P(L)) × (1 − P(G))]

算完后验之后,再做一次学习转移:P(L') = P(掌握 | 观测) + (1 − P(掌握 | 观测)) × P(T)。这个后验值就成了下一道同知识点题目的先验。整套推理就是这样一个前向递归,把它写成代码一共不到十行:

def bkt_update(p_l, p_t, p_g, p_s, correct): """按 BKT 前向算法更新一次掌握概率""" if correct: 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) post = num / den if den > 1e-12 else p_l return post + (1 - post) * p_t # 叠加学习转移 # 一个学生连续作答 5 道同知识点题目的掌握度轨迹 p_l, seq = 0.2, [True, True, False, True, True] for i, ok in enumerate(seq, 1): p_l = bkt_update(p_l, p_t=0.15, p_g=0.2, p_s=0.1, correct=ok) print(f"第{i}题 {'答对' if ok else '答错'} -> 掌握度 {p_l:.3f}")

拿这组参数跑一遍,你能直观看到几个反直觉的现象:初始掌握度 0.2 的学生答对第一题后跳到 0.43,答对第二题后到 0.72,可一旦答错第三题,掌握度会掉到 0.42——掉得比涨得还快,因为 P(G)=0.2 意味着答对的信息量没那么大。这就是掌握度和正确率最根本的区别。

2.3 用 EM 把参数从数据里"磨"出来:我踩过的参数退化坑

参数怎么来?标准做法是用 EM 算法在历史数据上做极大似然估计,E 步跑前向算法拿到每个时刻的隐状态后验,M 步更新四个参数。也可以用网格搜索加交叉验证,在参数量这么小的情况下效果差不多,而且更好调。

我自己踩得最狠的一个坑是知识点粒度。一开始我们把一道综合题标记了三个知识点,也就是同一次作答会影响三个 BKT 模型的状态。跑出来的参数完全没法看:P(G) 普遍偏高,因为综合题里的每个知识点只覆盖了部分解题步骤,学生做错了不一定是这个知识点不会。后来改成"一道题只挂一个主知识点,其余作为关联标签只用于推荐召回,不进入 BKT 的观测",参数立刻变得可解释多了。这个经验我觉得挺通用:BKT 适合单一技能的细粒度建模,强行让它处理多技能耦合的题目,等于给贝叶斯网络加了错误的观测模型。

另一个坑是冷启动知识点。新上线一个知识点时没有历史数据,P(L0) 只能拍脑袋。我的做法是先用题目难度系数反推一个先验——把 IRT 估计出的难度 b 值做一次单调映射,难度越高 P(L0) 越低,然后随着数据积累再用 EM 覆盖。这样至少不会一上线就把新知识点推给所有人。

2.4 BKT在真实数据上的天花板在哪

在 ASSISTments 这类公开数据集上,BKT 的 AUC 一般落在 0.67 到 0.75 之间,比简单正确率基线高出五到八个百分点。想再往上走,最有效的不是换模型,而是给 BKT 加扩展项。常见的几种:

  • BKT-Forget:增加一个遗忘概率 P(F),让已掌握状态有机会回退。适合长周期知识(比如外语词汇),对短周期的操作类技能反而有害。
  • BKT with Item Difficulty:把每个题目自己的 P(G) 和 P(S) 拆出来,而不是一个知识点共用一个。参数量上去之后需要更多数据支撑。
  • Individualized BKT:按学生分层估计 P(L0) 和 P(T),把"起点差异"和"学习速度差异"显式建模。

我的经验是,先加难度项,再考虑个性化项,遗忘项放在最后。因为前两者的收益稳定且容易验证,遗忘项很容易和数据里的噪声混淆,调不好反而拉低指标。

3. IRT的位置感:它不追时间,但它把题目和学生放进同一把尺子

3.1 1PL、2PL、3PL的区别就在那几个字母上

IRT 的家族命名规则很直白,数字代表有几个参数。最基本的 1PL 模型(也叫 Rasch 模型)只有一个题目难度参数 b 和一个学生能力参数 θ:

P(答对) = 1 / (1 + exp(−(θ − b)))

这个式子的含义是:能力和难度相等时,答对概率恰好是 0.5。能力每高出一个单位(logit),答对概率就往上抬一截。2PL 在此基础上加了一个区分度参数 a,用来刻画"这道题能不能有效区分高低水平的学生":

P(答对) = 1 / (1 + exp(−a(θ − b)))

区分度 a 越大,曲线越陡,学生能力稍微高一点答对概率就飙升;a 越小曲线越平,好的差的学生答对概率差不多,这种题在测评里基本是废题。3PL 再加一个猜测参数 c,处理选择题:

P(答对) = c + (1 − c) / (1 + exp(−a(θ − b)))

c 就是那条曲线在能力极低时的下限。四个选项的选择题,理论上 c 约等于 0.25,实际估出来 0.15 到 0.3 都算正常。

模型参数个数适用题型典型问题
1PL / Rasch1(b)主观题、填空题忽略题目区分能力差异
2PL2(a, b)客观题、短答题需要较大样本量才能稳定估计 a
3PL3(a, b, c)四选一选择题c 估计不稳定,易与 a 混淆

曾经有个团队拿着两三百条作答记录就要估 3PL 参数,结果估出来的区分度有负数,猜测参数大于 0.6。这不是模型的问题,是样本量的问题。经验阈值是:估 2PL 参数,每道题至少需要两百到五百条作答记录;估 3PL 参数,建议每道题给到一千条以上。样本不够就退回 1PL,参数虽然粗,但至少稳定。

3.2 能力值和难度值是怎么被估出来的

学生能力 θ 的估计有两套主流思路。极大似然估计(MLE)是找那个让当前作答模式出现概率最大的 θ 值,实现简单,但对全对和全错这两种极端作答模式会失效——全对的学生似然函数单调递增,MLE 会给出无穷大。所以工业系统里更常用贝叶斯方法,比如 EAP(期望后验),给 θ 加一个正态先验,然后把后验分布的均值作为估计值。先验的方差控制了估计的保守程度:方差大,估计更贴近数据;方差小,估计更靠近总体均值,极端值被压制。

题目参数的估计通常用 EM 或者马尔可夫链蒙特卡洛,流程上比 BKT 重得多,需要专门的标定环节。这也是为什么成熟的题库会有"预测试题"这个流程——新题先放进正式考试里但不计分,攒够作答数据再做参数标定,标定完成后才能进入正式题库。

3.3 自适应测评里IRT是发动机,但单独用它做追踪会缺什么

计算机自适应测评(CAT)是 IRT 最经典的应用:每次根据当前 θ 估计值和题目信息量函数,挑一道信息量最大的题呈现给学生,答完立刻更新 θ,如此往复。相比固定卷子,CAT 能用一半的题量达到同样的测量精度。

但把它直接当知识追踪用会缺两块东西。第一,它是跨知识点的能力标量,一个 θ 值代表的是整体水平,没法告诉你学生在"一元二次方程"和"相似三角形"上分别处于什么状态。第二,它没有时间维度上的状态转移,θ 在两次测试之间是静止的,学生中间学了什么、忘了什么,模型不关心。所以 IRT 在知识追踪里的正确位置是辅助角色:提供题目的难度标尺、提供学生的基线能力、在题目冷启动时给出一个不依赖作答历史的先验。

一个很实用的做法是把 IRT 估计出的 θ 和 b 当作特征喂给序列模型,或者用 θ 的增量(本次测试 θ 减去上次测试 θ)作为知识增长的监督信号。我在一个项目里试过这种做法,相比纯 DKT,AUC 提升了大约两个点,而且模型的输出更容易向教研团队解释——因为他们本来就在用难度系数和达标线这套语言。

4. DKT:把答题序列喂给RNN之后,模型到底学到了什么

4.1 输入构造这一步就决定了DKT的上限

DKT 的输入是一个交互序列,每个时间步编码成 one-hot 向量。原始论文里用的是「题目 ID × 作答结果」的联合编码:如果有 M 道题,输入维度就是 2M,前半段表示"题目 i 答对",后半段表示"题目 i 答错"。输出维度是 M,第 i 个位置表示"该学生答对第 i 题的概率"。

这个编码方式有个广为人知的问题:参数规模随题目数线性膨胀。题库一万道题,输入维度两万、输出维度一万,光是嵌入层就够呛,而且每道题的作答数据被稀释得厉害。工业界普遍改用技能级编码:把题目按知识点聚合,输入维度变成 2K(K 为知识点数量),输出维度 K。代价是丢失了题目粒度的信息——同知识点下的简单题和压轴题被当成同一个东西。折中方案是技能级编码叠加题目难度作为额外特征,维度只增加几维,信息量却能补回来不少。

序列长度也是必须处理的问题。DKT 用 LSTM 时,序列太长会有梯度问题和显存问题,同时远期的作答对当前状态的意义本来就在衰减。我的做法是按时间倒序截取最近 200 到 500 次有效交互,再往前做一次聚合(比如把更早的作答压缩成每个知识点的历史正确率和作答次数两个标量),拼在序列最前面。这样既不丢长期信息,也不至于让序列无限增长。

4.2 一个能跑起来的DKT实现:张量形状、掩码与损失

下面这段是我常用的骨架,把一个 batch 的学生序列喂进去,拿到每个时间步对所有知识点的预测概率。关键在于 padding 和 mask 的处理,这是新手最容易写错的地方。

import torch import torch.nn as nn class DKT(nn.Module): def __init__(self, num_skills, hidden=128, layers=1, dropout=0.2): super().__init__() self.num_skills = num_skills # 输入 2*K: 前 K 维为答对, 后 K 维为答错 self.lstm = nn.LSTM(2 * num_skills, hidden, layers, batch_first=True, dropout=dropout if layers > 1 else 0.0) self.out = nn.Linear(hidden, num_skills) def forward(self, x, lengths=None): # x: (B, T, 2K) if lengths is not None: packed = nn.utils.rnn.pack_padded_sequence( x, lengths.cpu(), batch_first=True, enforce_sorted=False) h, _ = self.lstm(packed) h, _ = nn.utils.rnn.pad_packed_sequence(h, batch_first=True, total_length=x.size(1)) else: h, _ = self.lstm(x) return torch.sigmoid(self.out(h)) # (B, T, K) def build_batch(records, num_skills, max_len=200): """records: [(skill_id, correct), ...] 按学生分组后的单个学生序列""" seq = records[-max_len:] x = torch.zeros(len(seq), 2 * num_skills) for t, (s, ok) in enumerate(seq): x[t, s if ok else s + num_skills] = 1.0 return x

训练损失是这个模型里最讲究的一块。不能对整条序列的每个时间步都算损失,因为大多数时间步对应的那道题和预测目标无关。正确做法是:在时间步 t,只取「学生在 t+1 时刻实际作答的那个知识点」对应的预测概率,和真实作答结果算二元交叉熵。写出来是这样:

def dkt_loss(pred, skills, corrects, mask): """pred: (B,T,K) skills/corrects/mask: (B,T)""" B, T, K = pred.shape idx = skills.reshape(B, T, 1).clamp(0, K - 1) p = pred.gather(2, idx).squeeze(-1) # (B,T) loss = nn.functional.binary_cross_entropy(p, corrects.float(), reduction='none') loss = (loss * mask).sum() / mask.sum().clamp(min=1) return loss

这里 skills[t] 要错开一位——用 t 时刻的隐状态预测 t+1 时刻的作答。这个 offset 我见过至少三个人写错,导致模型直接看到答案,训练 AUC 冲到 0.99,一到验证集就掉到 0.7。如果你发现指标高得离谱,第一件事就是检查这个位移。

4.3 DKT被吐槽最多的三个点:单调性、重构、冷启动

第一个槽点是预测不单调。一个学生在某个知识点上连续答对五道题,直觉上掌握度应该单调上升,但 DKT 的输出可能出现先升后降再升的抖动,甚至答对之后的预测概率反而比答对之前更低。根源在于隐状态的更新不是单向的,LSTM 的门控机制可能让某些输入产生反直觉的漂移。工程上的补救办法是在推理层加一个后处理:对同一知识点的预测做指数平滑,或者加一个单调性正则项参与训练。

第二个槽点是输入重构问题。DKT 的输出只在"下一道题属于已见过的知识点"时有意义,遇到一个从没出现过的知识点组合,模型只能靠隐向量的泛化硬猜。这在题库持续扩充的产品里非常致命。

第三个槽点是冷启动。新学生没有任何历史,模型的第一个预测基本等于瞎猜。可行的做法是用人口统计学特征或者入学测评的 IRT 能力值初始化隐状态,或者在序列开头补一个"虚拟交互序列",用该学生在其他相似知识点上的表现填充。

4.4 从DKT到DKVMN、SAKT、AKT:这些衍生模型解决了什么

DKT 之后至少出现了几十个变体,挑几个有代表性的说清楚它们各自的动机就好办了。

DKVMN(动态键值记忆网络)用一组静态的键矩阵表示知识点、动态的值矩阵表示每个知识点的掌握状态,读过程算注意力、写过程更新值矩阵。它解决了 DKT 的隐状态不可解释问题——你可以直接读出每个知识点的状态向量。

SAKT 引入自注意力机制,直接对历史交互做注意力聚合,不再依赖循环结构,训练可以并行,长序列上的表现也更稳。它的一个副产品是可解释性:注意力权重能告诉你这次预测参考了历史上的哪几次作答。

AKT 在 SAKT 基础上加了一个基于知识关系的先验,用题目或知识点的嵌入相似度来调制注意力权重。这对知识点之间有明显层级关系的学科(比如数学)收益比较明显。

还有一个方向值得一提:把 IRT 的单调性先验塞进深度模型,比如让预测概率随某个"能力维度"单调变化。这类模型的指标不一定比纯黑盒好,但在教学场景里可信度更高——老师看到一条平滑上升的掌握曲线,会更愿意相信系统给出的建议。

5. 三个模型怎么选:把数据量、可解释性和工程成本摆在一起看

5.1 一张对照表看清楚各自的适用边界

维度BKTIRTDKT 及其变体
建模对象单知识点的二元状态学生能力与题目参数交互序列的隐向量
时间建模有(马尔可夫转移)无有(序列模型)
冷启动依赖先验参数需要标定数据最差
可解释性强,每个参数可讲强,量表语言成熟弱,需事后解释
数据需求每个知识点几百条每道题几百条十万级交互起步
工程复杂度极低,单机可跑中,需要标定流程高,需要 GPU 和特征管道
典型 AUC0.67 ~ 0.750.65 ~ 0.720.75 ~ 0.83

这张表要横着看。数据量是第一个筛子,可解释性是第二个筛子。如果你的教研团队需要向老师解释"为什么给这个学生推这道题",DKT 就得配上归因分析,成本会明显上升。反过来,如果产品形态是高频刷题,学生动辄几千次交互,且推荐逻辑不需要向人解释,那深度模型的空间就大得多。

5.2 混合方案:用IRT给DKT喂特征,用BKT做兜底

真实系统很少只用一种模型。我目前比较推荐的组合是三层:

第一层用IRT做题目标定,产出每道题的难度和区分度,同时给学生一个学期初的基线能力值。第二层用BKT做细粒度的知识点掌握度估计,作为可解释的兜底输出,当深度模型不可用时(数据不足、服务降级)直接顶上。第三层用DKT 类模型做精细化预测,输入里拼接 IRT 的难度特征和 BKT 的当前掌握度特征。

这种拼接带来的收益是实打实的。我在一个 K12 数学项目上做过消融:纯 DKT 的验证集 AUC 是 0.782,加上 IRT 难度特征后到 0.794,再加上 BKT 掌握度特征后到 0.806。提升幅度不算惊人,但考虑到线上 AUC 每涨一个点都很费劲,这个性价比已经不错了。更重要的是,兜底链路的存在让系统的可用性上了一个台阶。

5.3 冷启动和线上漂移:真正让人头疼的部分

模型在离线指标上漂亮,上线之后表现掉一截,原因通常是两类。第一类是学生冷启动:新用户没有历史序列,深度模型的预测接近随机。解决方法是搭一条降级链路——序列长度小于 10 次时走 IRT 基线,10 到 50 次时走 BKT,超过 50 次才启用深度模型。这个阈值需要按你产品的实际分布来定,不能照搬。

第二类是知识漂移。新题不断上线,知识点标签体系也在调整,模型训练时见到的分布和线上请求的分布逐渐错位。我的做法是每周重训一次模型,但重训前必须先跑一次标签一致性检查——统计新旧知识点映射的差异比例,如果超过 5% 就暂停上线,先做人工核对。这个检查我们是通过把它写进训练流水线的卡点实现的,省了好几次线上事故。

还有一类隐蔽的漂移来自题目参数。一道题在题库里放了半年,经历过大规模曝光之后,它的实际难度会下降(学生之间互相讨论、老师讲解过、甚至题目本身被记住了)。IRT 参数如果不定期重新标定,难度值就会失真,进而污染整个特征管道。建议每季度对高频曝光的题目做一次参数复估。

6. 落地一个知识追踪系统的完整链路与评估陷阱

6.1 数据清洗与技能映射:90%的效果差异在这里

做过几个项目之后我的结论很明确:知识追踪的效果上限由技能映射质量决定,模型选择只影响几个点。原始数据里的知识点标注通常是一团乱麻——同一道题在不同版本里有三四个不同写法,同义的知识点被拆成好几个 ID,层级关系("一元一次方程"属于"方程")没有维护。

我的清洗流程大致是固定下来的:先做字符串归一化(去空格、统一全半角、统一大小写),再用编辑距离加规则匹配做候选合并,人工审核一遍高相似度候选,最后建立知识点的树形层级结构,把叶子节点作为 BKT 的建模单元、父节点作为聚合展示单元。整个流程走下来通常能减少三到四成的知识点数量,AUC 能提升两到三个点,而且这部分提升是最稳的,因为它减少的是标注噪声而不是模型偏差。

还要过滤掉脏样本:单次作答耗时低于一秒的(大概率误触)、同一道题在一分钟内重复提交多次且结果矛盾的、序列长度低于阈值的。这些样本留在训练集里,模型会学到一堆噪声模式。

6.2 划分数据集:为什么必须按学生切分

这是新手最容易犯的错误:把交互记录打散之后随机划分训练集和验证集。这样做的后果是同一个学生的记录同时出现在两边,模型只需要记住这个学生的隐状态就能"预测"验证集,指标虚高得离谱。我见过有团队报告验证集 AUC 0.95,一问才知道是按记录随机划分的。

正确做法有两种。按学生切分:把学生随机分成两组,一组的所有记录进训练集,另一组的所有记录进验证集。这种方式衡量的是模型对未见过的学生的泛化能力,对应产品里的新用户场景。按时间切分:用前 80% 时间窗口的记录训练,用后 20% 验证。这种方式衡量的是模型对未来的预测能力,更贴近真实上线场景,但会受时间分布变化影响。

我的建议是两个都做:按学生切分看泛化能力,按时间切分看时序稳定性,两个指标差距过大就说明模型对时间或人群有偏。另外一定要留一份完全独立的测试集,只在最终定版时用一次,避免反复调参导致的过拟合。

6.3 评估指标:AUC之外还要看什么

AUC 是知识追踪最常用的指标,但它有几个盲区。第一,它对校准(calibration)不敏感——模型说"80% 会答对"和"95% 会答对",只要排序对,AUC 可能一样。第二,它对知识点之间的差异不敏感——整体 AUC 0.8,可能在核心知识点上是 0.85,在边缘知识点上是 0.62。

所以我通常会同时看这几个指标:

指标关注点参考阈值
AUC整体排序能力> 0.75 可用,> 0.80 较好
ACC基于 0.5 阈值的准确率与 AUC 趋势一致即可
RMSE概率预测的数值精度< 0.42
ECE校准误差< 0.05
分知识点 AUC 最小值长尾场景不低于整体 AUC 减去 0.08

分知识点 AUC 这一项我强烈建议加上。曾经有个模型整体 AUC 0.81,看起来很美,但拆开一看,覆盖了 40% 交互量的前十个知识点 AUC 是 0.84,而后 30% 的长尾知识点 AUC 只有 0.58。这种模型上线之后,恰恰是长尾知识点上的推荐最不准,学生的体验反而更差。

除了统计指标,还要看教学指标。知识增长量是一个很直观的指标:同一个学生在使用系统前后各做一次同质测评,两次 IRT 能力值之差就是知识增长。这个指标比 AUC 更接近业务价值,但采集成本高,适合做 AB 实验的最终判定。

6.4 上线后的状态存储、缓存与AB实验设计

工程实现上有个坑值得单独说:掌握度状态的存储粒度。最朴素的做法是每次请求都从数据库拉出该学生的全部历史,重新跑一遍推理。学生历史长了之后,这个接口会慢到没法用。可行的方案是把模型隐状态定期快照存入缓存,每次请求时只增量推理最近几次交互,同时后台任务每天做一次全量重算来修正累积误差。快照的存储成本不高——BKT 只是几个浮点数,DKT 的隐向量也就几十到几百维。

推理链路要注意降级。模型服务超时或者返回异常时,必须有一个基于规则或者 IRT 的兜底输出,而不是直接返回空。教育产品的使用高峰集中在晚上和周末,流量毛刺明显,没有降级的推荐服务很容易在高峰期雪崩。

AB 实验设计上,别只盯着点击率。推荐系统在教育场景里有个经典的陷阱:推简单题能提高正确率和点击率,但学生学不到东西。所以实验的核心指标应该是知识增长量和后续测评的表现,点击率、答题时长只作为辅助指标观察。我一般会同时设三个层次的指标——过程指标(答题量、连续使用天数)、学习指标(同质测评的能力提升)、体验指标(主观满意度、退出率),三者都正向才判定实验成功。

最后说个容易被忽略的细节:AB 实验的分流必须按学生做,不能按请求做。同一个学生今天用实验组、明天用对照组,行为会被严重干扰,结论也就不可信了。分流时还要做分层,保证两组的初始能力分布没有显著差异,否则前一周的数据基本没法看。我在实际项目里会用一个固定哈希加能力分层的方式做分流,把初始 IRT 能力值分箱之后再在各箱内随机分配,这样一周内就能拿到相对干净的对比数据,不用等两周去平衡人群差异。

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

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

立即咨询