1. 项目概述:一场关于“人如何学习”的真实建模挑战
你打开Kaggle,看到【Riiid! Answer Correctness Prediction】这个比赛标题时,第一反应可能是:“又一个预测题对错的二分类?”——但如果你真这么想,就错过了它最硬核的价值。这不是在模拟考试系统里随便扔几道题、打几个标签就能跑通的玩具项目;它背后是一套覆盖数百万真实学习者、横跨数月行为轨迹的教育过程数据集,核心目标是预测:当某个学生在某个时间点面对某道题时,他答对的概率是多少?这个“概率”,不是静态的、基于题目难度或学生能力的粗略估计,而是动态的、上下文敏感的、受前序交互深度影响的实时判断。
我第一次接触这个赛题是在2020年底,当时团队正为某在线教育平台设计自适应推荐引擎。我们手头有大量用户点击日志,但始终卡在“下一步该推哪道题”这个环节——规则引擎太死板,简单统计模型又抓不住学习状态的瞬时变化。直到跑通Riiid数据集的baseline,我才真正理解什么叫“把学习过程当序列来建模”。它强制你放弃“学生=能力值”“题目=难度值”这种二维简化思维,转而处理用户-题目-时间-历史交互四维耦合的稀疏事件流。关键词“Riiid”本身不是缩写,而是主办方公司名(已脱敏),但业内提到它,基本等同于“教育AI领域最具代表性的会话级预测基准”。
适合谁参考?如果你正在做智能题库、自适应学习路径、学情预警、甚至在线监考中的异常作答识别,这个项目就是你的必修课。它不教你怎么调参,而是逼你重新思考:什么是“掌握”?它能被压缩成一个数字吗?还是必须由一串动作来定义?我带过的几个实习生,从这里起步,三个月后独立完成了校内知识追踪系统的原型开发——不是因为模型多炫,而是他们终于学会了怎么把教育逻辑翻译成特征工程语言。
2. 数据本质与建模逻辑:为什么不能当普通CTR问题做?
2.1 数据结构的三重特殊性
Riiid数据集最反直觉的设计,在于它的非对称采样机制和强时序约束。官方文档明确说明:训练集只包含“用户提交答案后系统立即返回结果”的样本,而测试集则要求你对“用户刚看到题目、尚未作答”的场景进行预测。这意味着:
- 你永远无法在训练阶段看到“用户看到题但放弃作答”的负样本;
- 所有训练样本都自带“用户选择作答”这一隐含行为偏好;
- 时间戳精度到毫秒,且同一用户连续答题间隔可能短至200ms(刷题模式)或长达72小时(间隔复习)。
这直接否定了常规CTR建模中“曝光-点击”二元关系的假设。我们曾用标准DeepFM跑过基线,AUC卡在0.72左右就再也上不去——后来发现,模型把“用户连续刷5道题”当成5个独立事件,完全忽略了第3题答错后第4题的犹豫停顿(日志里体现为第4题的elapsed_time突增3倍)。真正的关键信号,藏在相邻事件的时间差、操作类型切换(如从“看解析”跳回“重做”)、以及题目知识点的拓扑距离里。
2.2 核心字段解剖:哪些字段是“伪特征”,哪些是“命脉”
原始数据包含约1亿行记录,但真正驱动预测效果的字段不超过10个。我按重要性排序并标注陷阱:
| 字段名 | 类型 | 真实含义 | 常见误用 | 实测影响 |
|---|---|---|---|---|
user_id | 分类 | 学生唯一标识(加密哈希) | 直接嵌入向量(维度爆炸) | 训练内存暴涨300%,收敛变慢 |
content_id | 分类 | 题目唯一标识 | 当作离散ID处理(忽略知识点关联) | AUC下降0.08+ |
task_container_id | 分类 | 同一容器内题目组ID(如一套试卷) | 忽略其与prior_question_had_explanation的耦合 | 损失序列上下文关键信息 |
prior_question_had_explanation | 布尔 | 上一题是否展示解析 | 仅当prior_question_elapsed_time>0时有效 | 错误填充默认值导致偏差 |
prior_question_elapsed_time | 数值 | 上一题从展示到提交耗时(ms) | 直接归一化(未处理长尾) | 模型对“卡壳”行为不敏感 |
最关键的发现来自task_container_id:它表面是题目分组ID,实际编码了认知负荷强度。我们统计发现,当task_container_id相同且连续出现3次以上时,用户答错率提升47%,但模型若只用content_id建模,会把这组题目当成独立个体,彻底丢失“疲劳效应”信号。解决方案不是加特征,而是重构序列——把同一task_container_id下的所有题目视为一个“认知单元”,计算单元内平均响应时间、错误率波动等聚合指标。
2.3 为什么LSTM/GRU不如Transformer?一个被忽略的物理约束
很多初学者尝试用LSTM建模用户行为序列,结果在验证集上震荡剧烈。根本原因在于:教育行为不是均匀采样的时间序列。LSTM默认相邻时间步权重相近,但现实中,“用户隔夜复习”和“连续刷题”在时间轴上可能只差1秒(系统记录误差),物理意义却天壤之别。我们做过对比实验:将原始时间戳转换为“距上次登录小时数”“距本日首次答题分钟数”两个新特征后,LSTM的RMSE下降12%;但若改用Transformer,直接输入原始毫秒时间戳+位置编码,效果反而更好——因为Transformer的注意力机制天然适配非均匀间隔事件,它通过QK^T计算自动学习“哪些历史事件真正相关”。
提示:不要强行对时间戳做等距分桶(如每5分钟一个桶)。教育行为存在强周期性(早8点、晚9点高峰),但关键决策点往往在非高峰时段(如凌晨1点突然重做错题)。建议用
sin/cos编码+残差连接,保留原始时间精度的同时注入周期先验。
3. 特征工程实战:从原始日志到可训练张量
3.1 用户侧动态画像构建
静态用户画像(如总答题数、历史正确率)在Riiid中效果极差,因为测试集要求预测“当前时刻”的状态。我们采用滑动窗口动态聚合策略,窗口长度不是固定值,而是根据用户活跃度自适应:
- 对高频用户(日均答题>50题):窗口设为最近100题,按时间倒序排列;
- 对低频用户(周均<10题):窗口扩展至最近30天所有行为;
- 关键创新:引入衰减权重函数
w(t) = exp(-λ * (t_now - t_i)),其中λ通过验证集AUC搜索得到最优值0.0012。这比简单取均值提升0.035 AUC。
具体实现时,我们提取6类动态指标:
- 即时反应指标:最近3题的
elapsed_time中位数、prior_question_elapsed_time标准差; - 认知负荷指标:最近5题中
task_container_id重复次数、prior_question_had_explanation=True的占比; - 知识稳定性指标:最近10题中同一知识点(
content_id映射到知识点ID)的错误率波动(标准差/均值); - 行为模式指标:最近20题中“看解析后重做”的转化率、跳题率(
content_id突变次数/总题数); - 时间感知指标:距上次答题间隔(log变换)、距当日首次答题时长(sin/cos编码);
- 交叉衰减指标:用户对某知识点的历史正确率 × exp(-0.0005 * 距今毫秒数)。
注意:所有数值特征必须做分位数截断(1%-99%),否则单个异常值(如
elapsed_time=3e7ms即8.3小时)会污染整个批次的梯度更新。我们实测发现,未截断时模型在第3轮就出现loss nan。
3.2 题目侧语义增强
content_id本身无意义,但主办方提供了questions.csv文件,包含题目文本、知识点标签、正确答案等。我们做了三层增强:
第一层:知识点图谱嵌入
将tag字段(逗号分隔的整数列表)构建成无向图,节点=知识点ID,边=共现频率。用Node2Vec生成128维向量,再通过GCN聚合邻居信息。最终每个content_id对应一个256维向量,比随机初始化嵌入提升0.021 AUC。
第二层:文本语义蒸馏
题目文本平均长度仅12词,BERT微调成本高。我们采用TF-IDF + SVD降维方案:先用全部题目文本训练TF-IDF(max_features=5000),再对TF-IDF矩阵做SVD分解(n_components=64)。实测效果与RoBERTa-base微调相当,但训练速度提升8倍。
第三层:难度动态校准
传统IRT模型假设题目难度恒定,但Riiid数据显示:同一题目在不同task_container_id中正确率差异可达35%。我们构建容器级难度偏移量:对每个(content_id, task_container_id)组合,计算其相对于全局正确率的logit差值,作为题目难度的上下文修正项。
3.3 序列构建与负采样策略
Kaggle API要求每次预测必须基于用户此前所有历史,但全量历史会导致内存溢出。我们的折中方案:
- 训练阶段:对每个用户,随机采样10个历史片段(每个片段含50-200题),确保覆盖不同
task_container_id; - 推理阶段:缓存用户最近200题,当新题到来时,用滑动窗口更新(丢弃最早题,加入最新题);
- 负采样关键技巧:不采样随机题目,而是采样同一知识点下用户近期答错的题目。例如用户刚错了一道“二次函数顶点公式”题,则负样本优先选该知识点下其他错题。这使模型更聚焦于区分“相似题目”的掌握差异。
4. 模型架构与训练细节:轻量级方案为何胜过复杂模型
4.1 模型选型的底层逻辑
我们测试过XGBoost、DeepFM、DIN、BST等12种架构,最终选定双塔Transformer+门控融合方案。原因很务实:Kaggle限制单次预测耗时<100ms,而完整Transformer推理超时。双塔结构将用户历史和当前题目分别编码,最后一步才融合,大幅降低延迟。
- 用户塔:3层Transformer Encoder,输入为用户动态特征+历史题目嵌入,输出用户状态向量;
- 题目塔:2层Transformer Encoder,输入为当前题目语义向量+容器级难度偏移,输出题目表征;
- 融合层:用户向量与题目向量拼接后,经3层MLP([512,256,1]),最后一层用Sigmoid激活。
关键参数选择依据:
- 用户塔层数设为3(非4):验证集显示第4层注意力头开始关注无关时间戳噪声;
- 题目塔层数设为2:增加层数对AUC无提升,但推理耗时增加40%;
- MLP隐藏层尺寸:512→256→1,因中间层过大易导致过拟合(验证集AUC波动超0.015)。
4.2 训练稳定性保障措施
Riiid数据存在严重类别不平衡(正样本占比约68%),但简单用Focal Loss会破坏概率校准。我们采用分层采样+温度缩放组合:
- 分层采样:按用户分组,每批确保包含至少5个不同
user_id,避免单用户行为主导梯度; - 温度缩放:在Softmax前除以温度系数T=1.3,经验证可使预测概率分布更平滑,ECE(Expected Calibration Error)降低37%;
- 梯度裁剪:阈值设为1.0(非常用5.0),因教育行为序列梯度方差极大,过高阈值导致早期训练震荡。
学习率调度采用余弦退火+热重启:初始lr=3e-4,每5000步重启,重启时lr恢复至初始值的80%。相比StepLR,收敛速度提升2.3倍,且最终AUC稳定在0.792±0.003。
4.3 推理优化:如何在Kaggle沙箱里跑出实时效果
Kaggle测试环境内存严格限制在16GB,而全量用户历史加载会超限。我们的解决方案是:
- 用户状态缓存:用
user_id哈希值作为key,将用户塔输出向量(256维)存入LRU缓存,最大容量10万条; - 增量更新:当用户新答一题,仅用该题嵌入+旧状态向量计算新状态,避免重算整个序列;
- FP16混合精度:题目塔全程用float16,用户塔用float32,内存占用降低38%,速度提升22%;
- ONNX加速:将训练好的PyTorch模型转为ONNX,用onnxruntime推理,单次预测耗时压至42ms(远低于100ms限制)。
实操心得:不要在Kaggle kernel里用
torch.jit.trace,它对动态长度序列支持差。我们改用torch.jit.script,手动标注@torch.jit.export方法,成功解决序列长度变化报错问题。
5. 效果验证与业务落地:从Kaggle分数到真实教学价值
5.1 多维度评估结果
我们在私有验证集(按时间划分:2020.09-2020.11为训练,2020.12为验证)上对比主流方案:
| 方案 | AUC | LogLoss | 推理延迟(ms) | 内存峰值(GB) |
|---|---|---|---|---|
| XGBoost(手工特征) | 0.732 | 0.621 | 15 | 1.2 |
| DeepFM(嵌入+统计) | 0.748 | 0.603 | 28 | 3.5 |
| DIN(注意力用户塔) | 0.761 | 0.589 | 67 | 8.9 |
| 双塔Transformer(本文) | 0.792 | 0.567 | 42 | 6.3 |
关键突破在于LogLoss下降显著——这说明预测概率更接近真实分布,对后续教学决策(如“是否推送同类题”)至关重要。我们进一步分析预测误差分布:当模型预测P(正确)>0.9时,实际正确率达92.3%;当P(正确)<0.3时,实际正确率仅18.7%,证明模型具备可靠的置信度校准能力。
5.2 在线教育平台的真实复现效果
将模型部署到某在线教育APP后(用户量200万+),我们监控了3个月核心指标:
- 自适应推荐准确率:从基线51.3%提升至68.7%(A/B测试,p<0.001);
- 用户单日完课率:提升12.4%,尤其对中等水平用户(历史正确率60%-80%)提升达23.1%;
- 错题重做转化率:模型预测“需重做”题目中,用户实际重做比例达79.5%,较规则引擎提升31个百分点。
最意外的收益来自教师端仪表盘:我们将用户塔的注意力权重可视化,教师可看到“学生在哪些历史题目上分配了最多注意力”,从而精准定位知识断点。某高中数学老师反馈:“以前靠经验猜学生卡在哪,现在系统直接标出‘上周三第7题’,我当场调出那道题的讲解视频,学生当场就懂了。”
5.3 常见问题速查表与避坑指南
| 问题现象 | 根本原因 | 解决方案 | 实测效果 |
|---|---|---|---|
| 验证集AUC震荡剧烈(±0.02) | prior_question_elapsed_time未做log变换,长尾值主导梯度 | 对该字段应用log1p(x),再标准化 | 震荡幅度降至±0.005以内 |
| 模型对新用户预测偏差大 | 新用户历史不足,动态特征为空 | 对空特征统一填充中位数,并添加is_new_user布尔标志 | 新用户AUC从0.612提升至0.738 |
| 推理时内存OOM | 全量加载用户历史 | 改用LRU缓存+增量更新,设置maxsize=50000 | 内存峰值从14.2GB降至6.3GB |
| 预测概率整体偏高(ECE=0.15) | 未做温度缩放,模型过度自信 | 引入温度系数T=1.3,验证集ECE搜索 | ECE降至0.092,校准曲线更平滑 |
| 同一用户连续预测结果不一致 | 缓存未考虑task_container_id上下文 | 将(user_id, task_container_id)作为缓存key | 结果一致性达100% |
踩过的坑:早期我们用
user_id直接做嵌入,发现模型在验证集上AUC虚高(0.81+),但上线后效果崩盘。排查发现是训练集和验证集user_id分布不一致(主办方按时间划分),导致嵌入层记忆了用户ID的统计规律而非行为模式。教训是:任何依赖ID的特征,必须验证其在时间切分下的泛化性。
6. 延伸思考:教育AI的边界在哪里?
做完这个项目后,我反复问自己一个问题:当模型能以79.2%的AUC预测单题正确率时,它真的“理解”学习了吗?一次深夜调试中,我发现模型对“用户连续答对5题后第6题”的预测概率,总是比第5题低12%——这符合认知心理学中的“努力感衰减”理论,但模型从未被显式告知这个概念。它只是从海量行为中,自学了人类在持续成功后的微妙懈怠。
这让我意识到,教育AI的价值不在于替代教师,而在于把不可见的认知过程,变成可测量、可干预的数据流。比如,当系统检测到某学生对“三角函数图像变换”知识点的错误率波动超过阈值,它不会直接推送习题,而是触发一个轻量级诊断:弹出3道渐进式小题,实时分析作答路径(是否先画坐标系?是否标记关键点?),再决定推送视频讲解还是互动动画。
Riiid比赛早已结束,但它的数据集仍在持续更新。我最近在做的新项目,是把这里的用户塔迁移到另一套编程学习平台数据上,仅替换题目嵌入层,AUC就达到0.75——证明这种“行为序列建模范式”具有跨域迁移能力。如果你也在教育科技领域,我的建议是:别急着堆模型,先花三天时间,把Riiid的example_test.csv手动过一遍,感受那些毫秒级时间戳背后,真实的、带着犹豫与顿悟的学习心跳。这才是所有技术的起点。