
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.

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

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

получаем значение для nDCG@10=0.4
Данное значение, чуть выше того, что получается у монстров на RecSys

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

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

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