Уже больше года я занимаюсь проектом RuLearn. Это довольно большое мобильное приложение на ~10000 строчек кода, которое реализует метод интервальных повторений, об истории проекта можно прочитать в моих предыдущих публикациях 1 и 2. Проект получился удачным, и даже побывал в числе победителей школьного московского конкурса "Инженеры будущего". Школьного, потому что автор - школьник :)

За лето RuLearn в проекте многое изменилось, и сейчас я хочу зафиксировать результат, связанный с добавлением машинного обучения. Сейчас, когда модель готова и можно будет опять переключиться на программирование мобильной части, важно записать, что было сделано. Иначе потом и не вспомнишь.

Итак, интервальные повторения. Применяются в основном для изучения новых слов в иностранном языке. По мере изучения новых слов, вступает в действие кривая забывания Эббингауза. Поэтому если обучающему предлагать для повторения только те юниты, которые находятся на грани забывания, можно добиться повышения эффективности запоминания. У кривой Эббингауза фиксированные интервалы, и это, скорее всего, неправильно. Представьте разницу в изучении английского языка, с которым мы так или иначе сталкиваемся, даже когда его не учим, и, к примеру, китайского. Очевидно, что учить иероглифы и составлять из них слова сложнее, чем запомнить значение слова "cringe". С другой стороны, даже в рамках английского языка, слово "lugubrious" запомнить гораздо сложнее, чем слово "loot". Оценить различия в сложности языков, в принципе, задача невыполнимая. Очевидно, что арабский будет даваться жителю России проще, если он живет в мусульманском регионе. Финский как-то проще заходит жителям Питера итд. Иврит, если вы не сталкивались с ним раньше, сломает не только OpenCSV в вашем коде, но и ваш мозг.

Задача сложности внутри языка тоже не так проста, как кажется. Например, есть такой проект, в котором носителям английского давали в тестах оценить сложность для восприятия различных слов. Аналогичные исследования проводились всего несколько раз за последние десятилетия, потому что это очень дорого и довольно сложно. Понятно, что изучение сложности иностранных языков для "не носителей" вряд ли кто-то будет проводить вообще. В какой-то степени сложность внутри языка отражают частотные словари. Наиболее часто употребляющиеся слова должны быть, скорее всего, проще для восприятия как для носителей, так и для изучающих язык как иностранный.

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

Идея хорошая, но в начале мая я написал на Хабре:

Как это сделать практически, мне пока что непонятно. То есть ясно, что это тема из Natural Language Processing, но не совсем. Как использовать эту статистику? Как использовать то, что обычно делается на Python, в мобильнике? Одни вопросы. Хорошо бы найти научного руководителя.

Но научный руководитель не нашелся. Пришлось делать самому. Для начала добавил таблицу в базе данных, куда записывалась статистика ответов по одному из курсов в RuLearn. Что можно записать? Да все возможное! Идентификатор слова - отлично, потому что курс построен на базе частотного словаря, поэтому чем он больше, тем сложнее слово. Рейтинг до и после ответа (рейтинг повышается при правильном и сбрасывается при неправильном). Таймстемп. Время, потраченное на ответ. Число использованных подсказок. Тип теста. И... все - больше данных не осталось.

CREATE TABLE 'machine_learning'(id int, rating_before int, rating_after int, 
                                time_to_answer int, test_type int, timestamp int, 
                                correct int, hint_used int)

После того, как это стало понятно, отпал вопрос про Natural Language Processing. Это просто не нужно, у нас на входе готовые цифры. То есть обычная регрессия или классификация уровня "Hello world" для начинающих в Data Science.

Начинающие в Data Science пользуются готовыми датасетами, и это очень скучно. У меня будет свой, ура, но тут начались проблемы. В идеале, нужно было бы собирать статистику с нуля. Но среди семьи и знакомых не нашлось никого, кто бы вдруг решил учить что-то новое, поэтому пришлось пойти на компромисы и начинать сбор с середины курса с надеждой, что потом можно будет все исправить. За 2 месяца удалось набрать около 5000 ответов со статистикой и я решил, что можно начинать строить модель.

4703 ответа на начало августа, кто-то очень хорошо поработал :)
4703 ответа на начало августа, кто-то очень хорошо поработал :)

Оказалось, что для того, чтобы перенести в дальнейшем модель в мобильное приложение, нужно сразу использовать TensorFlow. А это инструмент для "настоящих профессионалов", и все простые обучалки с сайта TensorFlow давно выпилили. Но к счастью, интернет помнит все, и нашлелся классический python notebook для классификации цветков ириса. Задача похожая, и даже более простая - у меня классификация по вычисляемому полю correct (1=ответ правильный, 0=ответ неправильный), ну а у ирисов целых 3 вида. Соответственно, меняем в модели loss = categorical_crossentropy на binary_crossentropy, модифицируем код и... модель не работает.

Нет, ну работает, конечно, правильно в 85% случаев, а то и выше. Потому что в статистике и так 85% правильных ответов. Поэтому модель быстро понимает, что ответ "1" дает отличный accuracy_score, и делает ничего больше. Я менял датасеты, исключая старые данные, оставляя слова, выученные с нуля после начала сбора статистики. Модель показывала лучшие результаты во время обучения, но как только я проверял это на полном наборе, все разваливалось. Я постепенно увеличивал сет для обучения. Я делал стандартизацию данных и не понимал, как в дальшнейшем на сделать ее мобильном - ведь там StandardScaler отстутствует. Я даже стал лучше понимать Python :)

Это было не очень приятно. Хорошая идея никак не хотела реализоваваться. Книжки по Data Science не особо удачные. Например, Data Science Bookcamp (Leonard Apeltsin) начинается с объявления массива функций для пояснения идеи, которая и так понятна. Я дальше читать не стал, если для очевидных вещей автору понадобился такой синтаксис, то наверное, я дальше не осилю. Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow (Aurélien Géron) - со второй главы вводит в такие дебри стратификации, из которых не хочется возвращаться. Но после него я хотя бы догадался посмотреть на коррелляцию в моем сете:

result 1.000000
id -0.012985
ts -0.056333
lvl -0.074329

Очевидно, что регрессия здесь не поможет. К этому времени иллюзия, что машинное обучение выявит зависимости само по себе, без подсказок, у меня прошла. Я решил, что стоит преобразовать данные так, чтобы мне самому можно было бы понять, какая логика стоит за изменениями показателей. Например, результаты должны ухудшаться по мере увеличения сложности слов (то есть с ростом id). Или результаты должны быть лучше через минимальное время после первоначального обучения и падать в дальнейшем. Кроме того, классификация тоже неудачная идея. Мы не можем доверять модели на 100% (даже на 50%), поэтому стоит подстраховаться. Нужно вычислять вероятность, с которой мы получим правильный ответ и исходя из нее менять интервал для повторения. Даже совсем плохая модель не сможет сломать эту логику, если учитывать естественные ограничения (делить на 0 нельзя, вероятность > 1 быть не может). Итак, преобразуем данные. Создадим таблицу, в которой будут следующие поля:

  • id, номер слова

  • n_repeat: общее число повторений для этого id

  • cur_rating: рейтинг слова (это число, которое меняется от 10 до 15 для выученного слова, в зависимости от правильности ответов. При правильном ответе рейтинг увеличивается и соответственно, увеличивается интервал, после которого его нужно повторять. Рейтинг 0-9 используется во время изучения слова. 0 - слово никогда не показывалось ученику, 9 - почти выучено. При этом слово не считается "выученным" и не входит в статистику для машинного обучения).

  • s_lapsed: время, прошедшее с предыдущего повторения. Проблема в том, что я собирал значения timestamp. Поэтому потребуются преобразования и для тех единиц, где было всего одно повторение, возьмем за начальную минимальную дату по курсу, в данном случае 1715405940918, 11 мая 2024 года.

  • остальные параметры (type_repeat и n_hint) не имеют значения для построения модели на данном этапе. Поскольку в курсе использовался только один вид повторений, нет смысла учитывать это поле и число подсказок. Но в базе на всякий случай эти поля предусмотрим.

И вот что получается:

insert into ar5000ML_ml
WITH T as 
(
  SELECT id, count(id) as n_repeat, rating_before as cur_rating, MAX(timestamp) as max_ts, sum(correct) as sum_correct 
  FROM machine_learning
  group by id
)

SELECT id, cur_rating, n_repeat, sum_correct, 1 as type_repeat, (max_ts - ifnull((SELECT MAX(timestamp) 
                    FROM machine_learning 
                    WHERE timestamp < T.max_ts and id = T.id),1715405940918))/1000  as s_lapsed, 0 as n_hint
FROM T

Загружаем данные в dataframe, и проверяем коррелляцию. По этому сету получается гораздо лучше.

sqlstr = "select id, cur_rating, n_repeat, sum_correct*1.0/n_repeat as result, s_lapsed from ar5000ml_ml"
conn = sqlite3.connect('rulearn.db')
cursor = conn.cursor()
cursor.execute(sqlstr)
df = DataFrame(cursor.fetchall(), columns=['id', 'cur_rating', 'n_repeat', 'result', 's_lapsed'])
conn.close()

X = df[['id', 'cur_rating', 'n_repeat', 's_lapsed']]
y = df['result']

corr_matrix = df.corr()
print(corr_matrix["result"].sort_values(ascending=False))

result 1.000000
cur_rating 0.530378
s_lapsed 0.524887
id -0.190360
n_repeat -0.697639

Дальше по образцу с сайта TensorFlow делаем модель:

X = df[['id', 'cur_rating', 'n_repeat', 's_lapsed']]
y = df['result']
x_train, x_test, y_train, y_test = train_test_split(X, y, test_size=0.3, shuffle=True, random_state=69)

normalizer = tf.keras.layers.Normalization(axis=-1)
normalizer.adapt(np.array(X))
model = tf.keras.Sequential([
    normalizer,
    tf.keras.layers.Dense(64, activation='relu'),
    tf.keras.layers.Dense(64, activation='relu'),
    tf.keras.layers.Dense(1)
])

model.compile(loss='mean_absolute_error',
              optimizer=tf.keras.optimizers.Adam(0.001))

history = model.fit(
    x_train,
    y_train,
    validation_split=0.2,
    verbose= 1, epochs=100)

С такой коррелляцией неудивительно, что сеть обучается моментально:

hist = DataFrame(history.history)
hist['epoch'] = history.epoch
plt.plot(history.history['loss'], label='loss')
plt.plot(history.history['val_loss'], label='val_loss')
plt.ylim([0, 1])
plt.xlabel('Итерация')
plt.ylabel('Ошибка')
plt.legend()
plt.grid(True)
plt.show()

Проверим метрики обучения регрессионной модели.

test_predictions = model.predict(x_test)
from sklearn.metrics import mean_squared_error
print(f"Mean squared error is: {mean_squared_error(y_test, test_predictions)}")
from sklearn.metrics import r2_score
print(f"R2 score is: {r2_score(y_test, test_predictions)}")

Mean squared error is: 0.003626453253135609
R2 score is: 0.6607994723531427

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

Зависимость очень логична - чем сложнее слово, тем меньше вероятность правильного ответа.
Зависимость очень логична - чем сложнее слово, тем меньше вероятность правильного ответа.
Чем больше проходит дней, тем хуже мы помним слово - логично! Есть перекос с вероятностью 1.3, но в приложении просто не может быть ситуации, когда статистика бы собиралась через 0 минут поле предыдущей.
Чем больше проходит дней, тем хуже мы помним слово - логично! Есть перекос с вероятностью 1.3, но в приложении просто не может быть ситуации, когда статистика бы собиралась через 0 минут поле предыдущей.
Число повторений растет при сложных словах. 5 повторений - это значит, что рейтинг от 10 до 15 пройден без одной ошибки - слово железно выучено. Для некоторых же слов даже 30 повторений недостаточно - просто не запомниаются и все.
Число повторений растет при сложных словах. 5 повторений - это значит, что рейтинг от 10 до 15 пройден без одной ошибки - слово железно выучено. Для некоторых же слов даже 30 повторений недостаточно - просто не запомниаются и все.
Что это???
Что это???

Самый сложный для объяснения результат. Рейтинг 10 означает, что слово мы только что выучили и лучше всего его помним. Вероятность правильного ответа снижается к 13-14 рейтингу. Это значит, что интервалы Эббингауза для данного курса были изначально неправильными, их нужно было бы сократить. Рост вероятности при рейтинге 15 - следствие наличия давно и прочно выученных слов, которые показывались всего один раз за все время наблюдения. То есть, если я бы начал учить шведский язык, и собирал статистику с самого начала, этого "хвоста" бы не было.

Итак, модель готова. В следующей статье буду добавлять ее в мобильное приложение. К сожалению, эта часть вообще как-то странно документирована для разработчиков на Android, поэтому важно не забыть самому, как я это сделал :)

Комментарии (7)


  1. digtatordigtatorov
    16.08.2024 13:34
    +1

    Это обзорная статья или инструкция по применению? Приехал автобус с критикой

    10 тысяч строк это нормально, не много. Какую модель? Почему не пробовал разные? PyTorch не хуже, не согласен «нужно сразу использовать TensorFlow в мобилке». И вообще причем здесь тензорфлоу, если большая часть регрессионных моделей есть в scikit learn, а для работы с табличными данными используют pandas? Почему не клиент-серверное решение, которое развязывает руки на используемые библиотеки и решения через http протоколы? Почему не думал о рекуррентных сетях? С графиками ведь работаешь? Где данные о валидации модели? Об обучении? Проверка на тестовой части? Это не статистика, а просто данные, статистикой тут не пахнет. Матрица корреляций? Абсолютные и относительные погрешности? Почему это не отражено на графиках?

    >> Корелляция по этому сету гораздо лучше:

    >> result 1.000000
    >> cur_rating 0.530378
    >> s_lapsed 0.524887
    >> d -0.190360
    >> n_repeat -0.697639

    Как ты сделал этот вывод?

    Latex?

    Машинное обучение в человеческом обучении? Масла не много?


    1. vlad_msk_ru
      16.08.2024 13:34

      Когда заведете scikit на андроиде, напишите, коммунити порадуется. Собственно, по всем комментариям у вас странности. Например, последнее - вывод в консоль кореллейшн матрикс, при чем тут латех, вы что.


      1. digtatordigtatorov
        16.08.2024 13:34

        Если Вы не желаете слышать критику, пожалуйста, не делайте ничего нового. Джефф Безос

        Причем здесь андроид?

        Клиент-серверная архитектура, где в качестве клиента выступает телефон не требует ее (scikit) наличия. Все вычисления, тем более в задачах ML, было бы разумнее осуществлять на сервере, не находите? Там, где располагаются основные вычислительные мощности. Зачем перекладывать эти вычисления на пользователя? Ему и самому это не приятно.

        Как это сделать практически, мне пока что непонятно. То есть ясно, что это тема из Natural Language Processing, но не совсем. Как использовать эту статистику? Как использовать то, что обычно делается на Python, в мобильнике? Одни вопросы. Хорошо бы найти научного руководителя.

        Я даю Вам ответы на Ваши же вопросы.

        Подкрепляйте статью своими мыслями и выводами. Иногда даже простые вещи не всегда очевидны и понятны.

        Корелляция по этому сету гораздо лучше: ...

        Если Вы утверждаете, что корреляция лучше, сделайте сравнительную таблицу, чтобы не было повода возвращаться в начало статьи.

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

        Я уверен, что Вы подумаете, что это мелочи, но из мелочей складывается общая картина. - Не Джефф Безос

        Latex - замечательный инструмент, помогающий улучшить визуальное восприятие формул и буквенных выражений. (Или другой аналог предлагаемый на Хабр)

        Учитесь писать статьи сразу на хорошем уровне, Вы как я понимаю на момент выхода статьи все еще не студент. Это поможет Вам в будущем при написании настоящей статьи в научный журнал.

        Очень рекомендую оставлять программный код в состоянии воспроизводимости при создании графиков или изображений, так как Вам не раз может понадобиться переделать тот или иной рисунок. Существуют жесткие различные требования в разных местах.

        Старайтесь избегать воды, избегайте слов паразитов, тогда Вашу статью будет не только интересно, но и приятно читать.


        1. Dima_RziO Автор
          16.08.2024 13:34
          +1

          Я тогда дам тоже пару советов :) уберите поучающий тон из ваших комментариев, у вас нет ни одной публикации на хабре, и вообще непонятно, кто вы такой. Все ваши комментарии на сайте одинаковые - вы поучаете авторов как писать в достаточно хамской манере.

          Ваши мысли по поводу этой статьи - глупость на глупости. Городить клиент-серверную систему для пересчета модели весом в 21 килобайт, во времена, когда уже 5 лет в мобильных процессорах есть NPU? Сервер заводить с nvidia на борту для этого и держать его включенным? Серьезно? Объяснять, что такое Latex можете пойти в сад (детский). Не можете сконцетрировать внимание на 7 минут, чтобы не возвращаться в начало статьи - ваши проблемы. Отвечать на вопросы, заданные 2 месяца назад и уже решенные? вот тогда и надо было, сейчас идите, откуда пришли и не возвращайтесь.


          1. digtatordigtatorov
            16.08.2024 13:34

            Как же из Вас прет юношеский максимализм. Падение уровня статей на Хабре это следствие безалаберного отношения к людям, которые их читают. Я готовлю четыре части статьи. Приходите их прочитать к выпуску. Просто в моем случае я планирую сделать статью последовательной и структурированной, а не выкладывать воду и повторения с других статей. Вы бы еще со своего второго аккаунта статьи дублировали.

            Еще раз, научитесь быть терпимым к критике, без нее нет развития.

            P.S: Сервера бывают на CPU и выходят в копейки, но Вы решаете свою надуманную проблему не возможности использовать нужные для разработки библиотеки.


        1. Dima_RziO Автор
          16.08.2024 13:34

          "неприятно" в этом случае пишется слитно)


    1. Dima_RziO Автор
      16.08.2024 13:34

      не думал, что элементарные вещи нужно разжевывать, но ок, добавил код