Перейти к содержимому

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 опционально, чтобы можно было экспериментировать в тестах и в виджетах без переделки сигнатуры.

export function pSolve(pL: number, params: BktParams = DEFAULT_BKT): number {
return pL * (1 - params.pSlip) + (1 - pL) * params.pGuess;
}

Одна строка. Цена всей формулы P(solve) (см. главу 8). Численно стабильна (не делим, нет логарифмов), вызывается миллионы раз в селекторе — поэтому такая короткая.

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 никогда не равны нулю при P(L)[0,1]P(L) \in [0, 1], P(S),P(G)(0,1)P(S), P(G) \in (0, 1) — поэтому 0 / 0 не возникает.

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 проще.

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 микро-навыков подвели?

Если у нас есть пошаговый ответ ученика (через сканер или через форму на планшете), мы можем определить, что:

  • скобки — правильно (\to положительное обновление по expand_brackets);
  • арифметика — ошибка (\to отрицательное по arith.signs).

Это намного точнее чем «всё одинаково плюс/минус». Это задел под Solution Analyzer (направление #2 в плане).

Если perSkill не передан — fallback: «всё одинаково». Это норм для MVP, где сканер ещё не интегрирован.

history.push — для навигации, audit log и rare-skill bonus.

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 };
}

Самый плотный кусок. Разберём блочно.

ZPD-цель. Можно тюнить через opts, но 0.7 — стандартное значение из исследований (см. главу 8).

Защита от Math.log(0) = -Infinity. На практике pSolve(0) возвращает P(G) = 0.2 > 0, так что log(0) не наступит. Но писать защиту — дёшево.

logSum = Σ log(P_i)
GM = exp(logSum / n) = (P_1 × P_2 × ... × P_n)^(1/n)

Преимущества:

  • Численная стабильность: при больших n произведение могло бы underflow’иться;
  • Интерпретируемость: «средний log P» — естественная метрика в IRT, легко расширить.
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 = (число навыков с P(L) < 0.4) / число навыков
score = closeness + rareBonus * rarity

Это исследовательский компонент. Без него селектор зависает в зоне комфорта.

rareBonus = 0.15 — компромисс. См. главу про селектор.

return { task, pSolve: pSolveJoint, perSkillPL, score };

perSkillPL нужен для объяснимости (см. главу) — UI учителя хочет показать «почему».

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». Это ровно то, что нужно для учительского доверия и ясного питча для слушателей.