Разбираем алгоритм интервального повторения в приложении для слов: модель состояния, почему не FSRS, и как починили снежный ком. Читайте и применяйте!
Каждое приложение для запоминания слов держится на одном решении: когда показать слово снова. Мы ошиблись в этом месте интересным способом — рассказываю, как теперь устроен наш алгоритм интервального повторения и что нам это стоило.
Вся модель — это два числа и конечный автомат. Никаких сложных зависимостей.
class Word:
state: 'LEARNING' | 'REVIEW'
correct: int # успешных ответов в LEARNING
interval: int # дней, значимо только в REVIEW
difficulty: float # начинается с 1.0
due: dateНовое слово начинает в LEARNING и покидает его только после девяти правильных ответов. Мы ограничиваем три ответа в день, распределённые по трём типам упражнений: перевод, заполнение пропуска, произношение. Минимум три дня до «выпуска», и слово должно выдержать три разных способа проверки.
После выпуска — лестница удвоения: 1 → 2 → 4 → 8 → 16 → 32 → 64 → 128 → 256 → 512 дней. Правильный ответ — шаг вверх, ошибка — откат, при серьёзном провале слово возвращается в LEARNING и снова зарабатывает девять ответов.
Вопрос, который возникает у всех: почему не FSRS — алгоритм, на который перешёл Anki? Он моделирует память через difficulty, stability и retrievability, предсказывая вероятность вспомнить слово сегодня и выбирая день, когда вероятность пересекает целевую retention. Это лучше. Но мы не взяли его по двум причинам.
Нужны логи просмотров, которых у нас не было. FSRS подгоняет параметры под реальную историю. В первый день истории нет — пришлось бы ставить дефолтные параметры и сложную систему ради нуля выгоды.
Лестница удвоения отлаживается. Когда пользователь пишет, что слово слишком часто возвращается, я читаю его историю за десять секунд и объясняю причину. С подобранной моделью я бы смотрел на три числа. На этапе, когда ты ещё выясняешь, что твой продукт делает не так, возможность объяснить систему пользователю ценнее пары процентов retention.
Сейчас, когда логи уже есть, я бы пересмотрел решение. Это честное «сегодня мы бы сделали иначе», а не утверждение, что простое лучше.
Единственное место, где мы не пошли простым путём, — оценка ответа. Большинство карточек сводят ответ к «верно/неверно», выбрасывая почти всю информацию. Два пользователя ответили правильно, но один думал девять секунд и использовал подсказку.
Поэтому difficulty стартует с 1.0 и меняется не только от правильности:
def update_difficulty(word, answer):
if answer.attempt > 1:
difficulty += a # справился, но не с первой попытки
if answer.used_hint:
difficulty += b
if answer.time_ms > slow_for(word.exercise_type):
difficulty += c
if answer.type == SPEECH and answer.recognition_score < threshold:
difficulty += d
recent_error_rate = errors(word, last_sessions=10) / attempts(word, last_sessions=10)
difficulty = blend(difficulty, recent_error_rate)
return clamp(difficulty, MIN, MAX)
def next_interval(word):
return round(ladder_step(word.interval) / word.difficulty)Константы a, b, c, d я намеренно не раскрываю — они подобраны вручную, на глаз. Важна форма: медленный, с подсказкой, со второй попытки «правильно» — не то же самое, что мгновенный ответ, и интервал должен быть короче.
Речевой сигнал — самый ценный. Упражнения на произношение проходят через распознавание речи, и низкий балл у слова, которое пользователь «знает», — ранний признак, что он знает его только визуально. А такое знание и рушится в разговоре.
Скользящая ошибка за последние 10 сессий — противовес. Одиночный ответ шумен: люди тыкают не туда в автобусе. Слово должно быть стабильно трудным, чтобы коэффициент сдвинулся.
Планировщик был в порядке. Очередь — нет.
В модели нет ограничения на число слов, которые становятся due в один день. Пользователи добавляют слова пачками — сорок слов в воскресенье вечером. Через три дня все сорок одновременно выходят из LEARNING. Ещё через шестнадцать — снова вместе. Лестница детерминирована, поэтому пачки держат строй вечно.
Каждая дата по отдельности верна. Но во вторник приложение говорит: «у вас 380 слов на повторение». Люди не повторяют 380 слов — они закрывают приложение. А система, которую не открывают, хуже отсутствия: интервалы истекают, число растёт, к пятнице уже 520.
Мы видели это в данных: перед оттоком сессии не удлинялись, а укорачивались и прекращались. Бэклог никого не перегружал — он просто деморализовал.
Очевидное решение — ограничить новые слова в день. Так делают большинство приложений. Мы не стали: момент, когда человек услышал слово в сериале и хочет его сохранить, — пик вовлечённости. Сказать «вы достигли лимита» — наказать за то, ради чего приложение существует.
Поэтому мы ограничили другой конец. Сессия всегда одного размера — десять слов, и полная очередь не показывается.
def build_session(user):
due = words_due_for(user) # может быть 500
ranked = sort_by(due, [overdue_days desc, difficulty desc])
return take(ranked, SESSION_SIZE) # всегда 10Что изменилось. Видимое число перестало быть угрозой. В интерфейсе нет «380», только сессия из десяти и план на три сессии сегодня. Работа та же, но не выглядит стеной.
И порядок очереди стал настоящей проблемой — вот интересное следствие. Когда показываешь всё, порядок не важен: пользователь пройдёт всё или ничего. Когда показываешь десять из пятисот, эти десять слотов — весь продукт. Выберешь плохо — слово может ждать неделями, пока лёгкие циклически проскакивают. Поэтому сортировка сначала по просроченности, потом по сложности: самое гнилое в начале, шаткие слова в приоритете над теми, что пользователь прошёл бы легко.
Бэклог остался, но тает медленно, а не героическим забегом. Это нормально — тот же компромисс, что и лестница, только для очереди.
Две вещи, на которые у меня нет хорошего ответа.
Долгие отсутствия. Человек пропал на два месяца и вернулся к очереди, где все интервалы взорвались. Сброс всего в LEARNING — слишком жестоко; игнорировать разрыв — первый сеанс будет полон забытых слов. Сейчас сортировка по просроченности скрывает симптом, но это не решение.
Пачки движутся вместе. Ограничение сессии спрятало симптом, но не разбило колонну: слова, добавленные в один вечер, идут по лестнице строем. Добавление небольшого джиттера к интервалу размазало бы их. Причина, почему мы этого не сделали, неудовлетворительна: никто не жаловался с тех пор, как сессии ограничены, а всегда есть что-то более заметное для фикса.
Если вы строите систему интервального повторения, начните с простой лестницы и не усложняйте модель, пока не накопите логи. Ограничивайте размер сессии, а не добавление слов, и сортируйте очередь по просроченности и сложности. И не бойтесь ручной подгонки констант — это нормально на раннем этапе.
А если пользователь пропадает на два месяца? Поделитесь в комментариях, как вы решаете эту задачу. Мне до сих пор не даёт покоя.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →