newsmode
search
Меню
arrow_back Назад

Building a Strong Baseline Recommender in PyTorch, on a Laptop

auto_awesomeКраткое саммари

Юджин Ян (Ziyou Yan) описывает построение сильной базовой рекомендательной системы на PyTorch, работающей на обычном ноутбуке с 16 ГБ ОЗУ. В качестве данных используется датасет Amazon (май 1996 – июль 2014) от профессора Julian McAuley из UC San Diego — разделы электроники (420 тыс. товаров, 4 млн пар) и книг (1,95 млн товаров, 26,6 млн пар), причём данные крайне разрежены (99,99%). Вместо классической матричной факторизации в памяти автор делает её итеративно, пара за парой, с негативным сэмплированием, регуляризацией L2 и косинусным отжигом learning rate за 5 эпох. На бинарных метках достигается ROC-AUC 0,8083, на непрерывных — около 0,92–0,93, но кривые precision-recall показывают «обрыв смерти» около порога 0,5. Добавление смещения (bias) немного снижает AUC-ROC, но сглаживает кривые и делает модель более пригодной для продакшена. Главный вывод — нельзя оценивать модель только по числовым метрикам; графики кривых раскрывают её поведение гораздо лучше, и это лишь базлайн перед применением графовых и NLP-методов в следующей статье.

Построение сильной базовой рекомендательной системы на PyTorch, на ноутбуке

[ recsys deeplearning python 🛠 ] · 14 мин чтения

P.S. Ищете код к этой статье? Доступен на github: recsys-nlp-graph

Рекомендательные системы («recsys») — довольно старая тема, появившаяся ещё в 1990-х. Ключевая идея в том, что мы можем использовать мнения и поведение пользователей, чтобы предлагать и персонализировать для них релевантный и интересный контент.

Ещё одна публикация о стандартной item-user коллаборативной фильтрации вряд ли что-то добавила бы к сотням, если не тысячам, уже существующих статей.

К счастью, эта статья — не из таких.

Обзор

Поиск по теме рекомендательных систем выдаёт статьи о «коллаборативной фильтрации», «контентных методах», «матрице пользователь-товар» и тому подобном.

С тех пор появилось много прогресса в применении более новых методов для recsys. К сожалению, понятных статей или блогов о них крайне мало.

Поэтому в этой паре статей я расскажу о семи различных реализациях recsys.

Статьи охватывают весь путь от начала до конца: от получения и подготовки данных до (классической) матричной факторизации. Также рассматривается применение методов из теории графов и обработки естественного языка. Кроме того, будет обсуждаться сравнение результатов разных подходов на реальных данных (надеюсь, вы любите кривые обучения).

Получение данных

Мы не можем построить recsys без данных (впрочем, большинство систем машинного обучения вообще нельзя построить без данных).

К счастью, профессор Julian McAuley, доцент UC San Diego, собрал прекрасную коллекцию датасетов (например, отзывы о товарах, метаданные, социальные сети). В этой статье мы будем использовать датасет Amazon (май 1996 – июль 2014), а именно разделы электроники и книг.

Парсинг json

Все датасеты представлены в формате json, и их нужно распарсить в более удобный для работы формат (например, табличный). Некоторые из этих датасетов могут быть довольно большими: самый крупный содержит 142,8 млн строк и занимает 20 ГБ на диске.

Вот пример того, как выглядит json-запись для одного товара (нас интересует поле related).

{ "asin": "0000031852", "title": "Girls Ballet Tutu Zebra Hot Pink", "price": 3.17, "imUrl": "http://ecx.images-amazon.com/images/I/51fAmVkTbyL._SY300_.jpg", "related”: { "also_bought":[ "B00JHONN1S", "B002BZX8Z6", "B00D2K1M3O", ... "B007R2RM8W" ], "also_viewed":[ "B002BZX8Z6", "B00JHONN1S", "B008F0SU0Y", ... "B00BFXLZ8M" ], "bought_together":[ "B002BZX8Z6" ] }, "salesRank": { "Toys & Games":211836 }, "brand": "Coxlures", "categories":[ [ "Sports & Outdoors", "Other Sports", "Dance" ] ] }

Само собой, мы не сможем полностью загрузить это в оперативную память обычного ноутбука с 16 ГБ ОЗУ (который я использовал для этого упражнения).

Чтобы преобразовать json в csv, я проходил по json-файлу строка за строкой, конвертировал json в формат с разделением запятыми и записывал в CSV. (Примечание: для этого нужно заранее знать полный набор ключей json.)

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

Получение связей между товарами

Чтобы получить связи между товарами, нам нужны данные из поля «related». Оно содержит связи типа «also bought» (также покупали), «also viewed» (также смотрели), «bought together» (купили вместе) и так далее.

Извлечение этого требует немного дополнительной работы, поскольку оно хранится в словаре списков. Я уже упоминал, что это ещё и в строковом формате?

Чтобы получить связи, мы выполняем следующие шаги:

Вычислить (eval) строку и преобразовать её в формат словаря. Получить связанные идентификаторы товаров для каждой связи. Развернуть (explode) так, чтобы каждая пара товаров была отдельной строкой с одной связью.

В итоге у нас получается узкая таблица из трёх колонок: товар 1, товар 2 и связь (то есть also bought, also viewed, bought together). На этом этапе каждая пара товаров может иметь несколько связей между собой.

Вот пример:

product1 | product2 | relationship -------------------------------------- B001T9NUFS | B003AVEU6G | also_viewed 0000031895 | B002R0FA24 | also_viewed B007ZN5Y56 | B005C4Y4F6 | also_viewed 0000031909 | B00538F5OK | also_bought B00CYBULSO | B00B608000 | also_bought B004FOEEHC | B00D9C32NI | bought_together

Преобразование связей в единый скор

Теперь, когда у нас есть пары товаров и их связи, нам нужно преобразовать их в некий единый скор/вес.

Для этого упражнения я упрощу задачу и предположу, что связи двунаправленные (то есть симметричны по направлению). (Примечание: связи между парами товаров могут быть асимметричными; те, кто покупает телефоны, также покупают чехлы для телефонов, но не наоборот.)

Как нам преобразовать эти типы связей в числовые скоры?

Один простой способ — присвоить 1.0, если у пары товаров есть любая/несколько связей, и 0.0 в противном случае.

Однако это игнорировало бы различные типы связей — должна ли пара товаров со связью «also bought» иметь тот же скор, что и пара со связью «also viewed»?

Чтобы учесть это, веса были назначены в зависимости от связи (bought together = 1.2, also bought = 1.0, also viewed = 0.5) и просуммированы по всем уникальным парам товаров. В результате для каждой пары товаров получается единый числовой скор (пример ниже).

product1 | product2 | weight -------------------------------- B001T9NUFS | B003AVEU6G | 0.5 0000031895 | B002R0FA24 | 0.5 B007ZN5Y56 | B005C4Y4F6 | 0.5 0000031909 | B00538F5OK | 1.0 B00CYBULSO | B00B608000 | 1.0 B004FOEEHC | B00D9C32NI | 1.2

Для датасета электроники насчитывается 418 749 уникальных товаров и 4 005 262 пары товаров (разреженность = 4 млн / 420 тыс. ** 2 = 0.9999). Для книг у нас 1 948 370 уникальных товаров и 26 595 848 пар товаров (разреженность = 0.9999).

Да, данные для рекомендаций очень разрежены.

Разбиение на обучение и валидацию

Датасеты были случайным образом разделены на 2/3 для обучения и 1/3 для валидации. Это можно сделать с помощью sklearn.train_val_split или случайных индексов. Проще простого.

Готово, верно?

К сожалению, нет.

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

Создавать негативные сэмплы эффективным способом не так-то просто.

Предположим, у нас есть 1 миллион положительных пар товаров; чтобы создать валидационный набор с соотношением положительных и негативных пар 50:50, потребуется 1 миллион негативных сэмплов.

Если мы будем случайно (и наивно) сэмплировать из нашего пула товаров для создания этих негативных сэмплов, это означало бы случайное сэмплирование 2 миллиона раз. Вызов random столько раз занимает очень много времени — поверьте, я (наивно) это пробовал.

Чтобы обойти это, я применил такой хак:

Поместить множество товаров в массив. Перемешать массив.

На каждой итерации брать срез массива по номеру итерации × 2

Если итерация = 0, пара товаров = array[0: 0+2]. Если итерация = 1, пара товаров = array[2:, 2+2]. Если итерация = 10, пара товаров = array[20: 20+2]. Когда массив исчерпан, (пере)перемешать и повторить.

Это значительно сократило количество необходимых случайных операций и оказалось примерно в 100 раз быстрее наивного подхода.

Коллаборативная фильтрация (как её обычно называют)

Коллаборативная фильтрация использует информацию о поведении, активности или предпочтениях пользователей, чтобы предсказать, что понравится другим пользователям, на основе сходства товаров или пользователей. В отличие от неё, контентная фильтрация основана исключительно на метаданных товара (то есть бренде, цене, категории и т. д.).

Большинство статей о коллаборативной фильтрации посвящены коллаборативной фильтрации пользователь-товар (точнее, матричной факторизации). И там всегда присутствует (обязательная) матрица пользователь-товар (Рис. 1).

Рисунок 1. Клишированное изображение коллаборативной фильтрации

Однако если у нас нет данных о пользователях (как в нашем случае), может ли это всё равно работать?

Почему бы и нет?

Вместо матрицы пользователь-товар у нас есть матрица товар-товар. Затем мы обучаем латентные факторы для каждого товара и определяем, насколько сильно они связаны с другими товарами.

Один из способов сделать это — загрузить всю матрицу товар-товар (в память) и применить что-то вроде Python-библиотеки surprise или Alternating Least Squares из SparkML.

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

Наши датасеты очень разрежены (на 99,99% пусты). Можем ли мы воспользоваться этой разреженностью, чтобы сделать всё более ресурсоэффективным? (Подсказка: да, можем.)

Реализация 1: Матричная факторизация (итеративно, пара за парой)

Один из способов уменьшить потребление памяти — выполнять матричную факторизацию пара за парой, не загружая всё в память. Давайте обсудим, как реализовать это в PyTorch.

Сначала мы загружаем пары товаров (только пары, а не всю матрицу) в массив. Чтобы пройти по всем сэмплам, нам нужно лишь пройтись по массиву.

Далее нам нужно создать негативные сэмплы пар товаров. Вот подход, использованный для генерации негативных сэмплов:

Для каждой пары товаров (например, 001-002) мы берём товар слева (то есть 001) и объединяем его с n (например, пятью) негативными сэмплами товаров, образуя негативные пары товаров. Негативные сэмплы берутся из множества доступных товаров, причём вероятность того, что товар будет выбран, пропорциональна частоте его появления в обучающем наборе.

Таким образом, каждая (положительная) пара товаров (то есть 001-002) будет иметь во время обучения также пять связанных негативных пар товаров.

Непрерывные метки

Для каждой пары товаров у нас есть числовые скоры на основе некоторого взвешивания (bought together = 1.2, also bought = 1.0, also viewed = 0.5). Давайте сначала обучимся на этих непрерывных метках. Это похоже на работу с явными рейтингами.

Бинарные метки

Чтобы сделать это в бинарном случае (например, с неявной обратной связью), фактические скоры больше 0 преобразуются в 1. Затем добавляется финальный сигмоидный слой, чтобы преобразовать скор в значение между 0 и 1. Также мы используем функцию потерь вроде бинарной кросс-энтропии (BCE).

Регуляризация

Регуляризация — без сомнения, она ключевая в машинном обучении. Как нам её применить?

Один из подходов — добавление L2-регуляризации, чтобы веса эмбеддингов не росли слишком сильно. Мы делаем это, добавляя к суммарным потерям штраф sum(embedding.weights**2) * C (параметр регуляризации).

На высоком уровне для каждой пары:

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

Вот немного псевдокода о том, как работала бы итеративная матричная факторизация:

Расписание обучения

Для расписания обучения мы запускаем его на 5 эпох с косинусным отжигом (cosine annealing). В каждой эпохе learning rate начинается с высокого значения (0.01) и быстро падает до минимального, близкого к нулю, после чего сбрасывается к следующей эпохе (Рис. 2).

Рисунок 2. Расписание learning rate с косинусным отжигом

Результаты

Для меньшего датасета электроники (420 тыс. уникальных товаров с 4 млн пар) 5 эпох заняли 45 минут — кажется, не так уж плохо.

Обучаясь на бинарных метках (1 против 0), мы получаем ROC-AUC 0.8083. Неплохо, учитывая, что случайный выбор даёт ROC-AUC 0.5.

По кривой обучения (Рис. 3) ниже видно, что одной эпохи достаточно, чтобы достичь близкого к оптимальному ROC-AUC. Интересно, что каждый раз, когда learning rate сбрасывается, модели, похоже, приходится «переобучать» эмбеддинги с нуля (AUC-ROC падает почти до 0.5).

Рисунок 3. AUC-ROC по эпохам для матричной факторизации; каждый раз при сбросе learning rate модель словно «забывает», из-за чего AUC-ROC возвращается к ~0.5. Также одной эпохи, похоже, достаточно.

Однако если посмотреть на кривые precision-recall ниже (Рис. 4), видно, что примерно на 0.5 мы натыкаемся на «обрыв смерти». Если оценить порог чуть-чуть ниже, precision падает почти с 1.0 до 0.5; чуть выше — и recall становится плохим.

Рисунок 4. Кривые precision-recall для матричной факторизации (бинарные метки).

При использовании непрерывных меток производительность кажется значительно лучше (AUC-ROC = 0.9225), но мы видим тот же обрыв смерти (Рис. 5). Тем не менее это говорит о том, что явные (то есть шкальные) рейтинги могут работать лучше неявных.

Рисунок 5. Кривые precision-recall для матричной факторизации (непрерывные метки).

TL;DR: хотя для обучения мы используем непрерывные метки, выходной слой — это сигмоида, которая возвращает скоры от 0 до 1.


Зачем использовать непрерывные метки? Потому что они способны лучше различать силу связей между парами товаров. Вот разбор бинарных и непрерывных меток.

Бинарные метки: пара товаров имеет метку 1, если у них есть любая или несколько связей (например, also viewed, also bought, bought together); ноль в противном случае.

Это значит, что пара товаров, у которой есть только also viewed, получит ту же метку, что и пара товаров, у которой есть все три связи (то есть also viewed, also bought, bought together). Хотя этот подход может сказать нам, есть ли у пары товаров связь или нет, он не различает силу связи.

Непрерывные метки: чтобы различать силу связей, мы используем следующие метки (вместо 1 или 0) — bought together = 1.2, also bought = 1.0, also viewed = 0.5.

В результате бинарные метки имеют значения 0 или 1.0, тогда как непрерывные метки имеют значения 0, 0.5, 1.0, 1.2.


Независимо от типа метки, мы получаем предсказания через простую сигмоиду, и предсказания будут лежать в диапазоне от 0 до 1. (См. код для матричной факторизации на бинарных метках и непрерывных метках.) Тем не менее мы используем функцию потерь бинарной кросс-энтропии для бинарных меток и среднеквадратичной ошибки для непрерывных меток.

Поскольку финальный слой и для бинарных, и для непрерывных меток — это сигмоида, мы можем использовать ROC AUC для обоих случаев, что делает результаты обоих подходов сопоставимыми.

Эта базовая модель улавливает только связи между отдельными товарами. Но что, если товар в целом популярен или непопулярен?

Мы можем добавить смещения (bias). С точки зрения реализации это одно число для каждого товара.

Реализация 2: Матричная факторизация со смещением

Если смотреть только на метрику AUC-ROC, добавление смещения, похоже, не помогает: AUC-ROC снижается с 0.8083 до 0.7951 на бинарных метках и с 0.9335 до 0.8319 на непрерывных метках.

Однако если рассмотреть кривые precision-recall, добавление смещения уменьшает крутизну кривых в точке их пересечения, делая модель более пригодной для продакшена (то есть для запуска в продакшен). Вот кривые для бинарных (Рис. 6) и непрерывных меток (Рис. 7).

Рисунок 6. Кривые precision-recall для матричной факторизации + смещение (бинарные метки).

Рисунок 7. Кривые precision-recall для матричной факторизации + смещение (непрерывные метки).

Может показаться тревожным, что AUC-ROC на непрерывных метках резко падает (с 0.9335 до 0.8319), но не стоит этого пугаться — это артефакт того, как измеряется AUC-ROC.

На ROC-кривой для непрерывных меток без смещения (Рис. 8a) ложные срабатывания начинают появляться только примерно после того, как идентифицировано около 0.75 истинных срабатываний. За порогом 0.5 вводятся все ложные срабатывания (то есть обрыв смерти кривой precision на Рис. 5). ROC-кривая и метрика AUC-ROC делают это не очень заметным, и AUC-ROC кажется значительно лучше (хотя на самом деле это не так).

Рисунок 8a (слева) и 8b (справа). ROC-кривые со смещением и без него.

На большем датасете книг 5 эпох заняли около 22 часов — этот подход, похоже, масштабируется более чем линейно в зависимости от количества пар товаров (то есть масштабируется плохо). К сожалению, AUC-ROC во время обучения остаётся около 0.5.

Ключевые выводы

Подход (итеративной) матричной факторизации, похоже, неплохо справляется в роли базлайна, достигая приличного AUC-ROC около 0.8.

Хотя базовый подход (без смещения) работает хорошо, он страдает от резких обрывов на кривой precision. Добавление смещения значительно улучшает это.

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

Что дальше?

Постойте, разве это всё ещё не матричная факторизация, пусть и в итеративной, менее памятоёмкой форме? Где же подходы с графами и NLP? Ты меня обманул!

Да, текущая статья охватывает только базлайн, пусть и в обновлённой форме.

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

В следующей статье мы применим методы графов и NLP к задаче рекомендательных систем.

P.S. Ищете код к этой статье? Доступен на github: recsys-nlp-graph

Источники

McAuley, J., Targett, C., Shi, Q., & Van Den Hengel, A. (2015, August). Image-based recommendations on styles and substitutes. In Proceedings of the 38th International ACM SIGIR Conference on Research and Development in Information Retrieval (pp. 43-52). ACM.

Если эта статья оказалась полезной, пожалуйста, цитируйте её так:

Yan, Ziyou. (Jan 2020). Building a Strong Baseline Recommender in PyTorch, on a Laptop. eugeneyan.com. https://eugeneyan.com/writing/recommender-systems-baseline-pytorch/.

или

@article{yan2020matrix, title = {Building a Strong Baseline Recommender in PyTorch, on a Laptop}, author = {Yan, Ziyou}, journal = {eugeneyan.com}, year = {2020}, month = {Jan}, url = {https://eugeneyan.com/writing/recommender-systems-baseline-pytorch/} }



Присоединяйтесь к 11 800+ читателям, получающим обновления о машинном обучении, RecSys, LLM и инженерии.