Итак, в настоящее время я работаю над системой рекомендаций по музыке, построенной на базе базы данных Discogs. На базовом уровне он использует факторизацию матрицы ALS по Спискам желаний и Коллекциям тысяч пользователей Discogs, чтобы генерировать новые музыкальные рекомендации как для любителей музыки, так и для коллекционеров виниловых пластинок. На Medium есть множество замечательных статей о том, как работает совместная фильтрация и другие системы рекомендаций, по большей части я консультировался при создании первоначального проекта. Вместо того, чтобы повторять то, что уже было хорошо сказано, эта серия публикаций направлена ​​на то, чтобы задокументировать мои испытания и невзгоды, связанные с подготовкой кодовой базы и конвейера к производству. Присоединяйтесь ко мне, когда я выношу прототип ноутбука Jupyter в дикую природу!

Формулировка проблемы:

На Discogs у каждого альбома есть соответствующий release_id, поэтому для каждого пользователя, для которого у меня есть информация профиля, у меня есть массив, соответствующий release_ids в их списках желаний и коллекциях. После подачи большой разреженной матрицы этих взаимодействий пользователя с альбомом в модель матричной факторизации, я всего в recommend_all методе от получения N самых рекомендуемых альбомов для каждого пользователя, использующего удивительную неявную библиотеку Ben Frederickson.

Стремясь сократить как можно больше рекомендаций в режиме реального времени², я решил генерировать рекомендации Top 2000 для каждого пользователя после того, как модель подходит, и сохранять их все в базе данных Mongo. Таким образом, если пользователь уже находится в системе, его рекомендации можно будет быстро получить. Альбомы 2000 года могут быть излишними, но я бы предпочел слишком много, чем слишком мало.

Итак, вот загвоздка: каждая из этих 2000 рекомендаций на пользователя - это всего лишь код категории для release_id. Это просто индекс элемента для release_id альбома в нашей разреженной матрице. Таким образом, задачи здесь следующие:

  1. Сопоставьте рекомендацию код-категории, которую я назову release_idx, с release_id альбома.
  2. Извлеките все соответствующие метаданные альбома - исполнитель, название, лейбл, жанр и т. Д. - и объедините с каждой рекомендацией. (Потому что пользователь получает небольшую ценность только от release_id, да.)
  3. Извлечь все метаданные альбомов для альбомов, с которыми пользователь уже взаимодействовал. Это в основном для настраиваемой фильтрации пользователей, то есть если пользователь хочет скрыть рекомендованные элементы от исполнителя или лейбла, о котором он уже знает или с которым взаимодействовал.
  4. Сохраните рекомендации и previous_interactions для каждого пользователя в Mongo.
  5. Делайте это быстро.

Шаг 5 - Сделайте это быстро - это ключевой момент, потому что в идеале я хотел бы переоборудовать модель каждую ночь. У меня есть скребки, которые постоянно загружают новые профили пользователей, поэтому рекомендации будут немного меняться в зависимости от новой информации. Но более того, я хотел бы иметь возможность изменять параметры модели и тестирование на основе онлайн-взаимодействия пользователя с веб-приложением, например схема проверки, которая учитывает рекомендации, которые впоследствии были добавлены в список желаний пользователя или даже куплены.

Короче говоря, как мне собрать и сохранить 2000 рекомендаций с метаданными для всех пользователей как можно быстрее?

TL; DR - с более умным кодом Pandas я смог снизить время обработки для каждого пользователя с ~ 1,8 секунды со стандартным отклонением 0,7 секунды до ~ 500 мс со стандартным отклонением ~ 150 мс.

Исходный уровень

Мой первоначальный план дизайна должен был быть довольно быстрым и грязным. Я участвовал в проектном цикле учебного лагеря, и на этом этапе больше сосредоточился на настройке базового приложения Flask. Моя голова уже была в области SQL из-за необходимости настраивать инфраструктуру базы данных, поэтому я подумал, почему бы Postgres не позаботился и о работе по извлечению метаданных? Это была достойная схема для нескольких тысяч пользователей:

Суть базового процесса заключалась в следующем:

  1. Прокрутите каждого пользователя
  2. Запрос метаданных в базе данных Postgres по каждому рекомендованному альбому (4 присоединения)
  3. Запросить в базе данных Postgres метаданные в списке желаний и коллекции пользователя.
  4. Отформатируйте метаданные как 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 матрицу. Любой рекомендуемый альбом де-факто является членом этого набора, как и любой альбом в Списке желаний или Коллекции пользователя³.

Почему бы просто не объединить метаданные в этот главный список и отфильтровать эту большую таблицу для каждого пользователя?

Суть этого процесса заключалась в следующем:

  1. Создайте один большой DataFrame из всех альбомов, используемых для обучения модели, со связанными метаданными - с именем all_interactions в коде.
  2. Отфильтруйте all_interactions для списка желаний и коллекции пользователя, чтобы получить связанные метаданные для предыдущих взаимодействий.
  3. Отфильтруйте all_interactions альбомы, рекомендованные пользователем, чтобы получить соответствующие метаданные для этих рекомендаций.
  4. Отформатируйте метаданные как 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, внимательно обдумайте свой вариант использования!

В ближайшее время появятся новые сообщения, и мы снова увидим ссылки на само веб-приложение! Прокомментируйте любые улучшения, которые вы заметили, или понравилось ли вам это!

Также не стесняйтесь обращаться по адресу:

Гитхаб

Linkedin

[1] Не могу порекомендовать (ха) эту библиотеку в достаточном количестве. Большая часть его написана на Cython с поддержкой многопроцессорности, и интеграцией с графическим процессором CUDA.

[2] implicit делает это немного более удобным, интегрируя параметр recalculate_user в рекомендуемый метод класса модели ALS.

[3] Здесь я опускаю технические детали. Некоторые элементы, рекомендованные моделью, не попадут в окончательный список рекомендаций пользователя, если они настолько новые, что я еще не получил метаданные для этого недавно выпущенного альбома - внутреннее слияние при запросе базы данных приведет к удалению этого альбома. Discogs ежемесячно делает дамп базы данных всего своего архива, так что в худшем случае я отстану всего на месяц.

[4] Как видите, я использовал только 100 семплов в строках 5–6.

[5] Несколько хороших сообщений в StackOverflow о сложности индексирования Pandas: здесь, здесь, здесь и здесь.

[6] Примечание: как только я дедуплицировал release_ids. Исходный запрос к базе данных Postgres для получения метаданных альбома иногда приводит к дублированию записей для определенных альбомов, если у них более одного исполнителя. Например, этот разделенный EP Голди и Док Скотт будет иметь два идентификатора исполнителя, и поэтому при слиянии будет дублироваться строка для выпуска.