Как держать ученика в «зоне потока»: адаптивная сложность на IRT и Elo — и почему я не поверил ей без симуляции

от автора

Любой тренажёр рано или поздно упирается в одну задачу: что показать человеку следующим. Слишком легко — скучно и бесполезно. Слишком трудно — закрыл и не вернулся. Между этими краями есть узкая полоса, которую психологи называют зоной ближайшего развития, а продуктовые люди — зоной потока.

Я аналитик данных, и на работе постоянно имею дело с оценками скрытых величин по косвенным наблюдениям. Когда взялся за свой тренажёр английского, оказалось, что задача подбора сложности — ровно из этой оперы: у ученика есть некая способность, которую мы не наблюдаем напрямую, а видим только цепочку «ответил верно / ответил неверно». Дальше — обычная задача оценивания, только в реальном времени и на телефоне.

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

Почему пороги не работают

Первое, что приходит в голову: уровни. Новичок — лёгкие задания, продвинутый — трудные. Так делают почти все, и это разваливается по трём причинам.

Уровень — не число, а профиль. Человек может уверенно узнавать фразу на слух и при этом не мочь построить её сам. Один параметр «уровень» это не описывает.

Внутри уровня разброс огромный. «Средний» — это диапазон, в котором два ученика отличаются сильнее, чем средний от начинающего.

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

Нужна непрерывная величина, которая обновляется после каждого ответа.

Модель: IRT для сложности, Elo для способности

Каркас я взял из теории тестовых заданий (Item Response Theory) — той самой, на которой построены адаптивные экзамены вроде GRE, — и из рейтинговых систем. Ориентиром служило то, что публично рассказывал Duolingo про свою систему Birdbrain.

Идея разделения: сложность материала считается офлайн и одинакова для всех, а способность ученика θ калибруется онлайн, на устройстве. Никакого сервера для этого не нужно — вся адаптация живёт локально.

Вероятность верного ответа — логистическая функция от запаса способности над сложностью:

export const LOGISTIC_S = 12;          // крутизна кривойexport const TARGET_SUCCESS = 0.82;    // целевая доля верных ответовexport function pCorrect(theta: number, dEff: number, s = LOGISTIC_S): number {  return 1 / (1 + Math.exp(-(theta - dEff) / s));}

Здесь theta — способность ученика (шкала 0–100), dEff — эффективная сложность конкретного задания. Когда они равны, шанс верного ответа ровно 50%. Нам столько не надо: на половине ошибок человек сдаётся.

Целевые 82% — это компромисс между известным «правилом 85%» из работ про оптимальную сложность обучения и практикой: при 85% ошибки уже ощущаются как частые, если задания короткие и идут одно за другим. Из целевой вероятности выводится нужный запас способности над сложностью:

// При каком (θ − d) достигается TARGET_SUCCESSexport const TARGET_OFFSET = LOGISTIC_S * Math.log(TARGET_SUCCESS / (1 - TARGET_SUCCESS)); // ≈ 18.2

То есть подбирать надо не «задание по силам», а задание сложности примерно θ − 18. Это не подгонка: перевернутая логистика даёт точное значение для любой цели, поменяете 0.82 на 0.9 — сдвиг пересчитается сам.

Обновление способности: симметричный Elo

После каждого ответа θ сдвигается пропорционально неожиданности результата:

export function updateTheta(  theta: number,  dEff: number,  correct: boolean,  answersSoFar: number,): number {  const k = answersSoFar < 40 ? 8 : 3;      // сначала учимся быстро, потом осторожно  const expected = pCorrect(theta, dEff);  const next = theta + k * ((correct ? 1 : 0) - expected);  return Math.min(Math.max(next, 0), 100);}

Это Elo в чистом виде, где «соперник» — задание. Важные свойства, ради которых стоит брать именно его:

  • Провалить лёгкое дороже, чем трудное. Если expected был 0.95, ошибка снимает 8 × 0.95 ≈ 7.6 пункта. Если expected был 0.3 — всего 2.4. Это интуитивно правильно и снимает несправедливое наказание за попытку взять высоту.

  • Угадать трудное ценнее, чем решить лёгкое. Симметрично тому же.

  • Переменный K. Первые 40 ответов мы почти ничего не знаем о человеке, и оценка должна двигаться размашисто. Потом — мелкими шагами, иначе один неудачный вечер обнуляет накопленную картину.

Начальное значение θ берётся из короткого входного теста, а не с потолка: попасть в диапазон сразу гораздо лучше, чем сходиться к нему десятками заданий.

Вторая ось: формат задания — это тоже сложность

Вот момент, который я считаю самым полезным из всей конструкции. Одну и ту же фразу можно спросить радикально по‑разному, и разница между «узнать среди вариантов» и «вспомнить с нуля» больше, чем разница между простой и сложной фразой.

Поэтому формат задания входит в сложность слагаемым:

export const FORMAT_OFFSET: Record<PracticeFormat, number> = {  choice:   -8,  // узнавание перевода  gap:       0,  // подсказанное припоминание  assemble:  6,  // контролируемая продукция  fix:      10,  // заметить чужую ошибку  respond:  12,  // выбрать уместную реплику  recall:   18,  // свободное припоминание};export function effectiveDifficulty(phraseDifficulty: number, format: PracticeFormat): number {  return phraseDifficulty + FORMAT_OFFSET[format];}

Лестница выстроена по когнитивной нагрузке: узнавание → подсказанное припоминание → продукция → свободное припоминание. Свободное припоминание самое трудное и одновременно самое полезное для памяти — по нему в литературе по testing effect согласия больше всего.

Дальше выбор задания становится тривиальным: берём формат, при котором эффективная сложность ближе всего к целевой.

export function chooseFormat(theta, phraseDifficulty, options = {}) {  const targetDeff = theta - TARGET_OFFSET;  const formats = options.allow ?? allowedFormats(options);  return formats.reduce((best, f) =>    Math.abs(effectiveDifficulty(phraseDifficulty, f) - targetDeff) <    Math.abs(effectiveDifficulty(phraseDifficulty, best) - targetDeff) ? f : best);}

Один и тот же материал новичок увидит как «выбери перевод», а сильный ученик — как «вспомни целиком». Контент не дублируется, а сложность при этом меняется по‑настоящему, а не косметически.

Важная деталь — гейтинг. Продуктивные форматы нельзя давать на незнакомом материале, иначе получится не обучение, а угадайка:

export function allowedFormats({ stage = 0, hasSituation = false, hasFix = true } = {}) {  const formats = ['choice', 'gap', 'assemble'];  if (stage >= 2 && hasFix) formats.push('fix');  if (stage >= 2 && hasSituation) formats.push('respond');  if (stage >= 3) formats.push('recall');  return formats;}

stage здесь — ступень интервального повторения: сколько раз материал уже успешно воспроизводился. Свободное припоминание открывается только на том, что человек уже трижды вспомнил.

Проверка: почему я не поверил модели на слово

Тут начинается самое интересное. Всё описанное выше выглядит убедительно, компилируется и на глаз работает. Но «на глаз» в адаптивных системах не значит ровным счётом ничего.

Профдеформация помогла: я слишком часто видел, как аккуратно посчитанная метрика оказывается смещённой, чтобы поверить своим ощущениям от десятка заданий. А чтобы заметить, что целый сегмент пользователей систематически получает 60% вместо 82%, руками надо пройти сотни заданий — и всё равно не заметишь, если сам не относишься к этому сегменту.

Поэтому я написал симулятор. Он делает ровно одно: гоняет тысячи виртуальных учеников с разной «истинной» способностью через настоящий контент и настоящий код подбора, а потом смотрит на распределение доли верных ответов.

Логика симулятора умещается в несколько строк:

// у виртуального ученика есть истинная способность trueTheta,// система о ней не знает и начинает с оценки из входного тестаlet theta = seedFromLevelTest;for (let i = 0; i < STEPS; i++) {  const phrase = pickNextPhrase(state);  const format = chooseFormat(theta, phrase.difficulty, gating(phrase));  const dEff = effectiveDifficulty(phrase.difficulty, format);  // ученик отвечает по своей ИСТИННОЙ способности  const correct = Math.random() < pCorrect(trueTheta, dEff);  theta = updateTheta(theta, dEff, correct, i);   // а система обновляет ОЦЕНКУ  log(correct);}

Ключевой момент — разделение trueTheta и theta. Ученик отвечает по своей настоящей способности, система же видит только исходы и корректирует оценку. Если модель хороша, оценка сходится к истине, а доля верных ответов держится у цели.

Критерий приёмки я зафиксировал заранее, до запуска: доля верных ответов должна лежать в коридоре 78–90% для всех стартовых уровней. Заранее — принципиально, иначе неизбежно начинаешь подгонять критерий под то, что получилось.

Что показала симуляция

Первый прогон провалился. Сильные и средние держались в коридоре, а новички стабильно получали 58% верных ответов — далеко за нижней границей. Сначала я вообще решил, что ошибся в симуляторе: слишком уж ровно проседал один сегмент.

Симулятор был в порядке. И модель, как выяснилось, тоже. Проблема была в контенте: подбор честно просил задание сложности θ − 18, но в материале просто не было ничего настолько простого. Самая лёгкая фраза в самом лёгком формате всё равно оставалась трудноватой для настоящего новичка. Алгоритм упирался в пол и отдавал что было — а метрика показывала провал там, где виноват не алгоритм.

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

Чинилось это не в алгоритме, а в двух других местах:

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

  2. Добавление лёгкого регистра. Для части фраз появились разговорные упрощённые варианты — тот самый недостающий пол. Это подняло долю верных ответов у новичков с 58% примерно до 69,5%, а после калибровки стартов сегмент вошёл в коридор.

Второй сюрприз был мельче, но показательнее. Формат «найди лишнее слово» я сначала генерировал автоматически: вставлял в фразу случайное служебное слово. Симуляция таких вещей не ловит — их видно только глазами. А глазами получалось «I’m a excited» и «Looking a forward»: не типичная ошибка, а мусор, который ничему не учит.

Переписал на правила высокой точности — только частотные ошибки русскоязычных: лишнее to после модального (Could you to clarify), лишняя связка (I am work as), лишний артикль после предлога (stay in the touch). А если ни одно правило не срабатывает — функция возвращает null, и формат для этой фразы просто не предлагается:

export function makeFixError(en: string): { tokens: string[]; wrongIndex: number } | null {  // ... правила A, B, C  return null;  // лучше не предложить формат, чем предложить мусор}

Покрытие упало до трети фраз. Качество — до нуля мусора. Обмен, о котором я не жалею: в обучении одно бессмысленное задание стоит дороже, чем десять несделанных.

Что бы я сделал иначе

Симулятор писал бы первым, а не третьим. Он занял пару часов, а нашёл проблему, которую я не увидел бы месяцами — потому что сам я не новичок и на своём опыте пол сложности не почувствовал бы никогда.

Не усложнял бы модель раньше времени. В IRT есть двух‑ и трёхпараметрические варианты (с дискриминацией и угадыванием). Соблазн большой, пользы на наших объёмах — никакой: параметры нечем оценивать, данных не хватает. Одномерная модель с честной калибровкой выигрывает у сложной с выдуманными коэффициентами.

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

Итог

Работающая адаптивность — это не «если уровень новичок, то дай лёгкое». Это три вещи вместе: непрерывная оценка способности, вторая ось сложности в виде формата задания и проверка симуляцией с критерием, зафиксированным заранее.

Первые две — приятная математика, которую интересно писать. Третья — скучная, и именно она спасла проект: без неё я бы выкатил систему, которая держит новичков на 58% верных ответов и выглядит при этом совершенно исправной.

Всё описанное работает в Upword — тренажёре английского, который я делаю; код адаптации живёт целиком на устройстве, без серверов и сбора данных. Пощупать можно в браузере, если интересно посмотреть на модель в действии.

Если делаете что‑то похожее — спрашивайте в комментариях, отвечу подробно, включая то, что не получилось.

ссылка на оригинал статьи https://habr.com/ru/articles/1063622/