Recsys matrix decomposition
# !wget http://files.grouplens.org/datasets/movielens/ml-latest-small.zip
# !unzip -o ml-latest-small.zip
# pip install replay-rec --quiet
# pip install implicit -qqq
Note: you may need to restart the kernel to use updated packages.
import numpy as np
import pandas as pd
import os
from sklearn.metrics import mean_absolute_error
from scipy.sparse import csr_matrix
from scipy.sparse.linalg import svds
import scipy.sparse as sparse
import scipy
import matplotlib.pyplot as plt
from tqdm import tqdm
import warnings; warnings.filterwarnings('ignore')
from replay.metrics import HitRate, NDCG, MAP, Experiment # подсчет метрик
import implicit # для ALS
Датасет реитингов пользователей ¶
Рассмотрим датасет от GroupLens $-$ MovieLens:
Это набор данных из $27 000$ фильмов и $138 000$ пользователей, с общим количеством оценок в $20$ миллионов.
Но мы воспользуемся уменьшенной версией для быстроты вычислений: $9 000$ фильмов, $700$ пользователей, $100 000$ оценок. Скачать напрямую датасет можно по этой ссылке
# data source 1
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 |
print(f'Уникальных юзеров: {ratings["userId"].nunique()}')
print(f'Уникальных фильмов: {ratings["movieId"].nunique()}')
Уникальных юзеров: 610 Уникальных фильмов: 9724
ratings['rating'].value_counts()
rating 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: count, dtype: int64
- Посчитаем сколько раз каждый фильм был оценен
- Выбираем только те фильмы которые имеют больше 20 оценок
- Из
ratingsфильтруем те фильмы которые имеют больше 20 оценок
"""
Select subset of ratings data
"""
# count how many times each movie was rated
item_counts = ratings.groupby('movieId')['userId'].count() # number of ratings for each movie
pop_items = item_counts[item_counts > 20] # select only movies with counts > 20
ratings = ratings[ratings['movieId'].isin(pop_items.index)] # select subset of user ratings for movies that have counts > 20
ratings
| 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 |
| ... | ... | ... | ... | ... |
| 100803 | 610 | 148626 | 4.0 | 1493847175 |
| 100808 | 610 | 152081 | 4.0 | 1493846503 |
| 100829 | 610 | 164179 | 5.0 | 1493845631 |
| 100830 | 610 | 166528 | 4.0 | 1493879365 |
| 100834 | 610 | 168252 | 5.0 | 1493846352 |
66658 rows × 4 columns
создалим свою еннумерацию для всех пользователей и фильмов
all_users = ratings['userId'].unique().tolist() # all unique users in ratings data
all_items = ratings['movieId'].unique().tolist() # all unique movies in ratings data
n_users = ratings['userId'].nunique() # number of unique users
n_items = ratings['movieId'].nunique() # number of unique movies
user_id2idx = dict(zip(all_users, range(n_users)))
item_id2idx = dict(zip(all_items, range(n_items)))
ratings['userId'] = ratings['userId'].map(user_id2idx) # redefine user id (for better interpretation)
ratings['movieId'] = ratings['movieId'].map(item_id2idx) # redefine movie id (for better interpretation)
Разбиваем Выборку¶
- Сгрупируем все рейтинги для каждого
userId - Выбираем последнюю по дате рейтинг у каждого пользователя и кладем ее в
test - все оствльное у нас идет в
train
# group
full_history = ratings.sort_values(['userId', 'timestamp']).groupby('userId') # group by userid w/ sorted data
test = full_history.tail(1) # get last movie rating for each user
train = full_history.head(-1) # get all other movie ratings for each user except for first
train.shape, test.shape
((66048, 4), (610, 4))
train = train.reset_index()
test = test.reset_index()
Оставим толко те рейтинги фильмов для которых они постввили 5
test = test[test['rating'] == 5] # lets select only users who give 5.0 ratings to movies
test.shape
(117, 5)
Вспомогательная Функция¶
Создалим базовый родительский класс от которого наши дальнейшие классы SVD, FunkSVD будут наследовать. ALS тоже будет использовать шаги предобработки в этом классе.
BaseFactorizationModel
get_rating_matrixпринимает на вход обучающую выборку, как и раньше мы создаем сводную таблицу с пользователями и фильмами, где числа это соответсвующий рейтинг пользователь поставилpredictделает что и раньше, сортирует и выбирает для каждого пользователя топ к фильмов и возвращаетmovieIdи его score из SVD
class BaseFactorizationModel:
def __init__(self, random_state=0, user_col='userId', item_col='movieId', rating_col='rating'):
self.random_state = np.random.RandomState(random_state)
self.user_col = user_col
self.item_col = item_col
self.rating_col = rating_col
self.user_matrix = None
self.item_matrix = None
# matrix which we will decompose
def get_rating_matrix(self, data):
return pd.pivot_table(
data,
values=self.rating_col,
index=self.user_col,
columns=self.item_col,
fill_value=0
).values
"""
When we receive the matrix with scores, for each user
sort and get the top k
"""
def predict(self,
scores, # (user,film) score matrix
rating_matrix=None, # (user,film) rating matrix
filter_seen=True,
k=10):
# filter out films that already have been seen
if filter_seen:
scores = np.multiply(scores,
np.invert(rating_matrix.astype(bool))
)
# scores index : userId column : filmId
# get indicies of top k scores (indicies : movieId) in user array
ind_part = np.argpartition(scores, -k + 1)[:, -k:].copy()
# get the values of the top k scores
scores_not_sorted = np.take_along_axis(scores, ind_part, axis=1)
# indicies of scores from lowest to highest
ind_sorted = np.argsort(scores_not_sorted, axis=1) #
# scores from lowest to highest
scores_sorted = np.sort(scores_not_sorted, axis=1)
# get the indicies of the movieId with the highest scores
indices = np.take_along_axis(ind_part, ind_sorted, axis=1)
# for each user return the movies with the highest scores
preds = pd.DataFrame({
self.user_col: range(scores.shape[0]), # each user
self.item_col: np.flip(indices, axis=1).tolist(), # movieId index
self.rating_col: np.flip(scores_sorted, axis=1).tolist() # movieId score
})
# convert arrays (user_col,item_col) into rows for each user
preds = preds.explode([self.item_col, self.rating_col])
return preds
SVD¶
SVD из scipy
- В базовом методе 1 гиперпараметр
n_factors - включает один метод
fit; генерирует матрицу рейтингов для каждого уникального пользователя и фильма
Смысл SVD разложения:
- Ииспользует методы линейной алгебры для нахождения этих матриц и может быть вычислен с помощью прямых методов, таких как QR-разложение
- Мы формируем матрицу (get_rating_matrix) рейтингов фильмов
movieIdдля каждого пользователяuserIdв выборке SVDразлагает эту матрицу взаимодейстий пользователей на три матрицы (U,- Оставляя только размерность k (latern features) для матрих U (
userId,k) и V (movieID,k) и ΣI (k,k) - Эта апроксимации матрицы U и V захватывают скрытые признаки, представляющие основные закономерности в данных
- Если мы не указываем
n_factorsто мы получаем наиболее точную разложение мартицы рейтинга (get_rating_matrix)
class SVD(BaseFactorizationModel):
def __init__(self,
random_state=0,
user_col='userId',
item_col='movieId',
n_factors=20): # hyperparameter
super().__init__(random_state, user_col, item_col)
self.n_factors = n_factors
"""
Calculate the scores for each user
"""
def fit(self, data):
# user,movie rating matrix
self.rating_matrix = self.get_rating_matrix(data) # (unique users,unique films) ratings
csr_rating_matrix = csr_matrix(self.rating_matrix.astype(float))
# svd decomposition
user_matrix, singular_values, item_matrix = svds(A=csr_rating_matrix,
k=self.n_factors)
user_matrix = user_matrix * np.sqrt(singular_values) # (unique users,k latent features)
item_matrix = item_matrix.T * np.sqrt(singular_values) # (unique films, k latent features)
self.scores = user_matrix @ item_matrix.T # score matrix for each user & film (unique users, unique films)
self.user_matrix = user_matrix
self.item_matrix = item_matrix
svd_model = SVD()
svd_model.fit(train)
preds_svd = svd_model.predict(svd_model.scores, # user movie scores from svd decomposition
svd_model.rating_matrix) # user movie ratings
preds_svd
| userId | movieId | rating | |
|---|---|---|---|
| 0 | 0 | 676 | 3.941808 |
| 0 | 0 | 677 | 3.500389 |
| 0 | 0 | 588 | 3.180376 |
| 0 | 0 | 593 | 3.135802 |
| 0 | 0 | 1062 | 2.902454 |
| ... | ... | ... | ... |
| 609 | 609 | 1158 | 3.092268 |
| 609 | 609 | 61 | 3.092064 |
| 609 | 609 | 1201 | 3.08826 |
| 609 | 609 | 717 | 3.053321 |
| 609 | 609 | 1062 | 3.044532 |
6100 rows × 3 columns
FunkSVD¶
Теперь попробуем с нуля подход FunkSVD
В отличии от SVD модель обучается при помощи градиентного спуска
- Мы обучаем матрицы P, Q (U и Q из
SVD)
class FunkSVD(BaseFactorizationModel):
def __init__(self, random_state = 0,
user_col='userId',
item_col='movieId',
rating_col='rating',
lr = 0.01, # learning rate
reg = 0.05, # coefficient of regularisation
n_factors=20, # size of vectors
n_epochs=5): # number of iterations
super().__init__(random_state, user_col, item_col, rating_col)
self.lr = lr
self.reg = reg
self.n_factors = n_factors
self.n_epochs = n_epochs
def fit(self, data):
self.rating_matrix = self.get_rating_matrix(data)
n_users, n_items = self.rating_matrix.shape
"""
Parameter Initialisation
mu : глобальная средняя оценка
bu : смещение оценок пользователя (на сколько отличается средняя оценка от глобального)
bi : смещение оценок объектов (на сколько отличается средняя оценка от глобального)
P, Q : матрицы которые мы будем обучать
"""
mu = data[self.rating_col].mean() # global average rating
# initialise shifts
# how much avg rating of user differs from global avg
bu = np.zeros(n_users)
bi = np.zeros(n_items)
# we need to learn the matricies for users and items
P = self.random_state.normal(size=(n_users, self.n_factors))
Q = self.random_state.normal(size=(n_items, self.n_factors))
# number of iterations we will be updating our weights (matrices)
for _ in tqdm(range(self.n_epochs)):
errs = []
for _, row in data.iterrows():
# Get ids and ratings
user_id = row[self.user_col]
item_id = row[self.item_col]
rating = row[self.rating_col]
# Predict current rating
# global avg + shift of user/item + scalar vector multiplication
pred = mu + bu[user_id] + bi[item_id] + Q[item_id] @ P[user_id]
# calculate the difference b/w prediction and actual user ratings
err = rating - pred
errs.append(abs(err))
"""
Update Biases bu/bi
"""
bu[user_id] += self.lr * (err - self.reg * bu[user_id])
bi[item_id] += self.lr * (err - self.reg * bi[item_id])
"""
Update latent factors
"""
P[user_id] += self.lr * (err * Q[item_id] - self.reg * P[user_id])
Q[item_id] += self.lr * (err * P[user_id] - self.reg * Q[item_id])
print(round(np.mean(errs), 4))
# restore all data
self.user_matrix = P # (userId,k)
self.item_matrix = Q # (itemId,k)
self.user_bias = bu # (userId,)
self.item_bias = bi # (itemId,)
self.mu = mu # global doesn't change
# [ dont really neeed ]
self.scores = mu + bu.reshape(-1, 1) + bi + P @ Q.T
funk_model = FunkSVD()
funk_model.fit(train)
20%|██ | 1/5 [00:06<00:24, 6.10s/it]
1.7948
40%|████ | 2/5 [00:12<00:18, 6.13s/it]
0.8553
60%|██████ | 3/5 [00:18<00:12, 6.11s/it]
0.728
80%|████████ | 4/5 [00:24<00:06, 6.11s/it]
0.6719
100%|██████████| 5/5 [00:30<00:00, 6.13s/it]
0.6401
preds_funk = funk_model.predict(funk_model.scores,
funk_model.rating_matrix)
preds_funk
| userId | movieId | rating | |
|---|---|---|---|
| 0 | 0 | 544 | 5.201155 |
| 0 | 0 | 677 | 5.169179 |
| 0 | 0 | 1042 | 5.163057 |
| 0 | 0 | 1016 | 5.158277 |
| 0 | 0 | 215 | 5.151932 |
| ... | ... | ... | ... |
| 609 | 609 | 6 | 4.403011 |
| 609 | 609 | 208 | 4.38257 |
| 609 | 609 | 254 | 4.38018 |
| 609 | 609 | 270 | 4.372523 |
| 609 | 609 | 1107 | 4.366516 |
6100 rows × 3 columns
k = funk_model.scores.shape[1] # number of movieId
print(k)
1235
preds_funk = funk_model.predict(funk_model.scores,
funk_model.rating_matrix,
k=k)
test_pred = test.merge(preds_funk, on=['userId', 'movieId'])
test_pred
| index | userId | movieId | rating_x | timestamp | rating_y | |
|---|---|---|---|---|---|---|
| 0 | 839 | 5 | 40 | 5.0 | 845556915 | 3.611139 |
| 1 | 4160 | 26 | 134 | 5.0 | 965151428 | 2.638579 |
| 2 | 4876 | 29 | 1014 | 5.0 | 1500370457 | 2.947382 |
| 3 | 5375 | 36 | 418 | 5.0 | 845927014 | 3.46253 |
| 4 | 5756 | 40 | 730 | 5.0 | 1459369130 | 1.824829 |
| ... | ... | ... | ... | ... | ... | ... |
| 112 | 89049 | 575 | 1178 | 5.0 | 1358151542 | -0.153748 |
| 113 | 89226 | 577 | 522 | 5.0 | 1300996817 | 6.002605 |
| 114 | 89245 | 578 | 331 | 5.0 | 977364909 | 2.795876 |
| 115 | 89912 | 583 | 336 | 5.0 | 834988340 | 9.314807 |
| 116 | 90392 | 586 | 913 | 5.0 | 953142269 | 2.811585 |
117 rows × 6 columns
mean_absolute_error(test_pred['rating_x'], test_pred['rating_y'])
1.67207526461794
iALS¶
Метод ALS похож на funcSVD, они оба являются итеративные медоты обучения
- Но в
ALSмы фиксируем матрицу пользователейuserIdи обновляем весаitemId - Потом мы фиксируем матрицу
itemIdи обновляем веса пользователей - Метод хорошо параллелится и можем найти решение явным виде (не методом градиентном спуском)
- Повторяем это до схлдимости используя наименших квадратов в обоих подзадач
Отличие iALS от ALS
iALSпозволяет выполнять обучение инкрементально; модель может обновляться по мере поступления новых данных без необходимости переобучать всю модель с нуля.Это особенно полезно в сценариях, где данные поступают непрерывно (например, в реальном времени)
ALSменее эффективный для больших и динамически изменяющихся наборов данных, так как требует полного пересчета матриц пользователей и товаров при каждом обновлении.iALSпозволяет более эффективно использовать вычислительные ресурсы, так как изменения в данных могут быть учтены без полного пересчета модели. Это делает его более подходящим для систем рекомендаций с постоянным потоком новых данных.
# create the user movie rating matrix
base_model = BaseFactorizationModel()
rating_matrix = base_model.get_rating_matrix(train)
train_sparse = sparse.csr_matrix(rating_matrix)
Как и `funcALS`, метод имеет способ регуляризации, количетсво итерации и размерность матриц пользователей и
ials_model = implicit.als.AlternatingLeastSquares(factors=20,
regularization=0.1,
iterations=50,
use_gpu=False)
ials_model.fit((train_sparse).astype('double'))
user_vecs = ials_model.user_factors
item_vecs = ials_model.item_factors
# проверим по размерам, что мы действиельно не перепутали users и items
print(train_sparse.shape)
print(user_vecs.shape, item_vecs.shape)
(610, 1235) (610, 20) (1235, 20)
Предсказание как и раньше через скаларное произвидение матриц user_factors и item_factors
scores = user_vecs.dot(item_vecs.T)
scores.shape
(610, 1235)
user_vecs[0]
array([ 1.7469772 , 1.7536646 , -0.15816154, 0.3728279 , 1.2622052 ,
0.77496713, 1.3928413 , 1.2806208 , -0.45395368, 0.81654304,
0.812823 , 1.0866945 , -0.90293694, 2.4070432 , 1.5287678 ,
1.9461825 , 0.14609063, 0.13025095, 1.2569972 , -1.7232617 ],
dtype=float32)
item_vecs[0]
array([-0.00182583, 0.13658507, -0.14382875, 0.05055814, 0.09778877,
0.10126482, 0.03720245, 0.09039041, 0.1098939 , -0.29790142,
-0.04846857, -0.01657198, 0.03136025, -0.08922204, 0.31706864,
-0.06648538, -0.03854659, 0.06695224, 0.0011029 , -0.06568323],
dtype=float32)
preds_ials = base_model.predict(scores, rating_matrix)
Оценки Моделей¶
В конце оценим метрики качества всех трех подходов
K = [10]
metrics = Experiment(
[
NDCG(K),
MAP(K),
HitRate(K),
],
test,
query_column='userId', item_column= 'movieId'
)
metrics.add_result("SVD", preds_svd)
metrics.results
metrics.add_result("FunkSVD", preds_funk)
metrics.results
metrics.add_result("iALS", preds_ials)
metrics.results
| NDCG@10 | MAP@10 | HitRate@10 | |
|---|---|---|---|
| SVD | 0.057592 | 0.036226 | 0.128205 |
| FunkSVD | 0.002573 | 0.000950 | 0.008547 |
| iALS | 0.059676 | 0.042165 | 0.119658 |