# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# ### Матричные факторизации

# В данной работе вам предстоит познакомиться с практической стороной матричных разложений.
# Работа поделена на 4 задания:
# 1. Вам необходимо реализовать SVD разложения используя SGD на explicit данных
# 2. Вам необходимо реализовать матричное разложения используя ALS на implicit данных
# 3. Вам необходимо реализовать матричное разложения используя BPR(pair-wise loss) на implicit данных
# 4. Вам необходимо реализовать матричное разложения используя WARP(list-wise loss) на implicit данных
#

# +
import implicit
import pandas as pd
import numpy as np
import scipy.sparse as sp
from tqdm import trange

from lightfm.datasets import fetch_movielens
# -

# В данной работе мы будем работать с explicit датасетом movieLens, в котором представленны пары user_id movie_id и rating выставленный пользователем фильму
#
# Скачать датасет можно по ссылке https://grouplens.org/datasets/movielens/1m/

ratings = pd.read_csv('data/ml-1m/ratings.dat', delimiter='::', header=None, 
        names=['user_id', 'movie_id', 'rating', 'timestamp'], 
        usecols=['user_id', 'movie_id', 'rating'], engine='python')

movie_info = pd.read_csv('data/ml-1m/movies.dat', delimiter='::', header=None, 
        names=['movie_id', 'name', 'category'], engine='python')

ratings['user_id'] -= 1
ratings['movie_id'] -= 1
movie_info['movie_id'] -= 1

# Explicit данные

ratings.head(10)

# Для того, чтобы преобразовать текущий датасет в Implicit, давайте считать что позитивная оценка это оценка >=4

implicit_ratings = ratings.loc[(ratings['rating'] >= 4)]

implicit_ratings.head(10)

# Удобнее работать с sparse матричками, давайте преобразуем DataFrame в CSR матрицы

users = implicit_ratings["user_id"]
movies = implicit_ratings["movie_id"]
user_item = sp.coo_matrix((np.ones_like(users), (users, movies)))
user_item_t_csr = user_item.T.tocsr()
user_item_csr = user_item.tocsr()

# В качестве примера воспользуемся ALS разложением из библиотеки implicit
#
# Зададим размерность латентного пространства равным 64, это же определяет размер user/item эмбедингов

model = implicit.als.AlternatingLeastSquares(factors=64, iterations=100, calculate_training_loss=True)

# В качестве loss здесь всеми любимый RMSE

model.fit(user_item_t_csr)

# Построим похожие фильмы по 1 movie_id = Истории игрушек

movie_info.head(5)

get_similars = lambda item_id, model : pd.concat([movie_info[movie_info["movie_id"] == x[0]] 
                                        for x in model.similar_items(item_id)], axis=0)

# Как мы видим, симилары действительно оказались симиларами.
#
# Качество симиларов часто является хорошим способом проверить качество алгоритмов.
#
# P.S. Если хочется поглубже разобраться в том как разные алгоритмы формируют разные латентные пространства, рекомендую загружать полученные вектора в tensorBoard и смотреть на сформированное пространство

get_similars(0, model)

# Давайте теперь построим рекомендации для юзеров
#
# Как мы видим юзеру нравится фантастика, значит и в рекомендациях ожидаем увидеть фантастику

get_user_history = lambda user_id, dataset : pd.concat([movie_info[movie_info["movie_id"] == x] for x in dataset[dataset["user_id"] == user_id]["movie_id"]], axis=0)

get_user_history(3, implicit_ratings)

# Получилось! 
#
# Мы действительно порекомендовали пользователю фантастику и боевики, более того встречаются продолжения тех фильмов, которые он высоко оценил

get_recommendations = lambda user_id, model, dataset : pd.concat([movie_info[movie_info["movie_id"] == x[0]]
                                               for x in model.recommend(user_id, dataset)], axis=0)

get_recommendations(3, model, user_item_csr)


# Теперь ваша очередь реализовать самые популярные алгоритмы матричных разложений
#
# Что будет оцениваться:
# 1. Корректность алгоритма
# 2. Качество получившихся симиларов
# 3. Качество итоговых рекомендаций для юзера

# Base MF class

# +
class MF:
    def __init__(self, size, factors, reg_param, max_iter, user_label='user_id', item_label='movie_id', target_label='rating'):
        self.users_sz = size[0]
        self.items_sz = size[1]
        self.factors = factors
        
        self.max_iter = max_iter
        self.reg_param = reg_param
        
        self.users_m = np.random.uniform(0.0, 1 / np.sqrt(factors), (self.users_sz, factors))
        self.items_m = np.random.uniform(0.0, 1 / np.sqrt(factors), (self.items_sz, factors))
        
        self.user_label = user_label
        self.item_label = item_label
        self.target_label = target_label
        
    def similar_items(self, item_id, amount=10):
        distances = np.linalg.norm(self.items_m - self.items_m[item_id], axis=1)
        return list(zip(np.argsort(distances), list(sorted(distances))))[:amount]
    
    def recommend(self, user_id, data, amount=10):
        ratings = self.users_m[user_id] @ self.items_m.T
        non_zeros = data[user_id].nonzero()[1]
        return list(filter(lambda x: x[0] not in non_zeros, zip(np.argsort(ratings), list(sorted(ratings)))))[-amount:]


# -

# ### Задание 1. Не использую готовые решения, реализовать SVD разложение используя SGD на explicit данных

# +
class SVD(MF):
    def __init__(self, size, factors, learning_rate, reg_param, max_iter, user_bias_param=1e-6, item_bias_param=1e-6, user_label='user_id', item_label='movie_id', target_label='rating'):
        super().__init__(size, factors, reg_param, max_iter, user_label, item_label, target_label)
        
        self.learning_rate = learning_rate
        self.user_bias_param = user_bias_param
        self.item_bias_param = item_bias_param
        
    def fit(self, train):
        self.users_bias = np.zeros(self.users_sz)
        self.items_bias = np.zeros(self.items_sz)
        self.mu = train[self.target_label].mean()
        
        with trange(self.max_iter, desc='Learning...', leave=True) as t:
            for i in t:
            
                user, item, rating = train.iloc[np.random.randint(len(train))][:]
                
                error = (self.users_m[user, :] @ self.items_m[item, :] + self.users_bias[user] + self.items_bias[item] + self.mu) - rating
                
                self.users_bias[user] -= self.learning_rate * (error + self.user_bias_param * self.users_bias[user])
                self.items_bias[item] -= self.learning_rate * (error + self.item_bias_param * self.items_bias[item])
                
                self.users_m[user, :] -= self.learning_rate * (error * self.items_m[item, :] + self.reg_param * self.users_m[user, :])
                self.items_m[item, :] -= self.learning_rate * (error * self.users_m[user, :] + self.reg_param * self.items_m[item, :])
            
                if i % 10000 == 0:
                    self.A = (self.users_m @ self.items_m.T) + self.users_bias[:,np.newaxis] + self.items_bias + self.mu
                    measured = self.A[train[self.user_label], train[self.item_label]]
                    rmse = np.sqrt(np.mean((measured - train[self.target_label].to_numpy())**2))
                    t.set_description("RMSE: " + str(rmse))
                    t.refresh()
                
        self.A = (self.users_m @ self.items_m.T) + self.users_bias[:, np.newaxis] + self.items_bias + self.mu
            
            
        

# + tags=[]
svd = SVD((np.max(ratings['user_id']) + 1, np.max(ratings['movie_id']) + 1), factors=64, learning_rate=1e-2, reg_param=1e-5, max_iter=int(1e7))

svd.fit(ratings)
# -

get_similars(0, svd)

users = ratings["user_id"]
movies = ratings["movie_id"]
explicit_user_item_csr = sp.coo_matrix((np.ones_like(users), (users, movies))).tocsr()

get_user_history(3, ratings)

get_recommendations(3, svd, explicit_user_item_csr)


# ### Задание 2. Не использую готовые решения, реализовать матричное разложение используя ALS на implicit данных

# +

class ALS(MF):
    def fit(self, train):
        
        train_arr = train.toarray()
        rows, cols = train.nonzero()
        
        reg_matrix = self.reg_param * sp.identity(self.factors)
        
        identity_items = sp.identity(self.items_sz)
        identity_users = sp.identity(self.users_sz)
        
        
        with trange(self.max_iter, desc='Learning...', leave=True) as t:
            self.A = self.users_m @ self.items_m.T
            measured = self.A[rows, cols]
            rmse = np.sqrt(np.mean((measured - train_arr[rows, cols])**2))
            t.set_description("RMSE: " + str(rmse))
            t.refresh()
                
            for i in t:
                
                items_t = self.items_m.T
                item_x_itemt = items_t @ self.items_m
                for user in range(self.users_sz):
                    cur_user_ratings = train_arr[user, :]
                    
                    confidence_m = sp.diags(10 * cur_user_ratings)
                    Cu = items_t @ sp.csr_matrix.dot(confidence_m, self.items_m)
                    self.users_m[user, :] = np.linalg.inv(item_x_itemt + Cu + reg_matrix) @ items_t @ (confidence_m + identity_items) @ cur_user_ratings
                
                users_t = self.users_m.T
                user_x_usert = users_t @ self.users_m
                
                for item in range(self.items_sz):
                    cur_item_users = train_arr[:, item]
                    
                    confidence_m = sp.diags(10 * cur_item_users)
                    Ci = users_t @ sp.csr_matrix.dot(confidence_m, self.users_m)
                    self.items_m[item, :] = np.linalg.inv(user_x_usert + Ci + reg_matrix) @ users_t @ (confidence_m + identity_users) @ cur_item_users
                    
                
                self.A = self.users_m @ self.items_m.T
                measured = self.A[rows, cols]
                rmse = np.sqrt(np.mean((measured - train_arr[rows, cols])**2))
                t.set_description("RMSE: " + str(rmse))
                t.refresh()

# +
als = ALS(user_item_csr.shape, factors=64, reg_param=1e-2, max_iter=5)

als.fit(user_item_csr)
# -

get_similars(0, als)

get_user_history(3, implicit_ratings)

get_recommendations(3, als, user_item_csr)

# ### Задание 3. Не использую готовые решения, реализовать матричное разложение BPR на implicit данных

# +
from collections import defaultdict
from numpy.random import choice, randint

class BPR(MF):
    def __init__(self, size, factors, learning_rate, reg_param, max_iter, user_label='user_id', item_label='movie_id', target_label='rating'):
        super().__init__(size, factors, reg_param, max_iter, user_label, item_label, target_label)
        
        self.learning_rate = learning_rate
        
    def fit(self, train):
        train_arr = train.toarray()
        
        positives = defaultdict(list)
        rows, cols = train.nonzero()
        for i, j in zip(rows, cols):
            positives[i].append(j)
        
        
        with trange(self.max_iter, desc='Learning...', leave=True) as t:
            for i in t:
                for user in range(self.users_sz):
                
                    for i in positives[user]:
                        j = self.get_j(user, positives)
                
                        rating = (self.users_m[user, :] @ self.items_m[i, :].T) - (self.users_m[user, :] @ self.items_m[j, :].T)
                        
                        exponent = np.exp(-rating)
                        dsigmoid = exponent / (1 + exponent)
                
                        self.users_m[user, :] =  self.users_m[user, :] + self.learning_rate * (dsigmoid * (self.items_m[i, :] - self.items_m[j, :]) + self.reg_param * self.users_m[user, :])
                        self.items_m[i, :] =  self.items_m[i, :] + self.learning_rate * (dsigmoid * self.users_m[user, :] + self.reg_param * self.items_m[i, :])
                        self.items_m[j, :] =  self.items_m[j, :] + self.learning_rate * (-dsigmoid * self.users_m[user, :] + self.reg_param * self.items_m[j, :])
                
                self.A = self.users_m @ self.items_m.T
                measured = self.A[rows, cols]
                rmse = np.sqrt(np.mean((measured - train_arr[rows, cols])**2))
                t.set_description("RMSE: " + str(rmse))
                t.refresh()
                
            self.A = self.users_m @ self.items_m.T
        
    
    def get_j(self, user, positives):
        j = randint(self.items_sz)
        while j in positives[user]:
            j = randint(self.items_sz)
        return j
                
                
                
        
        
        
        

# +
bpr = BPR(user_item_csr.shape, factors=64, learning_rate=1e-2, reg_param=1e-5, max_iter=150)

bpr.fit(user_item_csr)
# -

get_similars(0, bpr)

get_recommendations(3, bpr, user_item_csr)


# ### Задание 4. Не использую готовые решения, реализовать матричное разложение WARP на implicit данных

class WARP(BPR):
    def __init__(self, size, factors, learning_rate, reg_param, max_iter, max_sampled, user_label='user_id', item_label='movie_id', target_label='rating'):
        super().__init__(size, factors, learning_rate, reg_param, max_iter, user_label, item_label, target_label)
        
        self.max_sampled = max_sampled

    def get_j(self, user, positives):
        j = randint(self.items_sz)
        highest_rating = self.users_m[user, :] @ self.items_m[j, :].T
        for _ in range(self.max_sampled):
            candidate = randint(self.items_sz)
            while candidate in positives[user]:
                candidate = randint(self.items_sz)
            candidate_rating = self.users_m[user, :] @ self.items_m[candidate, :].T
            if highest_rating < candidate_rating:
                highest_rating = candidate_rating
                j = candidate
        return j


# +
warp = WARP(user_item_csr.shape, factors=64, learning_rate=1e-3, reg_param=1e-3, max_iter=40, max_sampled=50)

warp.fit(user_item_csr)
# -

get_similars(0, warp)

get_recommendations(3, warp, user_item_csr)



StarcoderdataJupyterScriptsDedupFiltered