Прошло чуть больше месяца с релиза OLEG AI - моего бота, который рекомендует посты из пабликов Телеграма.
Вот предыдущий пост, в котором я подробно описал сабж: Аналог фейсбучной ленты для Телеграма. Тупенький ИИ OLEG
После релиза и последовавшего хабраэффекта (так еще говорят?), Олег получил несколько сотен новых юзеров, нагрузку и датасет реальных оценок.
А я получил понимание того, что модель рекомендаций надо немного докрутить, и побороться с утечками памяти.
Модель рекомендаций
В предыдущей статье я писал, что решил отказаться от нейросети в модели, тк модель на основе произведения векторов эмбеддингов обучается быстрее и менее склонна к переобучению. Поюзав немного старую модель и собрав фидбек с первых юзеров, мне стало понятно, что модель все-таки нужно немного усложнить.
Поисследовав немного, я пришел к тому, что нужно поработать над следующими вещами:
Научить модель отличать каналы
Старая модель не знала ничего про каналы, для нее каждый пост был уникальным. Я думал, что в результате разметки постов голосами пользователей посты с одного канала получат похожие эмбеддинги. Но внимательно присмотревшись к этому вопросу, я понял, что так не будет, потому что постов всегда будет гораздо больше чем юзеров, а это значит, что пространство постов будет размечено разреженными оценками. Далеко не всем постам достанется хотя бы одна оценка, а ведь чтобы считать пост хорошо размеченным, ему нужно 10-100 оценок.
Как быть? Количество каналов гораздо меньше количества постов и меньше количества юзеров. В канале все посты имеют примерно одинаковый flavour (извините), поэтому есть смысл ввести в модель эмбеддинги каналов и сделать их основным источником предсказательности. Посты тоже имеют свои эмбеддинги, но теперь 80% эмбеддингов несет канал и 20% - пост.
Окей, если мы вводим эмбеддинги каналов, простая модель DotProduct перестает работать, потому что перемножить три вектора для получения скаляра мы уже не можем. На помощь приходит нейросеть!
Берем эмбеддинги юзера, канала и поста, конкатенируем их в один длинный тензор, и подаем этот тензор на входы нейросети. В моем случае эмбеддинг юзера имеет размерность 100, канала - 80, юзера - 20. Соответственно, у нейросети должно быть столько входов, сколько элементов у конкатенированного тензора. На иллюстрации я изобразил в 10 раз меньше нейронов (10-8-2) просто для удобства иллюстрации. Почему я выбрал такие размерности (100-80-20) - см. ниже.
Нейросеть имеет один скрытый слой из 70 нейронов и 1 нейрон на выходном слое, степень активации которого означает предсказанную оценку.
Предполагается, что нейроны скрытого слоя обучаются признакам более высокого порядка, и сигнал от слоя эмбеддингов, проходя через них, формирует в итоге скаляр, предсказывающий оценку поста юзером.
Подобрать размерности эмбеддингов
В прошлой статье я писал, что не нашел какого-то четкого правила выбора размерностей эмбеддингов, и выбрал размерность 13 для юзера и для поста.
На этой итерации я поисследовал этот момент самостоятельно и провел пару дней в экспериментах.
Я пробовал разные размерности эмбеддингов и соотношение размерностей скрытого слоя и эмбеддингов, а также константы learning rate. Я смотрел, как быстро обучается модель, следил, чтобы скорость обучения (хорошо) не перерастала в быстрое скатывание в переобучение (плохо). Пытался найти баланс между точностью предсказания и генерализацией.
Поскольку у меня на руках был реальный датасет из нескольких десятков тысяч оценок, я мог себе позволить отрезать от него кусок для валидации, и сделать всё как надо: тренировать сетку на обучающем подсете, и валидировать ее работу, находя момент, когда начинается переобучение.
Попробовав различные комбинации размерностей, я подобрал такие значения:
Эмбеддинг юзера - 100,
Канала - 80,
Поста - 20
Соотношение между каналом и постом я выбрал самостоятельно, мне показалось, что смыслы которые несет собой канал превалируют над смыслами, которые несет собой отдельный пост. Также я чисто эмпирически предположил, что общая размерность канал+пост должна быть равна размерности эмбеддинга юзера. Остальные соотношения я подбирал экспериментально. Так, у меня получилось, что размерность скрытого слоя = 70. Такой результат получился, когда я пытался сделать обучение гладким, быстрым, но не очень быстрым, чтобы не проскакивать момент начала переобучения.
Утечки памяти
Олежка работал на самом простом инстансе DigitalOcean с 1ГБ ОЗУ, но так было лишь до тех пор, пока я не получил первую сотню юзеров. Он начал жрать память, пришлось разбираться. Сначала я конечно тупо увеличил ОЗУ дроплета, но оказалось, что это помогает лишь на время :)
Оказалось, что при массовой рассылке свежих постов, Олег каждый раз запрашивает из базы пул свежих постов (10-50к записей). Это тупо и медленно. По моим прикидкам это не должно было жрать память, ведь объект хранящий посты теряет ссылки на себя, как только метод рассылки заканчивает свою работу. Но у питонячего сборщика мусора, видимо, свой взгляд на этот вопрос. Кроме того, это просто некультурно - много раз подряд в цикле забирать из базы одну и ту же инфу. Я написал простой кеш пула свежих постов, это в общем помогло.
Память все еще течет, но гораздо медленнее. На докер-контейнере стоит restart: on-failure
, и когда контейнер вылетает по out-of-memory, он рестартит, происходит это раз в несколько суток.
Как полностью это побороть, я пока не понял. Много раз исследовал свой код на предмет утечек, есть пара методов - кандидатов на создание утечки. Это, например, метод, который добавляет в тензор эмбеддингов новую строку при добавлении нового юзера. PyTorch не поддерживает append строки к тензору, приходится пересоздавать тензор, и в этом месте возможна утечка. Но метод реализован по рекомендациям разработчиков PyTorch, и лучше его не сделать (вроде).
Теперь Олежке достаточно 4 ГБ и Shared CPU. Если кто вам скажет, что для ML нужна стойка с 8 dedicated GPU's - плюньте ему в лицо. Об этом, кстати, часто говорит Jeremy Howard, создатель курса fast.ai.