Рекомендательная система торговой сети самообслуживания

Hello Habr! Давно хотел это сказать.
Два слова о себе. Меня зовут Владислав Лещинский, два года назад, шагнул к своей мечте — овладению DataScience. Давно к этому шел, любил математику в школе, помнил все константы по физике и брал легкие интегралы на логарифмической линейке в уме, учился на инженера, анализировал по старинке, в экселе.
А потом случился бум больших данных и все все все…

Эта статья, некоторый итог, моего погружения в стихию DS и ML.
В рамках курса OTUS «Machine Learning. Advanced» я изучил в несколько любопытных направлений анализа с использованием машинного обучения. Когда настало время подготовки проектной работы, глаза разбегались, но я остановил свое внимание на рекомендательных системах.

знал бы я тогда, к чему это приведет.

здесь мог бы быть спойлер

Целью работы было создать модель, которая сможет предсказывать товары (в нашем случае топ 10), которые будут наиболее интересными и актуальными для покупки, конкретным покупателем. Данными для модели будет информация о покупках, совершенных им самим или «похожими на него» другими покупателями ранее. Поэтому обучение производится на данных «из прошлого», а проверка качества модели на данных «из настоящего».
Язык разработки Python, среда разработки Google Colab.

Исходные данные

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

умное приложение к умному холодильнику
умное приложение к умному холодильнику

Датасет был «живым», что не могло не порадовать меня как исследователя. Конечно я знал, что в моем случае задача отличалась от большинства учебных (Netflix, Spotify, Google и Яндекса), и состояла в необходимости прогнозировать и рекомендовать товары из числа тех, которые покупаются буквально каждый день повторно и в сочетании с другими, но, признаюсь, далеко идущие из этого последствия я еще не осознавал.

Поехали, загрузка…

загруженные данные
загруженные данные

Date — дата покупки
Month — месяц покупки
Hour — час покупки
Minute — минута покупки
Week_day — день недели
Shop — код торговой точки
Cat_id — категория товара
User_str — идентификатор покупателя
Prod_id — идентификатор товара
Price — цена товара

Разведочный анализ

Распределение числа покупателей по годам
Распределение числа покупателей по годам

На графике видно, что за 2019 — год начало тестирования системы, покупателей в системе практически не было (видимо то что мы видим это тестовые покупки), поэтому посчитал целесообразным исключить из массива данные за 2019 год. Для такой фильтрации для добавил поле «год» выделив его средствами Pandas из поля с датой покупки.

re_all['Year']=re_all['Date'].astype('datetime64[ns]').dt.year

Одновременно с фильтрацией по годам, я провел фильтрацию по торговым точкам — локациям покупки, с учетом анализа (рисунок ниже) и ценам продуктов, отсекая малозначимые (видимо тестовые) покупки.

распределение покупок по торговым точкам
распределение покупок по торговым точкам

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

re_date_lite=re_all[(re_all['Year']>2019)&(re_all['price']>10)]
shop_lite=list(shop_count[shop_count['prod_id']>200]['shop'].values)
re_all_lite=re_date_lite.loc[re_date_lite['shop'].isin(shop_lite)]

Выбор целевой переменной

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

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

Обсудим эти варианты.
Итак, допустим очередность, с которой мы кладем товары в корзину или выбираем в приложении является неотъемлемой и неизменной нашей привычкой — атрибутом поведения и в понедельник, и в четверг и через месяц? Но встаньте на минуту на место этого покупателя, ведь как бывает: вдруг отвлекли или товар раскупили, ну или хочется «чего-то сладенького», в такие дни. В общем не регулярненько как-то. Поэтому, несмотря на начальную привлекательность этой гипотезы (так и не проверенной мною, но может быть кто-то возьмется?), я склонился ко второму предположению, как более надежному.
Действительно, сам факт попадания товара в корзину, является более четким показателем осознанного потребления и если это не раз, а много раз, то тем более. Конечно, можно предложить и третье — добавить в приложение рейтингование, но когда это еще будет, а проект горит.
В коде, представленном ниже осуществляется формирование ранга товара («rank») , как функции от числа встречаемости данного товара в транзакциях покупки конкретного покупателя. Наиболее часто покупаемым товарам, присваивается ранг 1. Ранг для товаров имеющих одинаковое число покупок — также одинаковый.

re_user_prod=re_all_lite.groupby(['user_str','prod_id'])['cat_id'].count().reset_index()
re_user_prod.columns=['user_str','prod_id','purchase']
re_user_prod_sorted=re_user_prod.sort_values(['user_str','purchase'],ascending=False).copy() data_nums=re_user_prod_sorted[['user_str','prod_id','purchase']].values
n=1
count_row_bay=[]
count_bay=[] user_str=data_nums[0][0]
prod_id=data_nums[0][1]
bays= data_nums[0][2]
count_bay.append(n)
count_row_bay.append(count_bay)
for row in data_nums[1:]: if (row[0]==user_str): n=n+1 user_str=row[0] prod_id=row[1] bays= row[2] count_bay.append(n) else: n=1 user_str=row[0] prod_id=row[1] bays= row[2] count_bay.append(n)
#re_user_prod_sorted.loc[:, 'purch_count'] =count_bay
re_user_prod_sorted.loc[:, 'rank'] =count_bay re_user_prod_sorted.to_csv('/content/drive/MyDrive/re_user_prod_sorted.csv',index=False)

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

re_user_prod_sorted_lite=re_merg[re_merg['rank']<11]

Данные готовы к построению модели.

Формирование train и test датасетов

Подготовив данные, разбиваем исходный массив на train (re_work) и test (re_valid) датасеты. Как я уже обращал внимание ранее — разбиение осуществляется для упорядоченных по временной оси данных.

re_user_prod_sorted_lite=pd.read_csv('/content/drive/MyDrive/re_user_prod_sorted_lite.csv',sep=',')
ind=re_user_prod_sorted_lite.index.values
idx=int(len(ind)*0.8)
re_work=re_user_prod_sorted_lite[:idx].copy()
re_work.to_csv('/content/drive/MyDrive/re_work.csv',index=False)
re_valid=re_user_prod_sorted_lite[idx:].copy()
re_valid.to_csv('/content/drive/MyDrive/re_valid.csv',index=False)

Данные разделены в пропорции 8 к 2, как дань уважения, к Парето.

Построение модели для рекомендаций с использованием LightFM

Для построение модели, я решил использовать LigthFM, как широко распространенный пакет «из коробки» для построения рекомендаций. В работах, например тут и тут, дано очень детальное представление о возможностях этой библиотеки и последовательности подготовки и обработки данных.

re_baseline=re_work[['user_str','prod_id','rank']]
re_baseline['rank_baseline']=11-re_baseline['rank']
re_baseline.describe()

Поскольку LightFM традиционно работает с рейтингами, где максимальное значение соответствует большему предпочтению, введением переменой ‘rank_baseline’, преобразуем исходное значений ранга в рейтинг простым его вычитанием из 11.

Характеристики полученной переменной 'rank_baseline'
Характеристики полученной переменной ‘rank_baseline’

Осуществляем магию по созданию sparse.matrix. Для модели используем в качестве функции потерь warp функцию.

X_topic_pivot=re_baseline.pivot_table(index = 'user_str', values = 'rank_baseline', columns='prod_id', aggfunc = {'rank_baseline':'mean'}, margins = True, fill_value=0)
data_pivot=X_topic_pivot.reset_index()
pivot_train_np=data_pivot.to_numpy()
data_ds=pivot_train_np[:-1,1:-1].astype('int')
sData=sparse.csr_matrix(data_ds)
X_train_pivot, X_test_pivot=cross_validation.random_train_test_split(sData, test_percentage=0.2, random_state=None) modelFM = LightFM(no_components=100,loss = 'warp')
modelFM.fit(X_train_pivot, epochs=1000, num_threads=2)

Проверка модели на тестовых данных

Метрики качества, предлагаемые разработчиками библиотеки дают следующие значения.

k=10
test_recall = recall_at_k(modelFM, sData, k=k).mean()
test_precision = precision_at_k(modelFM, sData, k=k).mean()
print(test_precision,test_recall)

Precision= 0.27
Recall= 0.67

О метриках оценки для рекомендательных систем

В указанной выше работе приводятся распространенные метрики оценивания качества рекомендательных систем. В частности это:

MAP@k рассчитываемая по формулам:

и nDCG@k

Я дополнил свой код функцией расчета MAP@k. Конечно, в сети довольно много других реализаций этой метрики, но решил сделать свою :

def map_k(df_for_model): users=df_for_model['user_str'].unique() map=[]
# kcount=0 for user in users[:]: ksum=0 kcount=1 Valid_user=df_for_model[df_for_model['user_str']==user] ap=[] ap.append(user) nsum=0 for m in range(1,11): Valid=Valid_user[Valid_user['rank']==m] n=0 for i in Valid['pred_rang']: if i==m: n=n+1 if len(Valid)>0: n=n/len(Valid) if n>0: nsum=nsum+n ap.append(nsum/m) else: ap.append(0) mapu=0 f=list(ap[1:]) kcount=0 for i in f: if i>0: kcount=kcount+1 if sum(f)>0: mapu=sum(f)/kcount else: mapu=0 #print(f,mapu,kcount) ap.append(mapu) map.append(ap)
#print(map) map_df=pd.DataFrame(map,columns=['user_str','1','2','3','4','5','6','7','8','9','10','ap10']) return map_df,map_df['ap10'].mean()

расчет метрик

MAP@10

«Распотрошив» предикт, выполненный моделью для рангов

pred=modelFM.predict_rank(sData)
ar=pred.toarray()
predict=[]
for i in range(ar.shape[0]): for m in range(ar.shape[1]): ar_list=[] if (ar[i,m]>0)&(ar[i,m]<11): ar_list.append(timer_ids[i]) ar_list.append(prod_ids[m]) ar_list.append(ar[i,m]) predict.append(ar_list)
predict_df=pd.DataFrame(predict,columns=['user_str','prod_id','pred_rang'])
predict_df.head()

сгруппировал данные для сравнения реальных и предсказанных данных в Pandas датафрейм

и рассчитал значение map10_LFM= 0.18

ap_df,map10_LFM=map_k(re_valid_LFM)
print('map10_LFM=',map10_LFM)

Фрагмент матрицы AP представлен ниже

AP@10 для каждого пользователя
AP@10 для каждого пользователя

nDCG@10

Для расчета nDCG@10 я воспользовался встроенной функцией от sklearn.metrics

from sklearn.metrics import ndcg_score 
все метрики в одном месте
все метрики в одном месте

получаем значение для nDCG@10=0.4

Данное значение, чуть выше того, что получается у монстров на RecSys

взято из https://habr.com/ru/company/avito/blog/439206/
взято из https://habr.com/ru/company/avito/blog/439206/

но, думаю это не должно было меня успокаивать, поскольку сложность их задач несомненно выше.

Признаюсь, полученные мною результаты по MAP@10 и nDCG@10 разочаровали меня. Когда я начинал исследования мне рисовались значения если не под 0.90, то уж точно не меньше 0.3, как же наивен я был.
Можно ли доверять рекомендациям, которые получаются из данной модели, например этим (представлены рекомендации только 5 покупок, для компактности отображения)

Полученный мною результат серьезно пошатнул мое представление о прекрасном DS и в том числе уронил планку уровня моих новоприобретенных знаний, эффект Даннига-Крюгера налицо, «Пик глупости», покорен.

В отчаянии, но не сломленный окончательно я пошел думать, можно ли что-то улучшить.

Во второй части, Вы узнаете, удалось ли мне улучшить модель и если да, то как…

P.S. Эта моя первая публикация на habr и первая по DS, все замечания, предложения и вопросы, конечно же жду с замиранием сердца дебютанта.


Рекомендательная рекомендация

Рекомендательные системы сегодня встречаются повсеместно: рекомендация фильмов и музыки, персональное формирование ленты в социалных сетях, предложения онлайн магазинов и многие другие. Но знаете ли вы как они устроены и какие алгоритмы скрываются под их капотом? 10 февраля в OTUS пройдет бесплатное занятие на котором расскажут про несколько классических подходов к построению рекомендательных систем и научат реализовывать один из них своими руками. Также преподаватели расскажут о готовых инструментах, которые позволяют создать рекомендашку всего в пару строк кода. Регистрация на бесплатный урок доступна вот по этой ссылке.

Читайте так же:

  • Джон Мюллер: SEO не всегда может помочь сайту улучшить позицииДжон Мюллер: SEO не всегда может помочь сайту улучшить позиции Сотрудник Google Джон Мюллер заявил в Reddit, что иногда нет такого SEO-решения. Которое бы заставило сайт ранжироваться лучше. Мюллер написал об этом в обсуждении на тему, стоит ли нанимать SEO-эксперта. Чтобы он выявил проблемы сайта и помог улучшить его позиции в результатах поиска. […]
  • YouTube начал использовать автоматически сгенерированные главы в поискеYouTube начал использовать автоматически сгенерированные главы в поиске YouTube начал использовать автоматически генерируемые видеоглавы в качестве источника метаданных для поиска. Об этом сообщается в новом видео на канале Creator Insider. Раньше для ранжирования результатов поиска использовались только главы, добавленные вручную. Обновление запускается в […]
  • Андрей Рогозов присоединился к The Open NetworkАндрей Рогозов присоединился к The Open Network Пока в России ходят слухи, правда ли, что запрет криптовалют — это повышение индекса цитирования в СМИ или нет, бывший глава VK Андрей Рогозов присоединился к проекту  TON (The Open Network), работающем на блокчейн-технологии.Рогозов присоединился к команде TON Foundation и будет […]
  • Борьба с прокрастинацией, бесплатный курс по вёрстке и работа для джунов. Мероприятия HTML Academy в октябреБорьба с прокрастинацией, бесплатный курс по вёрстке и работа для джунов. Мероприятия HTML Academy в октябре Подумаешь — написать анонс мероприятий, что там делать вообще. Но я откладывал его как мог. Всю прошлую неделю делал другие статьи, потом подождал все выходные, а теперь вместо утра, чтобы выложить пораньше, пишу анонс в обед. Увы, прокрастинация порой накатывает даже там, где её не […]