Любой тренажёр рано или поздно упирается в одну задачу: что показать человеку следующим. Слишком легко — скучно и бесполезно. Слишком трудно — закрыл и не вернулся. Между этими краями есть узкая полоса, которую психологи называют зоной ближайшего развития, а продуктовые люди — зоной потока.
Я аналитик данных, и на работе постоянно имею дело с оценками скрытых величин по косвенным наблюдениям. Когда взялся за свой тренажёр английского, оказалось, что задача подбора сложности — ровно из этой оперы: у ученика есть некая способность, которую мы не наблюдаем напрямую, а видим только цепочку «ответил верно / ответил неверно». Дальше — обычная задача оценивания, только в реальном времени и на телефоне.
Ниже — модель, которая в итоге получилась: как считать сложность, как оценивать способность по ходу дела и как использовать тип задания в качестве второй оси сложности. И главное — как я проверял, что оно работает, потому что красивая математика врёт особенно охотно.
Почему пороги не работают
Первое, что приходит в голову: уровни. Новичок — лёгкие задания, продвинутый — трудные. Так делают почти все, и это разваливается по трём причинам.
Уровень — не число, а профиль. Человек может уверенно узнавать фразу на слух и при этом не мочь построить её сам. Один параметр «уровень» это не описывает.
Внутри уровня разброс огромный. «Средний» — это диапазон, в котором два ученика отличаются сильнее, чем средний от начинающего.
Пороги дискретны, а обучение непрерывно. Пока человек не перешагнул границу уровня, он получает один и тот же материал; перешагнул — его швыряет в заметно более трудный. Оба состояния плохи.
Нужна непрерывная величина, которая обновляется после каждого ответа.
Модель: 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, но в материале просто не было ничего настолько простого. Самая лёгкая фраза в самом лёгком формате всё равно оставалась трудноватой для настоящего новичка. Алгоритм упирался в пол и отдавал что было — а метрика показывала провал там, где виноват не алгоритм.
Это ровно тот класс ошибок, который невозможно увидеть в коде: формулы верны, тесты зелёные, а система систематически мучает целый сегмент пользователей. Нашлось только симуляцией.
Чинилось это не в алгоритме, а в двух других местах:
-
Перекалибровка стартовых значений. Оценки после входного теста я пересобрал так, чтобы они соответствовали реальному распределению сложности контента, а не абстрактной шкале.
-
Добавление лёгкого регистра. Для части фраз появились разговорные упрощённые варианты — тот самый недостающий пол. Это подняло долю верных ответов у новичков с 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/