用知识追踪模型估计每个知识点的掌握程度,据此规划下一步该学什么,并让每一次推荐都附带可解释的理由。
挑战杯参赛作品。第一版面向《数据结构》单门课程,28 个知识点、224 道题。
换机器 / 交接给别人接手:先看
BUDDYREADME.md, 里面写了接手时第一步要做什么(跑python scripts/check_env.py确认环境), 以及迁移后必须重跑哪些实验、哪些设计约定不能碰。
现在的刷题平台只记录行为量(做了几道题、正确率多少),不估计知识状态(哪个知识点真的会了、 哪个只是碰巧蒙对)。知途把答题序列喂给知识追踪模型,算出每个知识点的掌握概率, 再结合知识点之间的前置依赖关系给出下一步该学什么,并把"为什么推荐这个"讲清楚。
两点差异化(也是答辩时主要被追问的地方):
- 多模型对比实验。BKT / DKT / SAKT 三个模型加两个非学习基线(全局均值、逐知识点频率), 在同一数据集、同一套评测口径下对比,报告 AUC 与置信区间。不是"我们搭了个系统", 而是"我们测了什么、发现了什么"。
- 可解释推荐。每次推荐都输出可核查的依据:
推荐这道题,因为它是「二叉树的遍历」这个知识点的练习,而你在这个知识点上的掌握概率为 0.34,低于 0.70 的阈值。 (补强)。作答依据:已作答 4 次、答对 1 次;模型预测你答对这道题的概率约为 0.41。
cd Qin-path
# 建虚拟环境(本机开发时用的是一份独立 venv,路径无所谓,重建一份即可)
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # Linux / macOS
pip install -r requirements.txt
# 若 pip 装到的 torch 体积过大,用 CPU 轮子:
# pip install torch --index-url https://download.pytorch.org/whl/cpu有显卡的机器请改用
requirements-gpu.txt的步骤(先装 CUDA 版 torch 再装其余依赖)。
python -m ml.data.build_dataset # 按当前设备自动选档位
python -m ml.data.build_dataset --profile cpu产物在 data/processed/:知识点依赖图、题库 CSV、三个切分(train/valid/test)。
python -m ml.run_experiments # 全部模型
python -m ml.run_experiments --models BKT DKT SAKT # 只跑三个模型
python -m ml.run_experiments --skip-bootstrap # 快速模式产出:
ml/artifacts/results_<数据集>.json—— 指标原始数据ml/artifacts/report_<数据集>.md—— 可读报告(含自动生成的结论段)ml/artifacts/models/*.pt|.json—— 训练好的模型,后端要加载
已跑出的正式结果(ASSISTments 2009,测试集 370 名学生 / 16920 条交互,CPU 档,seed=42):
| 模型 | AUC | AUC 95% 置信区间 | 准确率 | RMSE | NLL | 参数量 |
|---|---|---|---|---|---|---|
| Mean(全局均值,最弱基线) | 0.5000 | [0.5000, 0.5000] | 0.6291 | 0.4830 | 0.6594 | 不适用 |
| KC-Mean(逐知识点频率,严格下限) | 0.6206 | [0.6019, 0.6393] | 0.6515 | 0.4716 | 0.6360 | 不适用 |
| BKT | 0.7177 | [0.6947, 0.7341] | 0.7005 | 0.4471 | 0.5858 | 不适用 |
| DKT | 0.8092 | [0.7917, 0.8255] | 0.7555 | 0.4051 | 0.4885 | 29233 |
| SAKT | 0.7977 | [0.7779, 0.8160] | 0.7427 | 0.4128 | 0.5068 | 48577 |
要点:DKT 相对最强非学习基线(KC-Mean)提升 +0.1886 AUC,说明知识状态建模确实有增益;
但 DKT 与 SAKT 的置信区间大幅重叠([0.7917, 0.8255] vs [0.7779, 0.8160]),
不能声称二者有实质差异——这与设计文档引用的实证研究结论一致,也是报告里如实写出的结论。
两个数据集各管一段,不要混着讲:
- 产品演示跑的是自拟的《数据结构》课程(28 个知识点、224 道题)+ 模拟学生作答, 用来演示学生端闭环与教师端薄弱点定位;
- 答辩指标跑的是公开数据集 ASSISTments 2009(116 个知识点、3694 名学生), 用它的知识点划分,与演示课程相互独立。
两者知识点数不同是刻意的,不是数据错乱。教师端实验表下方也写了这条口径说明。
python -m backend.app.seed # 初始化演示数据(班级 + 12 名学生 + 作答历史)
python -m uvicorn backend.app.main:app --reload --port 8000接口文档:http://127.0.0.1:8000/docs
演示账号(密码均为 123456):
| 账号 | 角色 |
|---|---|
teacher |
教师 |
stu01 … stu12 |
学生 |
cd frontend
npm install
npm run dev打开 http://localhost:5173(已配好 /api 代理到 8000 端口)。
python -m pytest ml/tests backend/tests -q # 33 条:模型/适配器回归 + 后端接口闭环python scripts/check_env.py # 27 项只读检查,只用标准库,依赖没装齐也能跑
python scripts/check_env.py --quiet # 只在有问题时输出,可直接用于脚本判定退出码 0 表示无缺失项,1 表示有缺失项。换机器后请先跑它,再看
BUDDYREADME.md 的结论对照表。
Qin-path/
├── ml/ 模型与实验(项目的技术内核)
│ ├── config.py 设备探测 + 规模档位(cpu / gpu)
│ ├── data/
│ │ ├── knowledge_graph.py 《数据结构》28 个知识点 + 前置依赖
│ │ ├── question_bank.py 题库 224 道题
│ │ ├── synth.py 合成答题序列生成器(BKT 生成过程 + 前置影响)
│ │ ├── adapters.py 公开数据集适配(ASSISTments CSV / pyKT pickle)
│ │ ├── dataset.py 按学生切分 + 持久化
│ │ └── build_dataset.py 数据集构建入口
│ ├── models/
│ │ ├── base.py 统一接口 KnowledgeTracer
│ │ ├── baseline.py 全局均值 / 逐知识点频率(非学习基线)
│ │ ├── bkt.py 贝叶斯知识追踪,EM 估计四参数
│ │ ├── dkt.py 深度知识追踪(LSTM)
│ │ └── sakt.py 自注意力知识追踪
│ ├── planner.py 掌握度 → 路径(显式规则,无大模型参与)
│ ├── metrics.py AUC / ACC / RMSE / NLL + bootstrap 置信区间
│ ├── service.py 在线推理服务(掌握度、推荐、班级统计、曲线)
│ ├── train.py 单模型训练
│ └── run_experiments.py 对比实验入口
├── backend/ FastAPI 服务
│ └── app/
│ ├── main.py 应用入口
│ ├── models.py ORM(只存会变的东西)
│ ├── security.py bcrypt 口令 + HMAC 签名 token
│ ├── routers/ auth / student / teacher / meta
│ ├── services/history.py 把作答记录还原成模型输入序列
│ └── seed.py 演示数据
├── frontend/ Vue 3 + Vite + ECharts
│ └── src/
│ ├── views/ LoginView / StudentView / TeacherView
│ └── components/ KnowledgeDAG / LearningCurve / ClassHeatmap / ExperimentTable
├── data/ raw(原始数据集)+ processed(构建产物)
├── scripts/ 运维脚本
│ ├── check_env.py 环境自检(27 项,只用标准库,换机器后第一步跑)
│ └── shot.py 无头浏览器截图验收(学生端 + 教师端)
├── ml/artifacts/ 实验指标与模型产物
└── BUDDYREADME.md 交接文档:新机器接手时先读这份
数据层 题库(题目 ↔ 知识点标注) + 作答记录(学生 × 知识点 × 对错 × 顺序)
│
模型层 知识追踪模型(BKT / DKT / SAKT)→ 逐知识点掌握概率
知识点依赖图(DAG)→ 路径规划的约束条件
│
应用层 学生端:做题 → 掌握度实时更新 → 接收推荐 → 查看推荐理由
教师端:全班掌握度分布 → 定位共性薄弱点
一条重要的建模约定:mastery 里 未观测(还没做过)的知识点记 NaN,不记 0。
把"没做过"当成"不会"会把一大批尚未学习的知识点误判成漏洞,推荐直接乱掉。
代码里 BKT.mastery() 与 planner.plan_path() 都严格区分"未知"与"薄弱"两种状态。
合成数据集的产物里写死了 data_source='synthetic',界面上也会打标提示,
它只用于验证实现与演示,不能作为答辩结论。
# 1) 下载(USTC 镜像,无需 EduData)
curl -L -o data/raw/assist2009_skill_builder.zip \
http://base.ustc.edu.cn/data/ASSISTment/2009_skill_builder_data_corrected.zip
# 2) 解压得到 skill_builder_data_corrected.csv
python -c "import zipfile;zipfile.ZipFile('data/raw/assist2009_skill_builder.zip').extractall('data/raw/')"
# 3) 构建切分(--max-seq-len 必须与训练时 max_len 一致,CPU 档为 100)
python -m ml.data.build_dataset --source assistments \
--csv data/raw/skill_builder_data_corrected.csv \
--name assistments2009 --prefix assistments2009 --max-seq-len 100
# 4) 跑实验(--prefix 要与上一步一致)
python -m ml.run_experiments --prefix assistments2009适配器会自动识别列名别名(user_id / skill_id / problem_id / correct / order_id
及其常见变体),并按 --min-kc-count(默认 30)过滤长尾知识点,按 --max-seq-len
截断每条学生序列。
两个容易踩的点:
- 编码:ASSISTments 2009 的导出文件不是 UTF-8(实测是 cp1252/latin-1,
题目文本里含
0x80字节),按 utf-8 读会在第 2634 字节直接抛UnicodeDecodeError。 适配器按utf-8-sig → cp1252 → latin-1顺序自动回退。 - 序列长度必须对齐:BKT 会在全长序列上评测,DKT/SAKT 只评测前
max_len步。 如果数据集长度大于模型的max_len,五个模型其实不在同一批交互上比。 所以--max-seq-len要和ml/config.py里对应档位的max_seq_len保持一致。
pip install EduData
python -c "from EduData import get_data; get_data('assistment-2009-2010-skill', 'data/raw/')"EduData 内置数据集:assist2009 / assist2012 / assist2015 / algebra2005 /
bridge2006 / statics2011 / junyi / xes3g5m / ednet_kt1。
拿到数据后:
python -m ml.data.build_dataset --source pykt --csv data/raw/<文件>.pkl \
--name assist2009 --prefix assist2009 --max-seq-len 100
python -m ml.run_experiments --prefix assist2009EduData 的 USTC 镜像(
base.ustc.edu.cn)实测会间歇性被网络拦截 (表现为连接被 RST,或响应不带Content-Length导致下载器抛TypeError)。 被拦时不用折腾下载器,直接按方式一用curl拉原始 zip 即可; 若curl -I能拿到HTTP/1.1 200且带Content-Length,就说明镜像已恢复。
请任课老师提供一个班一学期的作业或小测记录,按 question_bank.load_bank_from_csv()
与 adapters.load_assistments_csv() 的列约定整理即可。
代码不需要改。ml/config.py 的 resolve_device() 会自动探测 CUDA,
并切到 gpu 档位(数据规模 ×5、轮次 ×2、模型加宽)。
# 目标机器上:装 CUDA 版 torch
pip install torch --index-url https://download.pytorch.org/whl/cu124
pip install -r requirements.txt
python -c "import torch; print(torch.cuda.is_available())" # 期望 True
set ZT_PROFILE=gpu
# 演示用合成数据
python -m ml.data.build_dataset --profile gpu
# 答辩用真实数据集(--max-seq-len 要对齐 gpu 档的 200)
python -m ml.data.build_dataset --source assistments \
--csv data/raw/skill_builder_data_corrected.csv \
--name assistments2009 --prefix assistments2009 --max-seq-len 200
python -m ml.run_experiments --profile gpu --prefix assistments2009迁过去以后指标必须重跑。GPU 档用的是另一组超参(序列 200 步、轮次 120、模型加宽), 和本机 CPU 档跑出来的数字不是同一组实验,不能直接把 CPU 档的 AUC 拿去答辩。 跑完记得把第二节的结果表和各处答辩材料一起换掉。
详细清单见 requirements-gpu.txt。
支持的环境变量:
| 变量 | 默认 | 说明 |
|---|---|---|
ZT_DEVICE |
auto |
强制 cpu / cuda / mps |
ZT_PROFILE |
auto |
规模档位 cpu / gpu(auto 按设备选) |
ZT_DATABASE_URL |
SQLite | 换成 PostgreSQL 时改这里 |
ZT_SECRET_KEY |
演示值 | 生产环境必须覆盖 |
ZT_KT_MODEL |
BKT |
用哪个模型产出掌握度 |
1. 深度模型的模型选择必须用验证集 AUC,不能用验证集 loss。
答题数据噪声很大(失误/猜测),验证 loss 常常在第 2 个 epoch 就平台化,而模型其实还在
持续改善排序能力。最初按 loss 选 checkpoint,结果回滚到几乎没训起来的模型,线上 AUC ≈ 0.5。
改用 AUC 选型后恢复正常。见 ml/models/torch_utils.py 的 train_loop()。
2. 训练步数不足会让 DKT 直接退化成"预测均值"。 早期用 96 名学生 × 12 epoch × batch 32 = 36 个优化步,深度模型完全没训起来(AUC 0.49)。 CPU 档位把 epochs 提到 60 才够用。
3. 合成数据的序列结构必须贴近真实刷题行为。
第一版生成器每步在知识点池里均匀随机抽题,等于让模型学"无记忆"序列,
序列模型被不公平地压制。真实数据里学生是成串刷同一个知识点的,
所以加了 repeat_prob(默认 0.55)与"优先练薄弱点"的抽样权重。
4. 未观测 ≠ 薄弱。 见第四节。
5. SequenceDataset 实现了序列协议。 可以像 list[dict] 一样直接传给模型,
不用到处写 .sequences。
6. ASSISTments 2009 的 CSV 不是 UTF-8,且有个 0x80 字节的雷。
skill_builder_data_corrected.csv 是 cp1252/latin-1 编码,按 utf-8 读会在第 2634 字节
直接抛 UnicodeDecodeError(报错位置在文件极前面,容易误判成文件损坏)。
适配器按 utf-8-sig → cp1252 → latin-1 顺序流式嗅探编码再读。
注意别为了"顺手"把整个文件 list(reader) 物化:40 万行 × 30 列的 dict 列表要吃掉近 1GB,
改成惰性迭代后峰值内存只有 62MB。
7. 按频次过滤知识点时,题目表会带上"孤儿题目"。
知识点按 min_kc_count 过滤后,若 question_ids 仍由全部原始行构建,
其中属于已丢弃知识点的题目在拼 question_to_kc 时会让 kc_index[...] 直接 KeyError。
合成数据所有知识点都保留,所以这个坑只有接真实数据才会暴露。
修法:item_to_kc 只在保留下来的知识点里构建。回归测试见 ml/tests/test_adapters.py。
8. 数据集截断长度必须与模型 max_len 对齐,否则五个模型不可比。
BKT 会在每条序列的全长上评测,而 DKT/SAKT 只看前 max_len 步(ml/config.py 档位决定,
CPU 档 100、GPU 档 200)。若数据集允许的序列长度大于模型 max_len,
汇总表看着整齐,其实几个模型根本不在同一批交互上比。
所以 build_dataset --max-seq-len 要和对应档位的 max_seq_len 保持一致。
这个方向的可复用性很强,后续版本不需要重学技术栈:
| 版本 | 做什么 | 复用情况 |
|---|---|---|
| 第二版 | 换学科(高等数学 / 英语 / 编程课) | 模型、后端、前端全部复用,只换题库与知识点图 |
| 第三版 | 拍照做题,引入文字识别与多模态 | 新增输入通道 |
| 第四版 | 错因分析(不只判断对错,判断错在哪一步) | 模型层扩展 |
| 第五版 | 跨学科迁移分析(一门课的掌握对另一门课的影响) | 数据层扩展 |
- 已有商业化先例:松鼠 Ai 的认知诊断测评正是基于"知识空间理论 + 贝叶斯知识追踪" 构建个体知识图谱,公开信息称已覆盖全国六万余所学校、服务五千万以上学生。 被追问时的定位说明:本项目做的是单学科、开源、可解释、带完整对比实验的轻量系统, 是研究性作品,重点是把方法讲清楚、把实验数据摆出来,不与商业产品竞争功能覆盖面。
- 模型之间差距可能很小:已有实证研究(JEDM, 2022)在多个公开数据集上系统评测后指出, 深度模型充分调参后整体优于传统模型,但模型间相对差异很细微且随数据集变化, 没有任何一个模型能在所有数据集上占优;在部分数据集上连最简单的均值预测基线都可能 在准确率上超过复杂模型。所以本项目把"多模型对比实验"设为核心产出, 并用 bootstrap 置信区间判断"差距是否真实存在",而不是照搬论文结论。 报告里的自动生成结论段会明确写出这一点,不掩盖不利结果。
- 演示数据是模拟的,但实验指标不是:
backend/app/seed.py预填的作答历史是按模拟掌握度 生成的,不是真实学生数据,只能用来演示交互流程。而第二节表里的五个模型指标, 是在公开数据集 ASSISTments 2009 上真实跑出来的(源文件、过滤参数、丢弃行数都记在ml/artifacts/report_assistments2009.md的「数据来源与预处理」一节,可追溯)。 被问"数据哪来的"直接指那一节,不要说"用了真实学校数据"。 - 合成数据不报结论:
ml/artifacts/results_synthetic.json里的指标是自拟课程 + 模拟学生 跑出来的(BKT 0.56 / DKT 0.54 那一档),只证明流水线通了,不能当答辩结论。 界面上会自动打警示条;只要公开数据集的结果文件存在,界面就会自动切过去。