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[:, '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'

Осуществляем магию по созданию 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=[]
  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 для каждого пользователя

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/

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

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

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

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

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

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


Продолжение статьи уже есть и ее можно прочитать.


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

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