Walk-through web/lib/bkt.ts строка за строкой
Это разбор реального файла web/lib/bkt.ts.
Цель — чтобы ты мог открыть файл и за 10 минут понять каждое
решение.
Импорты и константы
Заголовок раздела «Импорты и константы»import { BktParams, DEFAULT_BKT, MicroSkill, MicroSkillId, StudentState, Task,} from "./microskills";DEFAULT_BKT — это наш набор литературных дефолтов (P(L_0)=0.2,
P(T)=0.1, P(S)=0.1, P(G)=0.2). Все функции принимают params
опционально, чтобы можно было экспериментировать в тестах и в виджетах
без переделки сигнатуры.
pSolve — формула предсказания
Заголовок раздела «pSolve — формула предсказания»export function pSolve(pL: number, params: BktParams = DEFAULT_BKT): number { return pL * (1 - params.pSlip) + (1 - pL) * params.pGuess;}Одна строка. Цена всей формулы P(solve) (см. главу 8). Численно стабильна (не делим, нет логарифмов), вызывается миллионы раз в селекторе — поэтому такая короткая.
bktUpdate — формула Байеса
Заголовок раздела «bktUpdate — формула Байеса»export function bktUpdate( pL: number, observedCorrect: boolean, params: BktParams = DEFAULT_BKT): number { const { pSlip, pGuess, pTransit } = params; const posterior = observedCorrect ? (pL * (1 - pSlip)) / (pL * (1 - pSlip) + (1 - pL) * pGuess) : (pL * pSlip) / (pL * pSlip + (1 - pL) * (1 - pGuess)); return posterior + (1 - posterior) * pTransit;}Сердце BKT. Тернарный оператор экономит ветвление и делает код плотнее.
Альтернатива — if/else, но 3 строки превращаются в 8.
Numerical safety: знаменатели pL * (1 - pSlip) + (1 - pL) * pGuess
никогда не равны нулю при , —
поэтому 0 / 0 не возникает.
ensureMastery — ленивая инициализация
Заголовок раздела «ensureMastery — ленивая инициализация»export function ensureMastery( state: StudentState, skillId: MicroSkillId, params: BktParams = DEFAULT_BKT): number { const cur = state.mastery[skillId]; if (cur === undefined) { state.mastery[skillId] = params.pInit; return params.pInit; } return cur;}Если ученик впервые сталкивается с навыком — ставим pInit. Это
мутирует state (записывает значение). Это сознательно: нам нужно,
чтобы дальше при выводе heatmap клетка появилась с правильным цветом, а
не с undefined.
Альтернатива — иммутабельный return: cur ?? params.pInit. Но это
требует следить за инициализацией снаружи. Mutate-on-first-use проще.
applyAttempt — что делать после попытки
Заголовок раздела «applyAttempt — что делать после попытки»export function applyAttempt( state: StudentState, task: Task, correct: boolean, perSkill?: Record<MicroSkillId, boolean>, params: BktParams = DEFAULT_BKT): StudentState { for (const skillId of task.microskills) { const observed = perSkill?.[skillId] ?? correct; const prior = ensureMastery(state, skillId, params); state.mastery[skillId] = bktUpdate(prior, observed, params); } state.history.push({ task_id: task.id, correct, per_skill: perSkill, ts: new Date().toISOString(), }); return state;}Ключевая фишка — параметр perSkill. Он отвечает на вопрос:
Когда ученик сделал задачу и ошибся — какие из 4 микро-навыков подвели?
Если у нас есть пошаговый ответ ученика (через сканер или через форму на планшете), мы можем определить, что:
- скобки — правильно ( положительное обновление по
expand_brackets); - арифметика — ошибка ( отрицательное по
arith.signs).
Это намного точнее чем «всё одинаково плюс/минус». Это задел под Solution Analyzer (направление #2 в плане).
Если perSkill не передан — fallback: «всё одинаково». Это норм для
MVP, где сканер ещё не интегрирован.
history.push — для навигации, audit log и rare-skill bonus.
scoreTaskForStudent — закрытое сердце селектора
Заголовок раздела «scoreTaskForStudent — закрытое сердце селектора»export function scoreTaskForStudent( state: StudentState, task: Task, opts: SelectorOptions = {}, params: BktParams = DEFAULT_BKT): ScoredTask { const target = opts.target ?? 0.7; const rareBonus = opts.rareSkillBonus ?? 0.15;
// Per-skill P(solve), then take the geometric mean as the joint — // a task fails if *any* required skill fails, so geo-mean penalises // missing one skill more than arithmetic mean. const perSkillPL: Record<MicroSkillId, number> = {}; let logSum = 0; for (const skillId of task.microskills) { const pL = state.mastery[skillId] ?? params.pInit; perSkillPL[skillId] = pL; logSum += Math.log(Math.max(1e-6, pSolve(pL, params))); } const pSolveJoint = Math.exp(logSum / task.microskills.length);
// Closeness to target — Gaussian-ish, peaks at target=0.7 const closeness = Math.exp(-Math.pow(pSolveJoint - target, 2) / 0.03);
// Rarity bonus: how many of this task's skills are below 0.4? const undertrained = task.microskills.filter( (s) => (state.mastery[s] ?? params.pInit) < 0.4 ).length; const rarity = undertrained / task.microskills.length;
const score = closeness + rareBonus * rarity;
return { task, pSolve: pSolveJoint, perSkillPL, score };}Самый плотный кусок. Разберём блочно.
target = 0.7
Заголовок раздела «target = 0.7»ZPD-цель. Можно тюнить через opts, но 0.7 — стандартное значение из
исследований (см. главу 8).
Math.max(1e-6, pSolve(pL, params))
Заголовок раздела «Math.max(1e-6, pSolve(pL, params))»Защита от Math.log(0) = -Infinity. На практике pSolve(0) возвращает
P(G) = 0.2 > 0, так что log(0) не наступит. Но писать защиту — дёшево.
Geometric mean через сумму логарифмов
Заголовок раздела «Geometric mean через сумму логарифмов»logSum = Σ log(P_i)GM = exp(logSum / n) = (P_1 × P_2 × ... × P_n)^(1/n)Преимущества:
- Численная стабильность: при больших
nпроизведение могло бы underflow’иться; - Интерпретируемость: «средний log P» — естественная метрика в IRT, легко расширить.
Closeness — гауссиана
Заголовок раздела «Closeness — гауссиана»closeness = exp(-(p - 0.7)^2 / 0.03)Параметр 0.03 — «ширина» допуска. Подробнее в
главе 8.
Альтернативы:
1 - |p - 0.7|— линейный штраф, хуже различает «почти попал» от «совсем мимо».1 / (1 + (p-0.7)^2)— Lorentzian, менее агрессивно штрафует край.
Гауссиана выбрана потому, что она гладкая и симметричная, и в ML-литературе по adaptive testing это стандарт.
Rarity bonus — exploration
Заголовок раздела «Rarity bonus — exploration»rarity = (число навыков с P(L) < 0.4) / число навыковscore = closeness + rareBonus * rarityЭто исследовательский компонент. Без него селектор зависает в зоне комфорта.
rareBonus = 0.15 — компромисс. См. главу про селектор.
Возвращаем «всё»
Заголовок раздела «Возвращаем «всё»»return { task, pSolve: pSolveJoint, perSkillPL, score };perSkillPL нужен для объяснимости (см. главу)
— UI учителя хочет показать «почему».
recommend — финальная сортировка
Заголовок раздела «recommend — финальная сортировка»export function recommend( state: StudentState, pool: Task[], topN = 5, opts?: SelectorOptions, params?: BktParams): ScoredTask[] { const recentIds = new Set( state.history.slice(-5).map((h) => h.task_id) ); const scored = pool .filter((t) => !recentIds.has(t.id)) .map((t) => scoreTaskForStudent(state, t, opts, params)); scored.sort((a, b) => b.score - a.score); return scored.slice(0, topN);}Последний штрих — выкидываем задачи из последних 5 history. Это анти- повтор:
- ученику скучно, если та же задача 3 раза за день;
- но мы не выкидываем больше — статистика на 5 шагов более-менее свежая;
- 5 — компромисс. Можно было бы 3 (агрессивнее), 10 (более safe), но 5 работает.
Альтернатива — exponential decay:
const recencyPenalty = recent.findIndex(h => h.task_id === t.id);score *= recencyPenalty < 0 ? 1 : Math.exp(-recencyPenalty / 3);Это сложнее объяснить, и для MVP избыточно.
150 строк. Ноль зависимостей (только типы из microskills).
Все формулы — явные, ни одной библиотеки ML не используется.
Это — намеренно. Каждое решение можно обосновать словами, без ссылки на «что-то внутри scikit-learn». Это ровно то, что нужно для учительского доверия и ясного питча для слушателей.