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.
Осуществляем магию по созданию 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 представлен ниже
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 пройдет бесплатное занятие на котором расскажут про несколько классических подходов к построению рекомендательных систем и научат реализовывать один из них своими руками. Также преподаватели расскажут о готовых инструментах, которые позволяют создать рекомендашку всего в пару строк кода. Регистрация на бесплатный урок доступна вот по этой ссылке.