Итак, в настоящее время я работаю над системой рекомендаций по музыке, построенной на базе базы данных Discogs. На базовом уровне он использует факторизацию матрицы ALS по Спискам желаний и Коллекциям тысяч пользователей Discogs, чтобы генерировать новые музыкальные рекомендации как для любителей музыки, так и для коллекционеров виниловых пластинок. На Medium есть множество замечательных статей о том, как работает совместная фильтрация и другие системы рекомендаций, по большей части я консультировался при создании первоначального проекта. Вместо того, чтобы повторять то, что уже было хорошо сказано, эта серия публикаций направлена на то, чтобы задокументировать мои испытания и невзгоды, связанные с подготовкой кодовой базы и конвейера к производству. Присоединяйтесь ко мне, когда я выношу прототип ноутбука Jupyter в дикую природу!
Формулировка проблемы:
На Discogs у каждого альбома есть соответствующий release_id, поэтому для каждого пользователя, для которого у меня есть информация профиля, у меня есть массив, соответствующий release_ids в их списках желаний и коллекциях. После подачи большой разреженной матрицы этих взаимодействий пользователя с альбомом в модель матричной факторизации, я всего в recommend_all
методе от получения N самых рекомендуемых альбомов для каждого пользователя, использующего удивительную неявную библиотеку Ben Frederickson.
Стремясь сократить как можно больше рекомендаций в режиме реального времени², я решил генерировать рекомендации Top 2000 для каждого пользователя после того, как модель подходит, и сохранять их все в базе данных Mongo. Таким образом, если пользователь уже находится в системе, его рекомендации можно будет быстро получить. Альбомы 2000 года могут быть излишними, но я бы предпочел слишком много, чем слишком мало.
Итак, вот загвоздка: каждая из этих 2000 рекомендаций на пользователя - это всего лишь код категории для release_id. Это просто индекс элемента для release_id альбома в нашей разреженной матрице. Таким образом, задачи здесь следующие:
- Сопоставьте рекомендацию код-категории, которую я назову release_idx, с release_id альбома.
- Извлеките все соответствующие метаданные альбома - исполнитель, название, лейбл, жанр и т. Д. - и объедините с каждой рекомендацией. (Потому что пользователь получает небольшую ценность только от release_id, да.)
- Извлечь все метаданные альбомов для альбомов, с которыми пользователь уже взаимодействовал. Это в основном для настраиваемой фильтрации пользователей, то есть если пользователь хочет скрыть рекомендованные элементы от исполнителя или лейбла, о котором он уже знает или с которым взаимодействовал.
- Сохраните рекомендации и previous_interactions для каждого пользователя в Mongo.
- Делайте это быстро.
Шаг 5 - Сделайте это быстро - это ключевой момент, потому что в идеале я хотел бы переоборудовать модель каждую ночь. У меня есть скребки, которые постоянно загружают новые профили пользователей, поэтому рекомендации будут немного меняться в зависимости от новой информации. Но более того, я хотел бы иметь возможность изменять параметры модели и тестирование на основе онлайн-взаимодействия пользователя с веб-приложением, например схема проверки, которая учитывает рекомендации, которые впоследствии были добавлены в список желаний пользователя или даже куплены.
Короче говоря, как мне собрать и сохранить 2000 рекомендаций с метаданными для всех пользователей как можно быстрее?
TL; DR - с более умным кодом Pandas я смог снизить время обработки для каждого пользователя с ~ 1,8 секунды со стандартным отклонением 0,7 секунды до ~ 500 мс со стандартным отклонением ~ 150 мс.
Исходный уровень
Мой первоначальный план дизайна должен был быть довольно быстрым и грязным. Я участвовал в проектном цикле учебного лагеря, и на этом этапе больше сосредоточился на настройке базового приложения Flask. Моя голова уже была в области SQL из-за необходимости настраивать инфраструктуру базы данных, поэтому я подумал, почему бы Postgres не позаботился и о работе по извлечению метаданных? Это была достойная схема для нескольких тысяч пользователей:
Суть базового процесса заключалась в следующем:
- Прокрутите каждого пользователя
- Запрос метаданных в базе данных Postgres по каждому рекомендованному альбому (4 присоединения)
- Запросить в базе данных Postgres метаданные в списке желаний и коллекции пользователя.
- Отформатируйте метаданные как JSON и сохраните в Mongo как документ
Результат
Это не ужасная производительность. Одно из его преимуществ состоит в том, что у него довольно мало памяти, поскольку большая часть работы выполняется на SQL. Но можете ли вы определить проблему?
# for 1000 Users // GCP n2-highmem-16 (16 cores/128GB RAM) # time in seconds mean 1.7690 std 0.7683 min 1.4863 25% 1.5930 50% 1.6309 75% 1.6871 max 14.5993 Total Time w/ 1 core : 29min 29sec // around 3GB of memory consumption *w/ 16 cores: 4min 50sec // tops out around 10GB memory-consumption (multiprocessing evaluation done now in retrospect - see end of V2)
Проблемы
- У многих пользователей будут пересекающиеся рекомендации - выполнение запроса для каждого пользователя создает МНОГО повторяющихся запросов для одних и тех же метаданных.
- Каждая рекомендация ограничена 2000 альбомами, но списки желаемых и коллекции могут увеличиваться на несколько порядков - у некоторых пользователей в списках желаемых есть более 100 000 альбомов. Таким образом, этим опытным пользователям потребуется больше времени, чем другим. И снова то же самое, что и выше - множество избыточных запросов.
- Относится к последнему пункту: помимо времени обработки каждого запроса, есть также накладные расходы ввода-вывода, связанные с вызовом базы данных в первую очередь. В Postgres на пользователя есть 2 данных, которые, вероятно, уже запрошены. Это ненужное ожидание.
В то время мне нужно было хранить только несколько тысяч пользователей, чтобы продемонстрировать базовую функциональность приложения, поэтому я позволил ему поработать несколько часов и оставил его. Теперь мне нужно пройти через 20 000 пользователей, так что определенно есть куда улучшаться. Посмотрим на следующую версию.
Версия 2.0
Я уже намекнул на то, что мы можем уменьшить количество избыточных запросов к базе данных Postgres. Для удобства мы уже создали набор всех уникальных альбомов, с которыми взаимодействовали наши пользователи, создав нашу оригинальную sparse_item_user
матрицу. Любой рекомендуемый альбом де-факто является членом этого набора, как и любой альбом в Списке желаний или Коллекции пользователя³.
Почему бы просто не объединить метаданные в этот главный список и отфильтровать эту большую таблицу для каждого пользователя?
Суть этого процесса заключалась в следующем:
- Создайте один большой DataFrame из всех альбомов, используемых для обучения модели, со связанными метаданными - с именем
all_interactions
в коде. - Отфильтруйте
all_interactions
для списка желаний и коллекции пользователя, чтобы получить связанные метаданные для предыдущих взаимодействий. - Отфильтруйте
all_interactions
альбомы, рекомендованные пользователем, чтобы получить соответствующие метаданные для этих рекомендаций. - Отформатируйте метаданные как JSON и сохраните в Mongo как документ
Результаты
Не такое уж хорошее начало ...
Пытаясь адаптировать логику моей платформы, ориентированной на SQL, я перенес множество операций, которые неэффективно используют Pandas.
# for 1000 Users // GCP n2-highmem-16 (16 cores/128GB RAM) # time in seconds mean 7.909150 std 0.323563 min 7.090000 25% 7.860000 50% 7.970000 75% 8.080000 max 10.760000 Total Time w/ 1 core : 2hr 12min // around 10GB of memory consumption w/ 16 cores: 22 min 48sec // tops out around 70GB memory-consumption
Примечание: накладные расходы составляют около 2 минут, не включенных в общее время, необходимое для создания большого DataFrame метаданных, но эта операция должна выполняться только один раз.
Проблемы
- Слияние DataFrames в строке 172 - медленная операция, особенно с учетом того, что у меня есть альбомы с повторяющимися строками, которые мне на самом деле не нужны, и они пропадут в следующей строке.
- Логическое маскирование обычно выполняется довольно быстро в Pandas / NumPy, но то, как я настроил его в строках 107 и 110 как составное условие, замедляет его. Необязательно выполнять поиск пользователя в столбце
'username'
дважды - я могу просто установить подмножество фрейма данныхraw_interactions
для пользователя, а затем установить маски только для столбца'score'
. - Использование памяти ВЗРЫВАЕТСЯ, потому что большая таблица должна быть скопирована каждому рабочему на отдельном ядре. Вероятно, есть способ обойти это, но я еще не обнаружил ...
Это выглядит жалким провалом, но следует отметить, что стандартное отклонение времени обработки уменьшилось. И максимальное затраченное время также уменьшилось. Сводная статистика показала мне, что обработка была более последовательной. Это дало мне надежду, что, если я смогу ужесточить код, я смогу сохранить это более жесткое распределение и все время сокращать.
На этом этапе я должен отметить, что я начал интегрировать опцию множественной обработки, поскольку это была задача, хорошо подходящая для распараллеливания. Это было довольно простое добавление кода с использованием модуля concurrent.futures:
Версия 2.5
Многие идеи, которые я перечислил в разделе Проблемы в версии 2.0 выше, в то время не были для меня полностью очевидны, поэтому на этом этапе я начал изучать другие альтернативы параллельной обработки. Обертки графического процессора, ускорители NumPy, компиляторы нижнего уровня и т. Д. Может быть, я мог бы использовать cuDF, Numba или Cython…
Я искал какой-то более эффективный всеобъемлющий инструмент, который волшебным образом исправил бы мою проблему, вместо того, чтобы сталкиваться с неизбежным и постепенным, чтобы уточнить мое понимание того, что нужно коду.
Однако мне не удалось правильно собрать самую последнюю версию cuDF на Ubuntu - так что этого не было.
Numba казалась хорошим выбором, если бы я мог преобразовать свои структуры данных Pandas в объекты NumPy. И я подумал, что, знаешь, может быть виноваты Панды наверху! 🤔
Конвертировать мои DataFrames в recarrays было несложно: это массивы NumPy, которые могут обрабатывать несколько типов данных между столбцами и поддерживать именованные поля. Изменения кода, необходимые для использования этого метода в моем большом DataFrame метаданных альбома:
pd.DataFrame.to_records(column_dtypes=specify_dtypes_here)
И замена других функций Pandas эквивалентами Numpy: np.concatenate
, np.isin
и т. Д.
Благодаря множеству неофициальных %%timeit
тестов казалось, что почти каждая операция будет значительно быстрее.
В спешке я не учел, как эти операции масштабируются на разных порядках ». Мое наивное понимание заключалось в том, что Pandas построен поверх NumPy, поэтому все базовые функции NumPy должны быть быстрее, верно?
На самом деле это не так. Чтобы получить представление о масштабах сравнения NumPy и Pandas, см. здесь. Главный вывод заключается в том, что после примерно 500 000–1 млн записей Pandas на самом деле стал немного более производительным при большом количестве операций.
Я избавлю весь код от этого промежуточного эксперимента и просто выделю результаты.
Результаты
Боже, так плохо
160 секунд всего для 10 пользователей. Мне не терпелось провести сравнительный анализ с большим размером выборки.
Проблема
- Массивы NumPy на 1 миллион + записей немного медленны только для базовой индексации и маскирования.
- Каким-то образом использование курсора Psycopg прямо в NumPy было намного медленнее, чем просто чтение SQL-запроса в Pandas. Так что, несмотря ни на что, я потерял время, вытащив запрос в Pandas, а затем преобразовав его в повторный массив.
- json.dump не принимает типы данных NumPy, поэтому потребовалось дополнительное преобразование.
Версия 3 - Где мы сейчас
Несмотря на небольшую неудачу, возня с NumPy научил меня некоторым полезным вещам о лучшей производительности Pandas. Я также пришел к выводу, что:
Индексирование по четко определенному индексу DataFrame - БЫСТРО
Под четко определенными я имею в виду уникальные, не повторяющиеся ключи. По сути, вы ссылаетесь на хеш-таблицу, в отличие от доступа к элементам столбца, которые больше похожи на список⁵.
Этот последний пункт был ключом к успеху, поскольку в моем all_interactions
DataFrame я понял, что 'release_idx'
каждого альбома эквивалентен индексу DataFrame⁶. А в целях хранения я отбрасываю все повторяющиеся строки, где SQL-запрос мог получить более одного исполнителя для альбома. Так что я действительно могу воспользоваться преимуществами этих быстрых поисков по хешу для фильтрации all_interactions
DataFrame.
Теперь получить полный список рекомендаций каждого пользователя стало намного проще и эффективнее:
- На предыдущем шаге: удалите дубликаты строк и установите индекс
all_interactions
DataFrame как столбец['release_idx']
. Этот DataFrame будет называтьсяall_interactions_dedupe
. - Передайте массив рекомендаций пользователя
all_interactions.loc[recommendations_array]
- Передайте массив взаимодействий пользователя в
all_interactions.loc[wantlist&collection]
. Фильтр для уникальных исполнителей и лейблов. - Отформатируйте в JSON и сохраните в Mongo.
Код выглядит так. Я разделил логику на два файла - один для классов и функций (store.py), а другой - для фактического выполнения пакета (v3.py). Это лучше отделяет бизнес-логику от фоновой:
Результат
# for 1000 Users // GCP n2-highmem-16 (16 cores/128GB RAM) # time in seconds mean 0.4825 std 0.1610 min 0.2925 25% 0.4132 50% 0.4559 75% 0.5237 max 4.1009 Total Time w/ 1 core : 8min 2sec // around 11GB of memory consumption w/ 16 cores: 1min 42sec // tops out around 80GB memory-consumption
Намного лучше! Это в 16 раз больше скорости по сравнению с дрянным кодом Pandas и примерно в 3,5 раза по сравнению с SQL при гораздо более последовательном поведении. Всплеск памяти означает, что мне нужен немного более дорогой тип машины GCP, но это стоит того, чтобы сэкономить время, особенно когда я увеличиваю базу пользователей.
Теперь я могу разобраться с моим текущим объемом пользователей (~ 20 000) чуть более чем за полчаса и, кроме того, я экономлю ~ 20 долларов в месяц. Это не безумное сокращение затрат, но учитывайте каждую деталь по мере ее масштабирования. Я все еще могу добиться относительно своевременной и рентабельной обработки на другой порядок или два.
Вывод
Надеюсь, это было информативное путешествие по итеративному жизненному циклу этого небольшого приложения. Я думаю, что следующим шагом будет сокращение количества операций записи Mongo для более крупной массовой операции и сокращение времени ввода-вывода для всего этого. Модин тоже выглядит интересным спуском. Но пока я доволен.
Уроки выучены:
- Используйте фильтрацию DataFrames, задав индекс столбца, если у вас есть уникальные ключи, или даже отсортированный массив.
- Перекомпоновка NumPy может быть быстрой для небольших наборов данных, но страдает от отметки от полумиллиона до миллиона записей.
- Составное логическое маскирование также замедляет некоторые операции. Попробуйте pd.DataFrame.query (), если вам нужно сопоставить несколько условий.
- Прежде чем слепо доверять статистике %% timeit, внимательно обдумайте свой вариант использования!
В ближайшее время появятся новые сообщения, и мы снова увидим ссылки на само веб-приложение! Прокомментируйте любые улучшения, которые вы заметили, или понравилось ли вам это!
Также не стесняйтесь обращаться по адресу:
[1] Не могу порекомендовать (ха) эту библиотеку в достаточном количестве. Большая часть его написана на Cython с поддержкой многопроцессорности, и интеграцией с графическим процессором CUDA.
[2] implicit делает это немного более удобным, интегрируя параметр recalculate_user в рекомендуемый метод класса модели ALS.
[3] Здесь я опускаю технические детали. Некоторые элементы, рекомендованные моделью, не попадут в окончательный список рекомендаций пользователя, если они настолько новые, что я еще не получил метаданные для этого недавно выпущенного альбома - внутреннее слияние при запросе базы данных приведет к удалению этого альбома. Discogs ежемесячно делает дамп базы данных всего своего архива, так что в худшем случае я отстану всего на месяц.
[4] Как видите, я использовал только 100 семплов в строках 5–6.
[5] Несколько хороших сообщений в StackOverflow о сложности индексирования Pandas: здесь, здесь, здесь и здесь.
[6] Примечание: как только я дедуплицировал release_ids. Исходный запрос к базе данных Postgres для получения метаданных альбома иногда приводит к дублированию записей для определенных альбомов, если у них более одного исполнителя. Например, этот разделенный EP Голди и Док Скотт будет иметь два идентификатора исполнителя, и поэтому при слиянии будет дублироваться строка для выпуска.