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

Примечание. У пятилетнего ребенка должно быть практическое знание Python!

Почему так много шума из-за проблемы с холодным запуском?

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

Это может быть довольно неудобно для вас как разработчика приложения, например, когда слушатели пытаются искать новые предложения подкастов для прослушивания, а вы получили - zip, zilch, nada, НИЧЕГО. Что ж, именно в этом заключалась проблема на начальных этапах разработки нашего подкаст-приложения Podurama. Посмотрим, как LightFM помог нам решить эту проблему…

Как LightFM помогает решить проблему холодного старта?

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

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

Проще говоря, LightFM может использовать обычное взаимодействие пользователя с элементом для прогнозирования известных пользователей. В случае n новых пользователей он может делать прогнозы, если ему известна дополнительная информация об этих новых пользователях. Эта дополнительная информация может включать такие характеристики, как пол, возраст, этническая принадлежность и т. Д., И должна передаваться в алгоритм во время обучения.

Давайте погрузимся в кодирование

Я собираюсь использовать (очень) небольшие фиктивные данные рейтингов и данные функций, потому что я хочу иметь возможность показывать промежуточные результаты на каждом этапе. Если хотите, то вот Блокнот Jupyter.

Фрейм данных рейтинга

Он содержит данные от трех пользователей - u1, u2, u3 и четырех элементов - i1, i2, i3, i4.

# create dummy dataset
data = {'user': ['u1','u1','u2','u2', 'u3', 'u3', 'u3'], 
        'item': ['i1', 'i3', 'i2', 'i3', 'i1', 'i4', 'i2'], 
        'r': [1,2,1,3,4,5,2]
       }
df = pd.DataFrame(data,  columns = ['user', 'item', 'r'])

Фреймворк пользовательских функций

У нас есть четыре дополнительных элемента информации о каждом пользователе - три логических функции - f1, f2, f3 и функция местоположения - loc, которая на данный момент может принимать два значения - Дели или Мумбаи.

#dummy user features
data = {'user': ['u1','u2','u3', 'loc'], 
        'f1': [1, 0, 1, 'del'], 
        'f2': [1, 1, 1, 'mum'],
        'f3': [0, 0, 1, 'del']
       }
features = pd.DataFrame(data,  columns = ['user', 'f1', 'f2', 'f3', 'loc'])

Создание набора данных, совместимого с LightFM

Если вы бегло просмотрели документацию LightFM, вы бы знали, что ему нравятся входные данные в определенном формате. И поэтому мы должны подчиниться.

from lightfm.data import Dataset
dataset1 = Dataset()

Вызов метода fit

Нам нужно вызвать метод fit, чтобы сообщить LightFM, кто пользователи, с какими элементами мы имеем дело, в дополнение к любым функциям пользователя / элемента.

Мы передадим методу fit три входа:

  • users: список всех пользователей
  • items: перечислить все элементы
  • user_features: список дополнительных пользовательских функций

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

Когда дело доходит до передачи user_features, я настоятельно рекомендую передать список, в котором каждый элемент имеет формат, подобный 'feature_name:feature_value' (обещаю, я объясню, почему я предпочитаю делать это таким образом, и есть ли другие альтернативы, но пока просто потерпите меня).

Это означает, что наш user_features должен выглядеть примерно так:
['f1:1', 'f1:0', 'f2:1', 'f3:0', 'f3:1', 'loc:mum', 'loc:del'].

Как вы правильно догадались, этот список был создан путем рассмотрения всех возможных feature_name,feature_value пар, которые могут встретиться в обучающем наборе. Например, для feature_name, равного loc, может быть два feature_values, а именно mum и del.

Я написал небольшой фрагмент кода, который позволяет мне создать такой список (я называю его uf):

uf = []
col = ['f1']*len(features.f1.unique()) + ['f2']*len(features.f2.unique()) + ['f3']*len(features.f3.unique()) + ['loc']*len(features['loc'].unique())
unique_f1 = list(features.f1.unique()) + list(features.f2.unique()) + list(features.f3.unique()) + list(features['loc'].unique())
#print('f1:', unique_f1)
for x,y in zip(col, unique_f1):
    res = str(x)+ ":" +str(y)
    uf.append(res)
    print(res)

Наконец, со всеми доступными частями давайте вызовем метод fit в нашем наборе данных:

# we call fit to supply userid, item id and user/item features
dataset1.fit(
        df['user'].unique(), # all the users
        df['item'].unique(), # all the items
        user_features = uf # additional user features
)

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

Построение взаимодействий

Входными данными метода build_interactions является итерация взаимодействий, где каждое взаимодействие представляет собой кортеж, содержащий три элемента:

  • Пользователь
  • элемент
  • веса взаимодействия (необязательно)

Веса взаимодействия просто означают, что если пользователь «u» взаимодействовал с элементом «i», насколько важным является это взаимодействие. С точки зрения нашего примера, вес - это рейтинг, который у нас есть для каждой пары (пользователь, элемент).

Другой случай, когда установка весов может быть полезна, - это когда мы имеем дело с данными взаимодействия (пользователя, песни). В таком случае я мог бы назначить более высокий вес тем взаимодействиям, в которых пользователь слушал более 3/4 песни.

# plugging in the interactions and their weights
(interactions, weights) = dataset1.build_interactions([(x[0], x[1], x[2]) for x in df.values ])

Таким образом, матрица interactions сообщает нам, взаимодействовал ли вообще пользователь с элементом, а матрица weights дает количественную оценку этого конкретного взаимодействия.

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

Если вы до сих пор со мной, то вам спасибо. Эта следующая часть чрезвычайно важна для понимания, поэтому вы можете реализовать ее и для своей собственной рекомендательной системы. (Интересный факт: значительная часть открытых проблем на странице LightFM Github каким-то образом связана с темой создания функций пользователя / элемента).

Создание пользовательских функций

Метод build_user_features требует ввода в следующем формате:
[
(user1, [feature1, feature2, feature3,….]),
(user2, [feature1, feature2, feature3,…. ]),
(user3, [feature1, feature2, feature3,….]),
.
.
]

Здесь следует помнить одну очень важную вещь: feature1, feature2, feature3 и т. д. должны быть одним из элементов, присутствующих в списке user_features, который мы передали методу fit в начале.

Повторюсь, наш список user_features в настоящее время выглядит так:
['f1:1', 'f1:0', 'f2:1', 'f3:0', 'f3:1', 'loc:mum', 'loc:del'].

Таким образом, для наших конкретных фиктивных данных ввод в build_user_features должен быть таким:

[
     ('u1', ['f1:1', 'f2:1', 'f3:0', 'loc:del']),
     ('u2', ['f1:0', 'f2:1', 'f3:0', 'loc:mum']),
     ('u3', ['f1:1', 'f2:1', 'f3:1', 'loc:del'])
 ]

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

# Helper function that takes the user features and converts them into the proper "feature:value" format
def feature_colon_value(my_list):
    """
    Takes as input a list and prepends the columns names to respective values in the list.
    For example: if my_list = [1,1,0,'del'],
    resultant output = ['f1:1', 'f2:1', 'f3:0', 'loc:del']
   
    """
    result = []
    ll = ['f1:','f2:', 'f3:', 'loc:']
    aa = my_list
    for x,y in zip(ll,aa):
        res = str(x) +""+ str(y)
        result.append(res)
    return result
# Using the helper function to generate user features in proper format for ALL users
ad_subset = features[["f1", 'f2','f3', 'loc']] 
ad_list = [list(x) for x in ad_subset.values]
feature_list = []
for item in ad_list:
    feature_list.append(feature_colon_value(item))
print(f'Final output: {feature_list}')

Наконец, мы должны связать каждый элемент feature_list с соответствующими идентификаторами пользователей.

user_tuple = list(zip(features.user, feature_list))

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

user_features = dataset1.build_user_features(user_tuple, normalize= False)

В матрице user_features, приведенной выше, строки - это пользователи, а столбцы - это пользовательские функции. 1 присутствует всякий раз, когда у этого пользователя есть эта конкретная пользовательская функция, присутствующая в данных обучения.

Всего мы видим 10 столбцов, что означает, что существует 10 пользовательских функций. Но почему 10, спросите вы, давайте посмотрим!

user_id_map, user_feature_map, item_id_map, item_feature_map = dataset1.mapping()
user_feature_map

Если вы посмотрите на вывод выше, станет ясно, почему у нас 10 пользовательских функций. По умолчанию идентификатор пользователя также является функцией, поэтому у нас есть три из них. Остальные семь должны быть вам знакомы, поскольку мы создали их в самом начале.

Время построить модель

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

model = LightFM(loss='warp')
model.fit(interactions,
      user_features= user_features,
      sample_weight= weights,
      epochs=10)

Печать оценки AUC

from lightfm.evaluation import auc_score
train_auc = auc_score(model,
                      interactions,
                      user_features=user_features
                     ).mean()
print('Hybrid training set AUC: %s' % train_auc)
Output: Hybrid training set AUC: 0.9166667

Повторяю, не стоит слишком волноваться из-за высоких значений AUC. Помните, что это просто фиктивные данные.

Прогнозы для ИЗВЕСТНЫХ пользователей

Метод predict принимает два входа:

  • отображение идентификатора пользователя (например: для получения прогнозов для «u1» необходимо передать 0; для «u2» пройти 1 и т. д.). Эти сопоставления доступны из user_id_map dictionary.
  • список идентификаторов элементов (опять же, не i1, i2, а отображение; доступно из item_id_map), для которых вы хотите получить рекомендации.
# predict for existing user
user_x = user_id_map['u3']
n_users, n_items = interactions.shape # no of users * no of items
model.predict(user_x, np.arange(n_items)) # means predict for all 
Output: array([-0.18600112, -0.91691172, -0.295421  , -0.06632421])

Прогнозы для НЕИЗВЕСТНЫХ пользователей

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

Для нового пользователя это то, что мы знаем - у него есть значения для f1, f2, f3 как 1,1 и 0 соответственно. Кроме того, их местонахождение - Дели.

user_feature_list = ['f1:1', 'f2:1', 'f3:0', 'loc:del']

Теперь мы не можем передать это напрямую методу predict. Мы должны преобразовать этот ввод в форму, понятную нашей модели lightFM.

В идеале входные данные должны выглядеть как одна из строк в матрице user_features (см. Рисунок 1 выше).

Я нашел в Stackoverflow фрагмент кода, который делает именно это - преобразует user_feature_list в требуемый формат (в нашем случае разреженная матрица с 10 столбцами). Я лишь немного изменил исходный код и заключил его в многоразовую функцию format_newuser_input здесь:

from scipy import sparse
def format_newuser_input(user_feature_map, user_feature_list):
  num_features = len(user_feature_list)
  normalised_val = 1.0 
  target_indices = []
  for feature in user_feature_list:
    try:
        target_indices.append(user_feature_map[feature])
    except KeyError:
        print("new user feature encountered '{}'".format(feature))
        pass

  new_user_features = np.zeros(len(user_feature_map.keys()))
  for i in target_indices:
    new_user_features[i] = normalised_val
  new_user_features = sparse.csr_matrix(new_user_features)
  return(new_user_features)

Наконец, мы можем начать предсказывать нового пользователя:

new_user_features = format_newuser_input(user_feature_map, user_feature_list)
model.predict(0, np.arange(n_items), user_features=new_user_features)

Здесь первый аргумент, то есть 0, больше не относится к сопоставленному идентификатору для пользователя u1. Вместо этого это означает - выберите первую строку разреженной new_user_features матрицы. Передача любого значения, отличного от 0, вызовет ошибку, и это правильно, поскольку в new_user_features нет строк за пределами row0.

Ура !! Мы сделали это. Наша система рекомендаций по фиктивному гибриду готова.

Другие способы создания функций пользователя / элемента

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

В качестве примера рассмотрим фрейм данных пользовательской функции, подобный приведенному ниже:

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

Если это так, мы можем просто передать user_featureslist формы [Asian, Hispanic, Female, Male, no, yes] в fit. Впоследствии мы должны также обновить способ создания наших пользовательских функций, и поэтому ввод в .build_user_features() должен быть таким:

[
     ('u1', ['Asian', 'Female', 'No']),
     ('u2', ['Hispanic', 'Male', 'Yes']),
     ('u3', ['Hispanic', 'Male', 'No'])
 ]

Выглядит отлично. Но теперь ответьте на это?

Что произойдет, если у меня будет одна дополнительная информация о каждом пользователе, то есть новый столбец «height_under_5ft», который может принимать значения «да» или «нет»?

В таких случаях может быть трудно отличить Да / Нет от возраста и Да / Нет от роста. Поэтому, чтобы избежать двусмысленности, было бы лучше использовать формат feature:value, о котором мы говорили в начале, и передать user_features = ['eth:Asian', 'eth:Hispanic', 'gender:Male', 'gender:Female', 'age:yes', 'age:no', 'height:yes', 'height:no] в fit, а следующий ввод в .build_usear_features()

[
     ('u1', ['eth:Asian', 'gender:Female', 'age:No', 'height:yes']),
     ('u2', ['eth:Hispanic', 'gender:Male', 'age:No', 'height:no'])
     ('u3', ['eth:Asian', 'gender:Female', 'age:No', 'height:yes'])
 ]

Конечная заметка

Есть много разных способов продвинуть это руководство вперед. Для начала попробуйте подключить свой реальный набор данных вместо этих фиктивных данных и посмотрите, насколько хорошо работает ваша рекомендательная система. Вы даже можете попробовать создать item_features, используя аналогичную методологию, которую мы обсуждали в этом руководстве.

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

До скорого :)

Чуть не забыл: «Если вам понравилась эта статья, возможно, вам понравится» (каламбур 😜)