Recsys rule knn
Эвристические модели. Коллаборативная фильтрация¶
import warnings
warnings.simplefilter('ignore')
from collections import Counter
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from tqdm import tqdm_notebook
from sklearn.metrics import mean_squared_error
from sklearn.base import BaseEstimator
from sklearn.metrics.pairwise import cosine_similarity
Датасет реитингов пользователей ¶
Рассмотрим датасет от GroupLens
$-$ MovieLens
:
Это набор данных из $27 000$ фильмов и $138 000$ пользователей, с общим количеством оценок в $20$ миллионов.
Но мы воспользуемся уменьшенной версией для быстроты вычислений: $9 000$ фильмов, $700$ пользователей, $100 000$ оценок. Скачать напрямую датасет можно по этой ссылке
Для начала немного посмотрим на данные¶
links.csv
$-$ связь междуid
фильма в датасете иid
соответствующего фильма наimdb.com
иthemoviedb.org
;movies.csv
$-$ описание каждого фильма с его названием и жанрами;ratings.csv
$-$ оценки пользователей фильмов с временной отметкой;tags.csv
$-$ список тегов, которые поставил пользователь фильму, с временной отметкой.
Для данной задачи нам понадобятся только часть данных $-$ информация о том, какой рейтинг ставили пользователи фильмам.
ratings = pd.read_csv('./ml-latest-small/ratings.csv', parse_dates=['timestamp'])
ratings.head()
userId | movieId | rating | timestamp | |
---|---|---|---|---|
0 | 1 | 1 | 4.0 | 964982703 |
1 | 1 | 3 | 4.0 | 964981247 |
2 | 1 | 6 | 4.0 | 964982224 |
3 | 1 | 47 | 5.0 | 964983815 |
4 | 1 | 50 | 5.0 | 964982931 |
# распределение оценок пользователей
ratings.rating.value_counts()
4.0 26818 3.0 20047 5.0 13211 3.5 13136 4.5 8551 2.0 7551 2.5 5550 1.0 2811 1.5 1791 0.5 1370 Name: rating, dtype: int64
len(ratings) / (ratings.userId.nunique() * ratings.movieId.nunique())
0.016999683055613623
ratings['timestamp'] = ratings['timestamp'].astype(int)
def train_test_split(X):
X.sort_values(by=['timestamp'], inplace=True)
# фильмы которым пользователь даль оценку
movies = X.groupby(['userId'], sort=False)['movieId'].apply(list).reset_index()
# оценки который пользователь дал
ratings = X.groupby(['userId'], sort=False)['rating'].apply(list).reset_index()
# создаем список рейтингов для каждого пользователя
X = movies.merge(ratings)
train = pd.DataFrame(X['userId']) # отделяем все кроме последнего оцененного фильма в train
train['movieId'] = X.movieId.apply(lambda x: x[:-1])
train['rating'] = X.rating.apply(lambda x: x[:-1])
# отделяем все кроме последнего рейтинги в train
train = train.explode(['movieId', 'rating'])
"""
Тестовая Выборка (последний рейтинг польз.)
"""
test = pd.DataFrame(X['userId'])
test['movieId'] = X.movieId.apply(lambda x: x[-1:]) # отделяем последний фильм в test
test['rating'] = X.rating.apply(lambda x: x[-1:]) # отделяем последний рейтинг в test
test = test.explode(['movieId', 'rating'])
return train, test
train, test = train_test_split(ratings)
print(test.shape)
(610, 3)
Тестовая Выборка¶
Мы предсказываем фильмы, которые должны понравиться, поэтому оценивать будем только на фильмах с высоким рейтингом от пользователя.
test = test[test['rating'] == 5]
test.shape
(117, 3)
test
userId | movieId | rating | |
---|---|---|---|
2 | 191 | 673 | 5.0 |
8 | 40 | 685 | 5.0 |
11 | 136 | 188 | 5.0 |
21 | 385 | 750 | 5.0 |
24 | 584 | 60 | 5.0 |
... | ... | ... | ... |
581 | 382 | 55247 | 5.0 |
600 | 296 | 4144 | 5.0 |
602 | 556 | 112852 | 5.0 |
604 | 258 | 4995 | 5.0 |
606 | 25 | 187593 | 5.0 |
117 rows × 3 columns
Метрики¶
(1) MRR¶
- Это среднее обратное ранжирование. Используется для оценки качества систем, которые возвращают упорядоченные списки результатов.
- Рассчитывается как среднее значение обратных рангов первых релевантных результатов для всех запросов.
(2) HR¶
- Это метрика, которая показывает долю запросов, для которых хотя бы один релевантный элемент был найден в результатах.
- Рассчитывается как отношение количества успешных запросов (где найден хотя бы один релевантный элемент) к общему количеству запросов: HR = Количество успешных запросов/Общее количество запросов
(3) NDCG¶
- Это метрика, которая учитывает как релевантность результатов, так и их порядок.
- Она нормализует накопленную полезность с учетом позиции, на которой находится результат.
(4) Coverage¶
- Метрика coverage (покрытие) в контексте рекомендательных систем измеряет, насколько хорошо система охватывает доступные элементы (например, товары, фильмы, песни и т.д.) в своих рекомендациях.
- Она показывает долю уникальных элементов, которые были рекомендованы пользователям, по сравнению с общим количеством доступных элементов в системе.
# mean reciprocal rate
def mrr(df: pd.DataFrame, pred_col='preds', true_col='true') -> float:
mrr_values = []
for _, row in df.iterrows():
try:
user_mrr = 1 / (row[pred_col].index(row[true_col]) + 1)
except ValueError:
user_mrr = 0
mrr_values.append(user_mrr)
return np.mean(mrr_values)
# hit rate
def hr(df: pd.DataFrame, pred_col='preds', true_col='true') -> float:
hr_values = []
for _, row in df.iterrows():
hr_values.append(int(row[true_col] in row[pred_col]))
return np.mean(hr_values)
# normalised discounted cumulative gain
def ndcg(df: pd.DataFrame, pred_col='preds', true_col='true') -> float:
# ideal dcg == 1 при стратегии разделения leave-one-out
ndcg_values = []
for _, row in df.iterrows():
try:
user_ndcg = 1 / np.log2(row[pred_col].index(row[true_col]) + 2)
except ValueError:
user_ndcg = 0
ndcg_values.append(user_ndcg)
return np.mean(ndcg_values)
def coverage(train_df: pd.DataFrame, pred_df: pd.DataFrame, item_id='item_id', pred_col='preds') -> float:
total_items_num = train_df[item_id].nunique()
pred_items_num = len(set.union(*pred_df[pred_col].map(lambda x: set(x))))
return pred_items_num / total_items_num
def calculate_metrics(train_df: pd.DataFrame, pred_df: pd.DataFrame, item_id='item_id', pred_col='preds', true_col='true'):
print(f'mrr = {mrr(pred_df, true_col=true_col).round(4)}')
print(f'hr = {hr(pred_df, true_col=true_col).round(4)}')
print(f'ndcg = {ndcg(pred_df, true_col=true_col).round(4)}')
print(f'coverage = {round(coverage(train_df, pred_df, item_id=item_id),4)}')
k = 10
# фильм, количество
count_items = Counter(train.movieId)
count_items = [*count_items.items()]
count_items.sort(key=lambda x: x[1], reverse=True)
# top 10 самых популярнвх фильмов
pred_items = [k for k, v in count_items[:k]]
pred_items
[356, 318, 296, 593, 2571, 260, 110, 480, 589, 2959]
pred = test.copy()
pred['preds'] = [pred_items] * len(pred)
pred
userId | movieId | rating | preds | |
---|---|---|---|---|
2 | 191 | 673 | 5.0 | [356, 318, 296, 593, 2571, 260, 110, 480, 589,... |
8 | 40 | 685 | 5.0 | [356, 318, 296, 593, 2571, 260, 110, 480, 589,... |
11 | 136 | 188 | 5.0 | [356, 318, 296, 593, 2571, 260, 110, 480, 589,... |
21 | 385 | 750 | 5.0 | [356, 318, 296, 593, 2571, 260, 110, 480, 589,... |
24 | 584 | 60 | 5.0 | [356, 318, 296, 593, 2571, 260, 110, 480, 589,... |
... | ... | ... | ... | ... |
581 | 382 | 55247 | 5.0 | [356, 318, 296, 593, 2571, 260, 110, 480, 589,... |
600 | 296 | 4144 | 5.0 | [356, 318, 296, 593, 2571, 260, 110, 480, 589,... |
602 | 556 | 112852 | 5.0 | [356, 318, 296, 593, 2571, 260, 110, 480, 589,... |
604 | 258 | 4995 | 5.0 | [356, 318, 296, 593, 2571, 260, 110, 480, 589,... |
606 | 25 | 187593 | 5.0 | [356, 318, 296, 593, 2571, 260, 110, 480, 589,... |
117 rows × 4 columns
Достаем информацию о филме которую мы рекоммендуем, всем фильмам поставили оценку 5
movies = pd.read_csv('./ml-latest-small/movies.csv')
movies[movies['movieId'].isin(pred_items)]
movieId | title | genres | |
---|---|---|---|
97 | 110 | Braveheart (1995) | Action|Drama|War |
224 | 260 | Star Wars: Episode IV - A New Hope (1977) | Action|Adventure|Sci-Fi |
257 | 296 | Pulp Fiction (1994) | Comedy|Crime|Drama|Thriller |
277 | 318 | Shawshank Redemption, The (1994) | Crime|Drama |
314 | 356 | Forrest Gump (1994) | Comedy|Drama|Romance|War |
418 | 480 | Jurassic Park (1993) | Action|Adventure|Sci-Fi|Thriller |
507 | 589 | Terminator 2: Judgment Day (1991) | Action|Sci-Fi |
510 | 593 | Silence of the Lambs, The (1991) | Crime|Horror|Thriller |
1939 | 2571 | Matrix, The (1999) | Action|Sci-Fi|Thriller |
2226 | 2959 | Fight Club (1999) | Action|Crime|Drama|Thriller |
calculate_metrics(train, pred, item_id='movieId', true_col='movieId')
mrr = 0.0218 hr = 0.0598 ndcg = 0.0306 coverage = 0.001
(2) Популярные с высоким рейтингом¶
Выбрать топ 10
- Рекоммендуем самые высоко оцененные фильмы всеми пользователями
- Все имеют толко оценку 5
count_items = Counter(train[train.rating == 5]['movieId'])
count_items = [*count_items.items()]
count_items.sort(key=lambda x: x[1], reverse=True)
pred_items = [k for k, v in count_items[:k]]
pred = test.copy()
pred['preds'] = [pred_items] * len(pred)
movies = pd.read_csv('./ml-latest-small/movies.csv')
movies[movies['movieId'].isin(pred_items)]
movieId | title | genres | |
---|---|---|---|
224 | 260 | Star Wars: Episode IV - A New Hope (1977) | Action|Adventure|Sci-Fi |
257 | 296 | Pulp Fiction (1994) | Comedy|Crime|Drama|Thriller |
277 | 318 | Shawshank Redemption, The (1994) | Crime|Drama |
314 | 356 | Forrest Gump (1994) | Comedy|Drama|Romance|War |
461 | 527 | Schindler's List (1993) | Drama|War |
510 | 593 | Silence of the Lambs, The (1991) | Crime|Horror|Thriller |
659 | 858 | Godfather, The (1972) | Crime|Drama |
898 | 1196 | Star Wars: Episode V - The Empire Strikes Back... | Action|Adventure|Sci-Fi |
1939 | 2571 | Matrix, The (1999) | Action|Sci-Fi|Thriller |
2226 | 2959 | Fight Club (1999) | Action|Crime|Drama|Thriller |
calculate_metrics(train, pred, item_id='movieId', true_col='movieId')
mrr = 0.0233 hr = 0.0598 ndcg = 0.0318 coverage = 0.001
Методы коллаборативной фильтрации¶
(1) User-based (userKNN)¶
User-based model является моделью коллаборативной фильтрации, основная идея которой похожим пользователям обычно нравятся похожие объекты
Идея алгоритма:
Найти, насколько другие пользователи в базе данных похожи на данного пользователя.
По оценкам других пользователей предсказать, какую оценку даст данный пользователь данному продукту, учитывая с большим весом тех пользователей, которые больше похожи на данного.
Отранжировать товары в порядке убывания предсказанных рейтингов и взять top k.
Определять схожесть двух пользователей можно с помощью косинусной меры близости между векторами поставленных оценок
# маппинг id в индексы
def get_mapping(data):
# уникальые пользователи в выборке
user_ids = data['userId'].unique().tolist()
# уникальные фильмы в нашей выборке
item_ids = data['movieId'].unique().tolist()
n_users = len(user_ids)
n_items = len(item_ids)
user_idx = range(n_users)
item_idx = range(n_items)
# идентификатор для пользователей и фильмов
user_mapping = dict(zip(user_ids, user_idx)) # {user_id: user_ind}
item_mapping = dict(zip(item_ids, item_idx)) # {item_id: item_ind}
return user_mapping, item_mapping
user_mapping, item_mapping = get_mapping(ratings)
train['userId'] = train['userId'].map(user_mapping)
test['userId'] = test['userId'].map(user_mapping)
train['movieId'] = train['movieId'].map(item_mapping)
test['movieId'] = test['movieId'].map(item_mapping)
Таким образом формула схожести двух пользователей:
$$ \textit{sim(u, v)} = \frac {\sum_i{\big(r_{ui} \times r_{vi}\big)}} {\sqrt{\sum_i{r_{ui}^2}} \times \sqrt{\sum_i{r_{vi}^2}}} $$
Интуитивно понятно, что предполагаемый рейтинг для пользователя можно оценить как средний рейтинг между схожими пользователями, но, благодаря введению понятия схожести, можно улучшить эту оценку, используя взвешенные веса и учитывая всех пользователей, которые посмотрели этот фильм.
Итак, приближаем новый рейтинг как сумму рейтингов других пользователей, взвешенных весами (т. е. похожестью):
$$ r_{ui} = \frac {\sum_{v \in User_i}\big(\textit{sim(u, v)} \times r_{vi}\big)} {\sum_{v \in User_i}\textit{|sim(u, v)|}} $$
"""
UserKNN
"""
def userKNN(train_data, test_data, k_neighb, k):
"""
Rating Matrix for each user (row)
"""
# shape = n_users * n_items
R = pd.pivot_table(train_data,
values='rating',
index='userId',
columns='movieId', fill_value=0)
"""
Matrix of similarity for each user (row)
"""
# Сходство между пользователями
user_sim = cosine_similarity(R) # shape = n_users * n_users
# расспределение similarity
# plt.hist(user_sim.flatten(), bins=30);
# делаем предсказание для пользователей в тестовой выборке
preds = []
for _, row in tqdm_notebook(test_data.iterrows()):
user = int(row['userId']) # current user row
# sort and reverse similarity vector values for current row user (exclude itself)
# choose the number of similar users
neighb_inds = np.argsort(user_sim[user])[::-1][1: k_neighb + 1] # argument
neighb_sim = np.sort(user_sim[user])[::-1][1: k_neighb + 1] # value of neighbours
# .astype(bool) : Факт того что была оценка
# rating of indicies
# multiply each neighbour movie rating vector by coefficient of its similarity to the user
sum_ratings = (R.iloc[neighb_inds] * neighb_sim.reshape(k_neighb, -1)).sum(axis=0) # numerator
sum_sim = (R.iloc[neighb_inds].astype(bool) * neighb_sim.reshape(k_neighb, -1)).sum(axis=0) # denominator
user_ratings = sum_ratings / (sum_sim + 1e-10)
user_ratings = pd.DataFrame(user_ratings, columns=['pred']).reset_index()
user_ratings.sort_values(by=['pred'], ascending=False, inplace=True)
preds.append(user_ratings['movieId'][:k].tolist())
preds_data = test_data.copy()
preds_data['preds'] = preds
return preds_data
k_neighb = 5
pred = userKNN(train, test, k_neighb, k)
0it [00:00, ?it/s]
pred.head()
userId | movieId | rating | preds | |
---|---|---|---|---|
2 | 2 | 134 | 5.0 | [112, 129, 66, 82, 349, 77, 116, 187, 113, 117] |
8 | 8 | 210 | 5.0 | [264, 107, 200, 219, 78, 238, 35, 33, 112, 277] |
11 | 11 | 215 | 5.0 | [64, 246, 256, 248, 128, 20, 347, 187, 190, 194] |
21 | 21 | 817 | 5.0 | [264, 128, 48, 617, 717, 704, 960, 814, 535, 631] |
24 | 24 | 41 | 5.0 | [55, 64, 346, 107, 9, 440, 68, 33, 16, 106] |
calculate_metrics(train, pred, item_id='movieId', true_col='movieId')
mrr = 0.0182 hr = 0.0598 ndcg = 0.0277 coverage = 0.0598
(2) Item-based model (itemKNN)¶
Item-based model очень похожа на предыдущую модель по структуре, но теперь мы ищем похожие товары
, а не пользователей. А именно:
Для продуктов, которые оценил пользователь найти, насколько они похожи на другие продукты в базе данных.
По оценкам других продуктов предсказать, какую оценку даст данный пользователь данному продукту, учитывая с большим весом те продукты, которые больше похожи на данный.
Поэтому при вычисление $r_{ui}$ мы посмотрим на все фильмы пользователя $u$, оценим их схожесть с фильмом $i$ и посчитаем взвешенную сумму:
$$ r_{ui} = \frac {\sum_{j \in Item_u}\big(\textit{sim(i, j)} \times r_{uj} \big)} {\sum_{j \in Item_u}\textit{|sim(i, j)|}} $$
Оценивать же схожесть двух фильмов будем с помощью той же косинусной меры близости:
$$ \textit{sim(i, j)} = \frac {\sum_u{\big(r_{ui} \times r_{uj}\big)}} {\sqrt{\sum_u{r_{ui}^2}} \times \sqrt{\sum_u{r_{uj}^2}}} $$
# ссылаемся к данным из
train.head()
userId | movieId | rating | |
---|---|---|---|
0 | 0 | 0 | 5.0 |
0 | 0 | 1 | 5.0 |
0 | 0 | 2 | 5.0 |
0 | 0 | 3 | 5.0 |
0 | 0 | 4 | 3.0 |
"""
itemKNN
"""
def itemKNN(train_data, test_data, k_neighb, k):
# shape = n_users * n_items
R = pd.pivot_table(train_data,
values='rating',
index='userId',
columns='movieId', fill_value=0)
# movies vs user cosine similarity
item_sim = cosine_similarity(R.T) # shape = n_items * n_items
preds = []
for _, row in tqdm_notebook(test_data.iterrows()):
"""
User interactions subset selection (user_interactions)
"""
user = row['userId']
user_interactions = train_data[train_data['userId'] == user] # user rating subset
user_interactions = user_interactions[user_interactions['rating'] == 5] # select ratings of 5 only
"""
For each user interaction row
userId movieId rating
0 0 0 5.0
"""
# for each user interaction row (user ratings of 5 only)
item_preds = pd.DataFrame()
for _, row in user_interactions.iterrows():
"""
Find top k similar movies
"""
# find the most similar movies (index and value)
item = row['movieId'] # index of movie
# find similar to it movies index and value
neighb_inds = np.argsort(item_sim[item])[::-1][1: k_neighb + 1]
neighb_sim = np.sort(item_sim[item])[::-1][1: k_neighb + 1]
sum_ratings = (R.T.iloc[neighb_inds] * neighb_sim.reshape(k_neighb, -1)).sum(axis=1) # numerator
sum_sim = (R.T.iloc[neighb_inds].astype(bool) * neighb_sim.reshape(k_neighb, -1)).sum(axis=1) # denominator
item_ratings = sum_ratings / (sum_sim + 1e-10)
item_ratings = pd.DataFrame(item_ratings, columns=['pred']).reset_index()
item_preds = pd.concat([item_preds, item_ratings])
item_preds.sort_values(by=['pred'], ascending=False, inplace=True)
preds.append(item_preds['movieId'][:k].tolist())
preds_data = test_data.copy()
preds_data['preds'] = preds
return preds_data
pred = itemKNN(train, test, k_neighb, k)
0it [00:00, ?it/s]
pred.head()
userId | movieId | rating | preds | |
---|---|---|---|---|
2 | 2 | 134 | 5.0 | [67, 67, 67, 67, 67, 67, 132, 132, 132, 108] |
8 | 8 | 210 | 5.0 | [112, 349, 817, 678, 114, 114, 372, 601, 264, ... |
11 | 11 | 215 | 5.0 | [673, 814, 121, 121, 121, 166, 246, 246, 161, ... |
21 | 21 | 817 | 5.0 | [1497, 1698, 717, 617, 673, 673, 545, 114, 372... |
24 | 24 | 41 | 5.0 | [112, 372, 814, 121, 121, 121, 166, 166, 246, ... |
calculate_metrics(train, pred, item_id='movieId', true_col='movieId')
mrr = 0.0105 hr = 0.0427 ndcg = 0.018 coverage = 0.0355
Как можно заметить данные алгоритмы имеют общие недостатки, например:
- Они не могут решить проблему холодного старта, когда у нас нет информации о пользователе или товаре.
- Для нетипичных пользователей или предметов сложно дать объективные предсказания, так как они мало похожи на остальных.
Существуют и более сложные модели коллаборативной фильтрации. Кроме того есть и другие принципы:
- Контентная фильтрация
- Смешанные подходы $-$ объединяют в себе content based и collaborative фильтрации.