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

Selector в действии

flowchart TD
S[Состояние ученика · вектор P(L)] --> Loop["Каждая задача из пула"]
Loop --> P["Совместный P(solve) — геом. среднее по навыкам"]
P --> C["Близость к цели exp(-(p-0.7)^2/0.03)"]
P --> R["Доля навыков с P(L) < 0.4"]
C --> Score["итог = близость + 0.15 * редкость"]
R --> Score
Score --> Sort[Сортировка по убыванию итога]
Sort --> Recent[Исключить задачи из последних 5 ответов]
Recent --> TopN[Топ-N рекомендаций]

Это все подвижные части алгоритма. Никаких нейросетей, градиентов и цикла обучения — только взвешенный рейтинг.

state = {
student_id: "S-12",
mastery: {
"linear_eq.expand_brackets": 0.166, // упал после серии ошибок
"arith.signs": 0.831, // силён
"arith.distributive_law": 0.45,
"linear_eq.move_to_one_side": 0.62,
"linear_eq.divide_by_coefficient": 0.78,
// ...
},
history: [/* последние ответы */]
}

2. Каждой задаче считаем «насколько подходит»

Заголовок раздела «2. Каждой задаче считаем «насколько подходит»»

Для задачи T-147 с навыками [expand_brackets, signs]:

  • P(solve1)=0.1660.9+0.8340.2=0.317P(\text{solve}_1) = 0.166 \cdot 0.9 + 0.834 \cdot 0.2 = 0.317
  • P(solve2)=0.8310.9+0.1690.2=0.782P(\text{solve}_2) = 0.831 \cdot 0.9 + 0.169 \cdot 0.2 = 0.782
  • Pjoint=0.3170.7820.498P_{\text{joint}} = \sqrt{0.317 \cdot 0.782} \approx 0.498

Closeness: closeness=exp((0.4980.7)20.03)0.260\text{closeness} = \exp\left(-\frac{(0.498 - 0.7)^2}{0.03}\right) \approx 0.260

Rarity (1 из 2 навыков ниже 0.4): rarity=0.5\text{rarity} = 0.5

Score: score=0.260+0.150.5=0.335\text{score} = 0.260 + 0.15 \cdot 0.5 = 0.335

Селектор пробегает по pool, считает score для каждой, сортирует по убыванию, выкидывает задачи из последних 5 (чтобы не повторяться) и возвращает top-5.

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

Что делать, если все задачи плохо подходят

Заголовок раздела «Что делать, если все задачи плохо подходят»

Бывает: у Ивана P(скобки)=0.166P(\text{скобки}) = 0.166 и в pool нет задач только на скобки + лёгкая арифметика — все включают сложные навыки. Что делать?

Селектор всё равно вернёт наименее плохой вариант. Но топ-1 будет с низким closeness. Это сигнал:

  • учителю: «база задач не покрывает реальные проблемы класса»;
  • Andri (куратору контента): «нужны задачи попроще на этот навык».

В UI учителя можно показывать индикатор «слабая рекомендация» (closeness < 0.3) — это часть объяснимости.

Если две задачи имеют почти одинаковый closeness — селектор предпочтёт ту, которая тренирует слабый навык через rareSkillBonus. Это важно:

  • без бонуса селектор может зависнуть в зоне комфорта;
  • с бонусом он исследует слабые места, чтобы их подтянуть.

Параметр rareSkillBonus = 0.15 — компромисс:

  • слишком маленький (0.05): селектор «не лезет» в слабые навыки, ученик застревает на тех, что уже умеет;
  • слишком большой (0.5): селектор всё время даёт сложные задачи, фрустрация.

0.15 даёт примерно: «при равном closeness, задача с одним слабым навыком из двух предпочтительнее».

Если ты прогоняешь селектор и видишь странную рекомендацию — почти всегда причина в одном из:

  1. Mastery vector неинициализирован — для нового навыка нет P(L)P(L), код падает на дефолт params.pInit = 0.2. Это норм.
  2. Discontinuity history — ученик пропустил 2 недели, его mastery устарело, нужен decay. Не делаем для MVP.
  3. Bias таксономии — Andri разметил один навык на 80% задач, второй на 5%. Селектор «сваливается» в первый. Решается на уровне разметки.

См. главу про объяснимость — она делает причины рекомендаций видимыми, что упрощает дебаг и поднимает доверие учителя.