import numpy as np
import matplotlib.pyplot as plt
import scipy.io
import scipy.optimize

def numerical_gradient(f, x, dx=1e-6):
    perturb = np.zeros(x.size)
    result  = np.zeros(x.size)
    for i in range(x.size):
        perturb[i] = dx
        result[i] = (f(x+perturb) - f(x-perturb)) / (2*dx)
        perturb[:] = 0
    return result

%matplotlib inline
#%qtconsole

"""
Explanation: Exercise 8 | Collaborative Filtering
End of explanation
"""


ex8_movies = scipy.io.loadmat('ex8_movies.mat')
Y = ex8_movies['Y']
R = ex8_movies['R'].astype(bool)
print(Y.shape, R.shape)

"""
Explanation: Part 1: Loading movie ratings dataset
You will start by loading the movie ratings dataset to understand the
  structure of the data.
End of explanation
"""


np.mean(Y[0, R[0, :]])

"""
Explanation: Y is a 1682x943 matrix, containing ratings (1-5) of 1682 movies on 
  943 users.
R is a 1682x943 matrix, where R[i,j] = 1 if and only if user j gave a
  rating to movie i.
From the matrix, we can compute statistics like average rating.
Average rating for movie 1 (Toy Story):
End of explanation
"""


plt.imshow(Y)
plt.xlabel('Movies')
plt.ylabel('Users')

"""
Explanation: We can "visualize" the ratings matrix by plotting it as an image.
End of explanation
"""


movieParams = scipy.io.loadmat('ex8_movieParams.mat')
Theta = movieParams['Theta']
num_movies = movieParams['num_movies']
X = movieParams['X']
num_features = movieParams['num_features']
num_users = movieParams['num_users']

num_users = 4
num_movies = 5
num_features = 3
X = X[:num_movies, :num_features]
Theta = Theta[:num_users, :num_features]
Y = Y[:num_movies, :num_users]
R = R[:num_movies, :num_users]

def cofi_cost_function(params, Y, R, num_users, num_movies, num_features, lambda_):
    #COFICOSTFUNC Collaborative filtering cost function
    #   [J, grad] = COFICOSTFUNC(params, Y, R, num_users, num_movies, ...
    #   num_features, lambda) returns the cost and gradient for the
    #   collaborative filtering problem.
    #
    
    # Unfold the U and W matrices from params
    X = params[:num_movies*num_features].reshape(num_movies, num_features)
    Theta = params[num_movies*num_features:].reshape(num_users, num_features)
    
    # You need to return the following values correctly
    J = 0
    X_grad = np.zeros(X.shape)
    Theta_grad = np.zeros(Theta.shape)
    
    # ====================== YOUR CODE HERE ======================
    # Instructions: Compute the cost function and gradient for collaborative
    #               filtering. Concretely, you should first implement the cost
    #               function (without regularization) and make sure it is
    #               matches our costs. After that, you should implement the 
    #               gradient and use the checkCostFunction routine to check
    #               that the gradient is correct. Finally, you should implement
    #               regularization.
    #
    # Notes: X - num_movies  x num_features matrix of movie features
    #        Theta - num_users  x num_features matrix of user features
    #        Y - num_movies x num_users matrix of user ratings of movies
    #        R - num_movies x num_users matrix, where R(i, j) = 1 if the 
    #            i-th movie was rated by the j-th user
    #
    # You should set the following variables correctly:
    #
    #        X_grad - num_movies x num_features matrix, containing the 
    #                 partial derivatives w.r.t. to each element of X
    #        Theta_grad - num_users x num_features matrix, containing the 
    #                     partial derivatives w.r.t. to each element of Theta
    #

    
    
    
    
    
    
    
    
    
    # =============================================================
    
    grad = np.r_[X_grad.flat, Theta_grad.flat]
    return J, grad

"""
Explanation: Part 2: Collaborative Filtering Cost Function
You will now implement the cost function for collaborative filtering.
  To help you debug your cost function, we have included set of weights
  that we trained on that. Specifically, you should complete the code in 
  cofi_cost_function to return J.
End of explanation
"""


cofi_cost_function(np.r_[X.flat, Theta.flat], Y, R, num_users, num_movies, num_features, 0)[0]

def check_cost_function(lambda_=0):
    X = np.random.rand(4, 3)
    Theta = np.random.rand(5, 3)
    Y = X.dot(Theta.T)
    Y[np.random.rand(*Y.shape) > 0.5] = 0
    R = np.zeros(Y.shape).astype(bool)
    R[Y != 0] = True
    
    X = np.random.rand(4, 3)
    Theta = np.random.rand(5, 3)
    
    num_users = Y.shape[1]
    num_movies = Y.shape[0]
    num_features = Theta.shape[1]
    
    def cost(params):
        return cofi_cost_function(params, Y, R, num_users, num_movies, num_features, lambda_)[0]
    
    num_grad = numerical_gradient(cost, np.r_[X.flat, Theta.flat])
    
    cost, grad = cofi_cost_function(np.r_[X.flat, Theta.flat], Y, R, num_users, num_movies, num_features, lambda_)
    print(np.c_[num_grad, grad])
    print('Average difference:', np.mean((num_grad - grad) ** 2))

"""
Explanation: The cost at the given parameters should be about 22.22.
End of explanation
"""


check_cost_function()

"""
Explanation: Part 3: Collaborative Filtering Gradient
Once your cost function matches up with ours, you should now implement 
  the collaborative filtering gradient function. Specifically, you should 
  complete the code in cofi_cost_function to return the grad argument.
End of explanation
"""


cofi_cost_function(np.r_[X.flat, Theta.flat], Y, R, num_users, num_movies, num_features, 1.5)[0]

"""
Explanation: Part 4: Collaborative Filtering Cost Regularization
Now, you should implement regularization for the cost function for 
  collaborative filtering. You can implement it by adding the cost of
  regularization to the original cost computation.
Regularized cost function. The cost at the given parameters with $\lambda=1.5$ should be about 31.34:
End of explanation
"""


check_cost_function(1.5)

"""
Explanation: Part 5: Collaborative Filtering Gradient Regularization
Once your cost matches up with ours, you should proceed to implement 
  regularization for the gradient.
End of explanation
"""


with open('movie_ids.txt', encoding='latin1') as f:
    movies = [line.strip().split(' ', 1)[1] for line in f]

len(movies)

"""
Explanation: Part 6: Entering ratings for a new user
Before we will train the collaborative filtering model, we will first
  add ratings that correspond to a new user that we just observed. This
  part of the code will also allow you to put in your own ratings for the
  movies in our dataset!
End of explanation
"""


ratings = {1: 4, 98: 2, 7: 3, 12: 5, 54: 4, 64: 5, 66: 3, 69: 5, 183: 4, 226: 5, 355: 5}

"""
Explanation: We have selected a few movies we liked / did not like and the ratings we
 gave are as follows:
End of explanation
"""


my_ratings = np.zeros(1682)
for k, v in ratings.items():
    my_ratings[k-1] = v

for i, v in enumerate(my_ratings):
    if v != 0:
        print('Rated {} for {}'.format(v, movies[i]))

"""
Explanation: For example, Toy Story (1995) has ID 1, and we rated it 4.
End of explanation
"""


ex8_movies = scipy.io.loadmat('ex8_movies.mat')
Y = ex8_movies['Y']
R = ex8_movies['R'].astype(bool)
print(Y.shape, R.shape)

"""
Explanation: Part 7: Learning Movie Ratings
Now, you will train the collaborative filtering model on a movie rating 
  dataset of 1682 movies and 943 users
End of explanation
"""


#  Add our own ratings to the data matrix
Y = np.c_[my_ratings, Y]
R = np.c_[my_ratings != 0, R]

print(Y.shape, R.shape)

def normalize_ratings(Y, R):
    m, n = Y.shape
    Ymean = np.zeros(m)
    Ynorm = np.zeros(Y.shape)
    for i in range(m):
        idx = R[i, :]
        Ymean[i] = np.mean(Y[i, idx])
        Ynorm[i, idx] = Y[i, idx] - Ymean[i]
    return Ynorm, Ymean

Ynorm, Ymean = normalize_ratings(Y, R)

num_users = Y.shape[1]
num_movies = Y.shape[0]
num_features = 10
# Set Regularization
lambda_ = 10

# Set Initial Parameters
X = np.random.rand(num_movies, num_features)
Theta = np.random.rand(num_users, num_features)

initial_params = np.r_[X.flat, Theta.flat]

def cost(params):
    return cofi_cost_function(params, Y, R, num_users, num_movies, num_features, lambda_)

res = scipy.optimize.minimize(cost, initial_params, jac=True, method='L-BFGS-B', 
                              options=dict(maxiter=100, disp=True))

"""
Explanation: Y is a 1682x943 matrix, containing ratings (1-5) of 1682 movies by 
  943 users
R is a 1682x943 matrix, where R(i,j) = 1 if and only if user j gave a
  rating to movie i
End of explanation
"""


X = res.x[:num_movies*num_features].reshape(num_movies, num_features)
Theta = res.x[num_movies*num_features:].reshape(num_users, num_features)

p = X.dot(Theta.T)

my_predictions = p[:, 0] + Ymean
sorted_predictions = sorted(enumerate(my_predictions), key=lambda x: x[1], reverse=True)

print('\n'.join('Predicting rating {:.1f} for movie {}'.format(rating, movies[i])
               for i, rating in sorted_predictions[:10]))


"""
Explanation: Unfold the returned theta back into U and W
End of explanation
"""


