!pip install lightfm -qqq
from lightfm import LightFM
from lightfm.data import Dataset as LFMDataset
import numpy as np
import pandas as pd
import scipy.sparse as sparse
from scipy.sparse import csr_matrix, diags
from scipy.sparse.linalg import svds
from tqdm import tqdm
Контентные методы рекомендаций¶
¶
LightFM¶
LightFM
это гибридный алгоритм рекомендаций, который сочетает в себе методыколлаборативной фильтрации
и контентной фильтрации.- Эмбеддинги пользователей и эмбеддинги объектов можно обучать как с использованием их признаков, так и без.
- Если признаки используются, то эмбеддинги пользователей и объектов представляют собой сумму векторов их признаков (включая id как признак).
df = pd.read_csv('/kaggle/input/mtc-data/mts_lib.csv')
df.head()
user_id | item_id | progress | rating | start_date | |
---|---|---|---|---|---|
0 | 126706 | 14433 | 80 | NaN | 2018-01-01 |
1 | 127290 | 140952 | 58 | NaN | 2018-01-01 |
2 | 66991 | 198453 | 89 | NaN | 2018-01-01 |
3 | 46791 | 83486 | 23 | 5.0 | 2018-01-01 |
4 | 79313 | 188770 | 88 | 5.0 | 2018-01-01 |
def get_data_info(data,
user_id='user_id',
item_id='item_id'):
print(f'Размер датасета = {data.shape[0]} \nколичество пользователей = {data[user_id].nunique()} \nколичество объектов = {data[item_id].nunique()}')
# конвертируем в дату
df.loc[:, 'start_date'] = pd.to_datetime(df['start_date'],
format="%Y-%m-%d")
# удаляем дубликаты, оставляя последний по времени
df = df.sort_values('start_date').drop_duplicates(subset=['user_id', 'item_id'],
keep='last')
get_data_info(df)
Размер датасета = 1532998 количество пользователей = 151600 количество объектов = 59599
df.progress.value_counts()
progress 100 228230 0 200915 99 56710 1 48356 2 33917 ... 74 8427 79 8426 76 8407 58 8316 63 8267 Name: count, Length: 101, dtype: int64
# remove user processes less than 30
df = df[df['progress'] > 30]
def filter_data(df, user_count=5, item_count=5):
# select users who have selected [item_count] or more
user_counts = df.groupby('user_id')['item_id'].count()
pop_users = user_counts[user_counts >= item_count]
df = df[df['user_id'].isin(pop_users.index)].copy()
return df
df = filter_data(df)
get_data_info(df)
Размер датасета = 654819 количество пользователей = 64955 количество объектов = 59485
Чтение Фич Пользователей, Товара¶
К данным df
мы можем подтянуть дополнительную информацию о пользователе (user_id
) и товара (item_id
)
u_features = pd.read_csv('/kaggle/input/mtc-data/users.csv')
i_features = pd.read_csv('/kaggle/input/mtc-data/items.csv')
i_features.rename(columns={'id': 'item_id'}, inplace=True)
Удостоверимся что новые данные содержать только пользователей и товары которые есть в df
# make sure the feature are present in rating dataframe df
i_features = i_features[i_features['item_id'].isin(df['item_id'])].copy()
u_features = u_features[u_features['user_id'].isin(df['user_id'])].copy()
Создадим мапперы для user2id
и item2id
user_idx = df['user_id'].astype('category').cat.codes
item_idx = df['item_id'].astype('category').cat.codes
user2id = dict(zip(df['user_id'], user_idx))
item2id = dict(zip(df['item_id'], item_idx))
# user features
u_features.head()
user_id | age | sex | |
---|---|---|---|
0 | 1 | 45_54 | NaN |
2 | 3 | 65_inf | 0.0 |
10 | 11 | 55_64 | 0.0 |
11 | 12 | 55_64 | 1.0 |
12 | 13 | 25_34 | 0.0 |
# item features
i_features.head()
item_id | title | genres | authors | year | |
---|---|---|---|---|---|
0 | 128115 | Ворон-челобитчик | Зарубежные детские книги,Сказки,Зарубежная кла... | Михаил Салтыков-Щедрин | 1886 |
1 | 210979 | Скрипка Ротшильда | Классическая проза,Литература 19 века,Русская ... | Антон Чехов | 1894 |
2 | 95632 | Испорченные дети | Зарубежная классика,Классическая проза,Литерат... | Михаил Салтыков-Щедрин | 1869 |
3 | 247906 | Странный человек | Пьесы и драматургия,Литература 19 века | Михаил Лермонтов | 1831 |
4 | 294280 | Господа ташкентцы | Зарубежная классика,Классическая проза,Литерат... | Михаил Салтыков-Щедрин | 1873 |
u_features['age'].value_counts()
age 18_24 21396 25_34 12207 35_44 7422 55_64 7256 45_54 6186 65_inf 4016 Name: count, dtype: int64
Разбиение на подвыборки¶
Стандартная практика разбиение данных на обучаюшию и тестовую выборку
def train_test_split(X, user_col, time_col):
full_history = X.sort_values([user_col, time_col]).groupby(user_col)
test = full_history.tail(1)
train = full_history.head(-1)
return train, test
train, test = train_test_split(df, 'user_id', 'start_date')
u_features.head()
user_id | age | sex | |
---|---|---|---|
0 | 1 | 45_54 | NaN |
2 | 3 | 65_inf | 0.0 |
10 | 11 | 55_64 | 0.0 |
11 | 12 | 55_64 | 1.0 |
12 | 13 | 25_34 | 0.0 |
Сгуппируем название фичей и их значение и поместим их в один вектор u_features_list
# set index as user_id
u_features.set_index('user_id', inplace=True)
# create feature name & value
u_features_list = u_features.apply(
lambda feature_values: [f'{feature}_{feature_values[feature]}'
for feature in feature_values.index
if not pd.isna(feature_values[feature])],
axis=1
)
u_features_list = u_features_list.rename('features')
u_features_list
user_id 1 [age_45_54] 3 [age_65_inf, sex_0.0] 11 [age_55_64, sex_0.0] 12 [age_55_64, sex_1.0] 13 [age_25_34, sex_0.0] ... 159603 [age_18_24, sex_1.0] 159605 [age_18_24, sex_0.0] 159606 [age_25_34, sex_0.0] 159607 [age_25_34] 159610 [age_35_44, sex_0.0] Name: features, Length: 58529, dtype: object
Все уникальные варианты в этом векторе
# all unique feature name & value
user_tags = set(u_features_list.explode().dropna().values)
user_tags
{'age_18_24', 'age_25_34', 'age_35_44', 'age_45_54', 'age_55_64', 'age_65_inf', 'sex_0.0', 'sex_1.0'}
(B) Фичи предметов¶
Возмем только топ 50 жанров
# book genre
i_features['genres']
0 Зарубежные детские книги,Сказки,Зарубежная кла... 1 Классическая проза,Литература 19 века,Русская ... 2 Зарубежная классика,Классическая проза,Литерат... 3 Пьесы и драматургия,Литература 19 века 4 Зарубежная классика,Классическая проза,Литерат... ... 59594 Политология,Книги по экономике,Газеты 59595 Политология,Книги по экономике,Газеты 59596 Политология,Общая история,Газеты 59597 Журнальные издания 59598 Журнальные издания,Энциклопедии,Научная фантас... Name: genres, Length: 59485, dtype: object
i_features_lfm = i_features.copy()
i_features_lfm.set_index('item_id', inplace=True)
i_features_lfm.head()
title | genres | authors | year | |
---|---|---|---|---|
item_id | ||||
128115 | Ворон-челобитчик | Зарубежные детские книги,Сказки,Зарубежная кла... | Михаил Салтыков-Щедрин | 1886 |
210979 | Скрипка Ротшильда | Классическая проза,Литература 19 века,Русская ... | Антон Чехов | 1894 |
95632 | Испорченные дети | Зарубежная классика,Классическая проза,Литерат... | Михаил Салтыков-Щедрин | 1869 |
247906 | Странный человек | Пьесы и драматургия,Литература 19 века | Михаил Лермонтов | 1831 |
294280 | Господа ташкентцы | Зарубежная классика,Классическая проза,Литерат... | Михаил Салтыков-Щедрин | 1873 |
# посчитаем количество рвз книга была прочитана
i_features_lfm['reads'] = df.groupby('item_id')['user_id'].count()
i_features_lfm.head()
title | genres | authors | year | reads | |
---|---|---|---|---|---|
item_id | |||||
128115 | Ворон-челобитчик | Зарубежные детские книги,Сказки,Зарубежная кла... | Михаил Салтыков-Щедрин | 1886 | 11 |
210979 | Скрипка Ротшильда | Классическая проза,Литература 19 века,Русская ... | Антон Чехов | 1894 | 87 |
95632 | Испорченные дети | Зарубежная классика,Классическая проза,Литерат... | Михаил Салтыков-Щедрин | 1869 | 5 |
247906 | Странный человек | Пьесы и драматургия,Литература 19 века | Михаил Лермонтов | 1831 | 6 |
294280 | Господа ташкентцы | Зарубежная классика,Классическая проза,Литерат... | Михаил Салтыков-Щедрин | 1873 | 7 |
# genre features (list)
i_features_lfm['genres'] = i_features_lfm['genres'].str.lower().str.split(',')
i_features_lfm['genres'] = i_features_lfm['genres'].apply(lambda x: x if isinstance(x, list) else [])
i_features_lfm[['genres','reads']].head()
genres | reads | |
---|---|---|
item_id | ||
128115 | [зарубежные детские книги, сказки, зарубежная ... | 11 |
210979 | [классическая проза, литература 19 века, русск... | 87 |
95632 | [зарубежная классика, классическая проза, лите... | 5 |
247906 | [пьесы и драматургия, литература 19 века] | 6 |
294280 | [зарубежная классика, классическая проза, лите... | 7 |
i_features_lfm[['genres','reads']].explode('genres')
genres | reads | |
---|---|---|
item_id | ||
128115 | зарубежные детские книги | 11 |
128115 | сказки | 11 |
128115 | зарубежная классика | 11 |
128115 | литература 19 века | 11 |
128115 | русская классика | 11 |
... | ... | ... |
125582 | газеты | 10 |
33188 | журнальные издания | 5 |
65317 | журнальные издания | 4 |
65317 | энциклопедии | 4 |
65317 | научная фантастика | 4 |
126510 rows × 2 columns
# genre based read count
genres_count = i_features_lfm[['genres','reads']].explode('genres').groupby('genres')['reads'].sum()
genres_count.sort_values(ascending=False)
genres любовное фэнтези 72008 попаданцы 52832 современные любовные романы 49248 современные детективы 46660 героическое фэнтези 40794 ... литература 7 класс 3 экономическая статистика 2 воздушный транспорт 2 научно-практические журналы 2 математика 3 класс 1 Name: reads, Length: 640, dtype: int64
# top 50 genres by read ammount; get their index in dataframe
item_tags = genres_count.sort_values(ascending=False)[:50].index
list(item_tags[:10])
['любовное фэнтези', 'попаданцы', 'современные любовные романы', 'современные детективы', 'героическое фэнтези', 'современная русская литература', 'боевая фантастика', 'зарубежные любовные романы', 'боевое фэнтези', 'эротические романы']
# filter item dataframe; select only top 50 genres
def filter_genres(genres_list, valid_genres=None):
if not genres_list:
return []
return [genre for genre in genres_list if genre in valid_genres]
# filter genres
i_features_lfm['features'] = i_features_lfm['genres'].apply(filter_genres,
valid_genres=set(item_tags))
i_features_list = i_features_lfm['features']
i_features_lfm.head()
title | genres | authors | year | reads | features | |
---|---|---|---|---|---|---|
item_id | ||||||
128115 | Ворон-челобитчик | [зарубежные детские книги, сказки, зарубежная ... | Михаил Салтыков-Щедрин | 1886 | 11 | [зарубежная классика, литература 19 века, русс... |
210979 | Скрипка Ротшильда | [классическая проза, литература 19 века, русск... | Антон Чехов | 1894 | 87 | [литература 19 века, русская классика] |
95632 | Испорченные дети | [зарубежная классика, классическая проза, лите... | Михаил Салтыков-Щедрин | 1869 | 5 | [зарубежная классика, литература 19 века, русс... |
247906 | Странный человек | [пьесы и драматургия, литература 19 века] | Михаил Лермонтов | 1831 | 6 | [литература 19 века] |
294280 | Господа ташкентцы | [зарубежная классика, классическая проза, лите... | Михаил Салтыков-Щедрин | 1873 | 7 | [зарубежная классика, литература 19 века, русс... |
¶
Для того чтобы сделать предсказание, условно нужно сделать 3 шага перед predict
- (a) Построение матрицу взаимодействии
.fit_partial()
для LightFM датасет - (b) Построение признаков пользователей и объектов
build_item_features()
для LightFM датасет - (с) Добавляем интеракции в LightFM датасет
build_interactions()
"""
(A) Построем матрицу взимодействий
- LightFM работает с sparse формате
- Датасе конвертирует данные в sparse формат
- Матрица взаимодействий - матрица размером (количество пользователей,количество объектов)
"""
lfm_dataset = LFMDataset()
# Список уникальных пользователей и предметов
print('user_id',df['user_id'].unique()[:5]) # (1)
print('item_id',df['item_id'].unique()[:5]) # (2)
user_id [47427 99355 55263 58868 40184] item_id [ 46915 249281 80651 164458 128111]
# (4) список уникальных фичи пред0-дбь зщжюметов
list(item_tags[:10])
['любовное фэнтези', 'попаданцы', 'современные любовные романы', 'современные детективы', 'героическое фэнтези', 'современная русская литература', 'боевая фантастика', 'зарубежные любовные романы', 'боевое фэнтези', 'эротические романы']
# set unique users & items
lfm_dataset.fit_partial(users=df['user_id'].unique(), # (1)
items=df['item_id'].unique()) # (2)
# user features & item features
lfm_dataset.fit_partial(user_features=user_tags, # (3)
item_features=item_tags) # (4)
# матрица взаимодействий
lfm_dataset.interactions_shape()
(64955, 59485)
"""
Mappers
"""
user_mapping = lfm_dataset.mapping()[0]
item_mapping = lfm_dataset.mapping()[2]
print('user mapping')
for ii,(i,j) in enumerate(user_mapping.items()):
if(ii<5):
print(i,j)
print('\nitem mapping')
for ii,(i,j) in enumerate(item_mapping.items()):
if(ii<5):
print(i,j)
inv_user_mapping = {value: key for key, value in user_mapping.items()}
inv_item_mapping = {value: key for key, value in item_mapping.items()}
user mapping 47427 0 99355 1 55263 2 58868 3 40184 4 item mapping 46915 0 249281 1 80651 2 164458 3 128111 4
'''
(B) Построем признаки пользователей и объектов
'''
sparse_i_features = lfm_dataset.build_item_features([[row.item_id, row.features] for row in i_features_list.reset_index().itertuples()])
sparse_u_features = lfm_dataset.build_user_features([[row.user_id, row.features] for row in u_features_list.reset_index().itertuples()])
sparse_i_features
<Compressed Sparse Row sparse matrix of dtype 'float32' with 131534 stored elements and shape (59485, 59535)>
# check what the data contains
sparse_i_features[51, :].nonzero(), sparse_i_features[51, :].data
((array([0], dtype=int32), array([51], dtype=int32)), array([1.], dtype=float32))
"""
(С) Добавляем интеракции в датасет
- [user_id] [item_id] [progress] из главной матрицы
"""
# Трансформируем
(interactions, weights) = lfm_dataset.build_interactions([(row.user_id, row.item_id, row.progress) for row in train.itertuples()])
Посмотрим на содержание матриц interactions и weights: используя матрицу interactions можно легко перейти от рейтингов к бинарному фидбеку. Будем использовать weights.
print(interactions.shape)
print(interactions.data) # interaction
print(weights.data) # progress
(64955, 59485) [1 1 1 ... 1 1 1] [65. 78. 77. ... 85. 60. 33.]
¶
В модели тва гиперпараметра; no_components
и loss
Для fit
нужно
- Нужен интеракции (которые получили через
build_interactions()
(C) - Нужны
user_features
иitem_features
(построили черезbuild_item_features()
(B)
%%time
lightfm = LightFM(no_components=50,
loss='warp')
lightfm.fit(interactions,
user_features=sparse_u_features,
item_features=sparse_i_features,
epochs=40,
num_threads=8)
CPU times: user 4min 1s, sys: 146 ms, total: 4min 1s Wall time: 1min 4s
<lightfm.lightfm.LightFM at 0x7ba3aa7fc3d0>
# merge on item_id,
train[train.user_id == 2535].merge(i_features)['genres'].value_counts()
genres Современная русская литература 19 Современная зарубежная литература 6 Легкая проза 2 Саморазвитие / личностный рост,О психологии популярно 2 Мистика,Современная зарубежная литература 2 Современные детективы 1 Историческая литература,Культурология 1 Книги для детей 1 Юмор и сатира,Современная русская литература 1 Зарубежная публицистика,Биографии и мемуары 1 Современная русская литература,Юмористическая проза 1 Книги для подростков,Детская проза 1 Общая психология,Современная русская литература 1 Зарубежная классика,Литература 20 века 1 Name: count, dtype: int64
pred = lightfm.predict(user_ids=user_mapping[2535],
item_ids=sorted(item_mapping.values()),
user_features=sparse_u_features,
item_features=sparse_i_features)
# предсказание для каждой книги
pred
array([-152.5988 , -152.75304, -154.45453, ..., -153.4577 , -154.3803 , -153.56342], dtype=float32)
k = 10
ids = np.argpartition(pred,-k)[-k:]
rel = pred[ids]
res = pd.DataFrame(zip(ids, rel),
columns=['item_id', 'relevance'])
res['item_id'] = res['item_id'].map(inv_item_mapping)
res.merge(i_features).head()
item_id | relevance | title | genres | authors | year | |
---|---|---|---|---|---|---|
0 | 99616 | -147.307663 | Когда дыхание растворяется в воздухе. Иногда с... | Зарубежная публицистика,Современная зарубежная... | Пол Каланити | 2016 |
1 | 237169 | -147.230911 | Понаехавшая | Современная русская литература | Наринэ Абгарян | 2011 |
2 | 90225 | -145.876343 | Зулейха открывает глаза | Современная русская литература | Гузель Яхина | 2015 |
3 | 107876 | -146.454849 | Женщины непреклонного возраста и др. беспринцЫ... | Современная русская литература,Юмористическая ... | Александр Цыпкин | 2018 |
4 | 184129 | -147.189774 | Не оглядывающийся никогда | Современная русская литература | Татьяна Устинова | 2011 |
(B) Ручным подходом¶
Вернемся к формуле:
$$r_{ui} = <q_u , p_i> + b_u + b_i$$
$$q_u = \sum_{j \in f_u} e^U_j$$ $$ e^U_j = w^U_j e_j$$ $$b_u = \sum_{j \in f_u} b^U_j$$ $$ b^U_j = w^U_j b_j$$
где $q_u, p_i$ - вектора пользователя и объекта, являющиеся суммой векторов и их признаков $b_u, b_i$ - смещения для признаков пользователя и объекта
item_id_lfm = item_mapping[55913]
user_id_lfm = user_mapping[2535]
# user
u_biases, u_vectors = lightfm.get_user_representations()
u_vectors.shape, u_biases.shape
((64963, 50), (64963,))
i_biases, i_vectors = lightfm.get_item_representations()
i_vectors.shape, i_biases.shape
((59535, 50), (59535,))
user_vector = sparse_u_features[user_id_lfm] @ u_vectors
item_vector = sparse_i_features[item_id_lfm] @ i_vectors
rel_ours = (user_vector @ item_vector.T + sparse_u_features[user_id_lfm] @ u_biases + sparse_i_features[item_id_lfm] @ i_biases).ravel()[0]
rel_ours
-147.31015