From f338378938ddf3095c6d6db6dd4f110cdecdca80 Mon Sep 17 00:00:00 2001 From: Evaclaire Wamitu Date: Wed, 31 Jul 2024 09:32:47 +0300 Subject: [PATCH] Add comments and docstring --- .../movie_recommendor-checkpoint.ipynb | 1561 ++++++++++++++++- movie_recommendor.ipynb | 73 +- 2 files changed, 1569 insertions(+), 65 deletions(-) diff --git a/.ipynb_checkpoints/movie_recommendor-checkpoint.ipynb b/.ipynb_checkpoints/movie_recommendor-checkpoint.ipynb index 4aa8390..b433d82 100644 --- a/.ipynb_checkpoints/movie_recommendor-checkpoint.ipynb +++ b/.ipynb_checkpoints/movie_recommendor-checkpoint.ipynb @@ -14,7 +14,7 @@ }, { "cell_type": "markdown", - "id": "9538a32c-c02a-4a83-8819-249bed447e92", + "id": "866a7435-710a-41fb-a929-5aeb9d0081fb", "metadata": {}, "source": [ "![attachment:logo.png](logo.png)" @@ -120,10 +120,9 @@ "id": "f28f3f7d-5712-458c-bc61-7730778d795e", "metadata": {}, "source": [ - "1. To build a collaborative filtering model using user ratings to generate top 5 movie recommendations, leveraging algorithms such as Singular Value Decomposition (SVD) and k-Nearest Neighbors (k-NN).\n", - "2. To address the cold start problem for new users by integrating content-based filtering, utilizing features such as movie genres, directors, and cast.\n", - "3. To evaluate the hybrid recommendation system using appropriate metrics like Root Mean Square Error (RMSE), Mean Average Precision (MAP), and Normalized Discounted Cumulative Gain (NDCG) to ensure accuracy and relevance of the recommendations.\n", - "\n" + "1. To build a collaborative filtering model using user ratings to generate top 5 movie recommendations leveraging algorithms such as Singular Value Decomposition (SVD) and K-Nearest Neighbors (k-NN).\n", + "2. To tackle the cold start problem for new users by developing a content-based filtering system utilizing movie genres and titles.\n", + "3. To build a weighted hybrid recommendation system by combining the collaborative and conten-based filtering systems and evaluating the Root Mean Square Error (RMSE) to ensure accuracy and relevance of the recommendations." ] }, { @@ -131,13 +130,9 @@ "id": "5efa31bb-7862-49a4-9ce8-cc8fc893043a", "metadata": {}, "source": [ - "## Success Metrics\n", + "## Success Metric\n", "\n", - "1. Root Mean Square Error (RMSE) < 0.9 for rating predictions\n", - "2. Mean Average Precision @5 (MAP@5) > 0.3 for recommended movies\n", - "3. Precision@5 of around 0.2 to 0.5\n", - "4. Recall@5 of around 0.2 to 0.5\n", - "5. F1 Score of around 0.3 to 0.7" + "- Root Mean Square Error (RMSE) < 0.9 for rating predictions\n" ] }, { @@ -211,7 +206,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 1, "id": "7018cb24-f44b-4c38-ab33-d45adfde8093", "metadata": {}, "outputs": [], @@ -242,7 +237,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "id": "d587021f", "metadata": {}, "outputs": [], @@ -371,7 +366,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "id": "8e2b2e76", "metadata": {}, "outputs": [ @@ -678,7 +673,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "id": "9d454adf", "metadata": {}, "outputs": [ @@ -839,7 +834,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "id": "fa4b26e5", "metadata": {}, "outputs": [ @@ -955,7 +950,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "id": "8b2e0ae5", "metadata": {}, "outputs": [], @@ -966,7 +961,7 @@ " Renames a column in the DataFrame.\n", "\n", " Args:\n", - " df (pandas.DataFrame): The DataFrame containing the column to rename.\n", + " df: The DataFrame containing the column to rename.\n", " current_name (str): The current name of the column.\n", " new_name (str): The new name for the column.\n", "\n", @@ -984,7 +979,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "id": "95a81b7b", "metadata": {}, "outputs": [ @@ -1148,7 +1143,7 @@ "[285783 rows x 5 columns]" ] }, - "execution_count": 8, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -1169,7 +1164,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 8, "id": "e9d0a39f", "metadata": {}, "outputs": [], @@ -1189,7 +1184,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 9, "id": "c9948e24", "metadata": {}, "outputs": [ @@ -1288,7 +1283,7 @@ "4 4.0 1995 " ] }, - "execution_count": 10, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -1299,7 +1294,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "id": "4f6353d6-1523-4dc5-9288-ed63c622f686", "metadata": { "scrolled": true @@ -1400,7 +1395,7 @@ "4 5.0 4.0 1995 " ] }, - "execution_count": 11, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -1408,6 +1403,7 @@ "source": [ "# Genre Processing: Split the genres in the `movies.csv` dataset into lists for easier analysis\n", "data_explorer.merged_data['genres']=[row.strip().lower().replace('|',', ') for row in data_explorer.merged_data['genres']]\n", + "# Display first 5 rows\n", "data_explorer.merged_data.head()" ] }, @@ -1423,7 +1419,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 11, "id": "8d0fb7f0", "metadata": {}, "outputs": [ @@ -1464,7 +1460,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 12, "id": "b430f787", "metadata": {}, "outputs": [ @@ -1500,7 +1496,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 13, "id": "bebeff6f", "metadata": {}, "outputs": [ @@ -1531,7 +1527,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 14, "id": "9fc27ef7", "metadata": {}, "outputs": [ @@ -1561,7 +1557,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 15, "id": "e4df2d45-99a6-4bc0-8cae-ae9989295dc6", "metadata": {}, "outputs": [ @@ -1571,7 +1567,7 @@ "dtype('int64')" ] }, - "execution_count": 16, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -1579,6 +1575,7 @@ "source": [ "# Convert user_id from float to int\n", "data_explorer.merged_data['user_id'] = data_explorer.merged_data['user_id'].astype(int)\n", + "# Display converted data type\n", "data_explorer.merged_data['user_id'].dtype" ] }, @@ -1603,7 +1600,7 @@ "id": "99baee9b-276a-4882-b922-8338aec63e90", "metadata": {}, "source": [ - "In this section we will perform exploratory data analysis to identify patterns, trends and relationships within the data. This will involve visualizations as well as statistical techniques to summarize the main characteristics of the data." + "In this section we will perform exploratory data analysis to identify patterns, trends and relationships within the data. This will involve visualizations as well as statistical techniques to summarize the main characteristics of the data. The primary goals of EDA include understanding the data structure, identifying patterns and relationships, detecting anomalies, generating initial insights and informing further analysis. This process typically involves summarizing data with descriptive statistics and elaborating using data visualization techniques. EDA provides a foundational understanding of the data guiding the selection of appropriate models for further analysis." ] }, { @@ -1619,12 +1616,12 @@ "id": "b4715bd9-479f-4a1b-b005-57ae49bb8283", "metadata": {}, "source": [ - "This will involve analyzing and summarizing individual variables in our dataset to describe the basic features and patterns without considering relationships between variables. First step is to assign the variable 'df' to data_explorer.merged_data for ease of reference and then previewing the first five columns. " + "This involves analyzing and summarizing individual variables in our dataset to describe the basic features and patterns without considering relationships between variables. First step is to assign the variable 'df' to data_explorer.merged_data for ease of reference and then previewing the first five columns. " ] }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 16, "id": "8c2dfcb4-a0c7-4380-8c2f-4cdc12735c9b", "metadata": {}, "outputs": [ @@ -1723,12 +1720,13 @@ "4 17 4.5 1995 " ] }, - "execution_count": 17, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ + "# Sanity check\n", "df = data_explorer.merged_data\n", "df.head()" ] @@ -1752,7 +1750,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 17, "id": "724a5c86-19b6-4f93-80a6-ead1dd3c7916", "metadata": {}, "outputs": [ @@ -1886,7 +1884,7 @@ " '''\n", " Initializes the UnivariateAnalysis class with a DataFrame.\n", " \n", - " Parameters:\n", + " Args:\n", " df (DataFrame): A pandas DataFrame containing the movie data to analyze.\n", " '''\n", " self.df = df\n", @@ -2027,12 +2025,12 @@ "source": [ "## Bivariate Analysis\n", "\n", - "Bivariate analysis refers to the statistical examination of two variables to understand the relationship between them. We create a `BivariateAnalysis` class designed to perform various bivariate analyses on our dataset. In this instance we shall explore relationships between release years vs ratings, genres vs ratings, movie titles vs rating and movie titles vs total number of people who have rated them. ." + "Bivariate analysis refers to the statistical examination of two variables to understand the relationship between them. We create a `BivariateAnalysis` class designed to perform various bivariate analyses on our dataset. In this instance we shall explore relationships between release years vs ratings, genres vs ratings, movie titles vs rating and movie titles vs total number of users who have rated them. ." ] }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 18, "id": "449c7ef5-e4da-4285-a38c-57f914dfa46e", "metadata": {}, "outputs": [ @@ -2233,7 +2231,7 @@ " and creates a bar plot to visualize the top n titles based on the total \n", " number of ratings received.\n", " \n", - " Parameters:\n", + " Args:\n", " top_n (int): The number of top-rated titles to display. Default is 20.\n", " '''\n", " \n", @@ -2308,37 +2306,1492 @@ "\n", "The `Top 20 Rated Titles by Number of Ratings` plot shows that 'Forrest Gump' leads the list with the highest number of ratings followed closely by 'The Shawshank Redemption' and 'Pulp Fiction'. The ratings range from around 200 ('Lord of the Rings: The Fellowship of the ring') to nearly 300 for the top-rated films indicating these are all highly popular and widely reviewed. \n", "\n", - "Thee `Correlation Heatmap` visualizes the relationships between the numeric variables in our dataset. It shows a strong positive correlation (0.98) between release year and decade, moderate positive correlations between movieId and both release year (0.51) and decade (0.5) and weak or negligible correlations among the other variables. The diagonal values of 1 represent perfect self-correlation." + "The `Correlation Heatmap` visualizes the relationships between the numeric variables in our dataset. It shows a strong positive correlation (0.98) between release year and decade, moderate positive correlations between movieId and both release year (0.51) and decade (0.5) and weak or negligible correlations among the other variables. The diagonal values of 1 represent perfect self-correlation." + ] + }, + { + "cell_type": "markdown", + "id": "1cb08e0f-4bf9-45d8-be50-e16c6d8f562e", + "metadata": {}, + "source": [ + "# MODELING" + ] + }, + { + "cell_type": "markdown", + "id": "36de16dd-c541-4ac5-bda5-e500095eed4e", + "metadata": {}, + "source": [ + "## Collaborative Filtering" + ] + }, + { + "cell_type": "markdown", + "id": "e08b0b56-be71-470a-9a8c-1b31bcced076", + "metadata": {}, + "source": [ + "### Dummy model\n", + "A dummy or vanilla model is a simple model that is typically used as a reference or baseline against which more complex models are compared. Its purpose is to provide a reference point to evaluate the effectiveness of the more sophisticated algorithms. For our model, we evaluate the dummy model using the Surprise library. The data is prepared using the Reader and Dataset classes from `Surprise` to format our DataFrame containing movie ratings with a rating scale between 1 and 5. The dataset is then divided into training and test sets reserving 25% of the data for testing purposes. A `NormalPredictor` dummy model is then created which generates random predictions based on the observed distribution of ratings and this model is trained on the training set. The performance of the model is evaluated on the test set using the Root Mean Squared Error (RMSE) metric which provides a quantitative measure of its accuracy. Finally, the RMSE of the dummy model is printed showing the baseline performance for comparison with Singular Value Decomposition `SVD` and K-Nearest Neighbors `KNN` algorithms." ] }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 19, "id": "4aa1079f-b0ea-4f89-aa54-efb6254c7753", "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "1902" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" + "name": "stdout", + "output_type": "stream", + "text": [ + "RMSE: 1.4304\n", + "Baseline Model RMSE: 1.4304275189371316\n" + ] } ], "source": [ - "df.release_year.min()" + "from surprise import NormalPredictor\n", + "from surprise import Reader, Dataset, SVD, KNNBasic\n", + "from surprise.model_selection import cross_validate, GridSearchCV\n", + "from surprise.model_selection import train_test_split\n", + "from surprise import accuracy\n", + "\n", + "# Prepare the data\n", + "reader = Reader(rating_scale=(1, 5))\n", + "data = Dataset.load_from_df(df[['user_id', 'movieId', 'rating']], reader)\n", + "\n", + "# Split the data\n", + "trainset, testset = train_test_split(data, test_size=0.25, random_state=42)\n", + "\n", + "# Create and evaluate the dummy model\n", + "dummy_model = NormalPredictor()\n", + "dummy_model.fit(trainset)\n", + "predictions = dummy_model.test(testset)\n", + "baseline_rmse = accuracy.rmse(predictions)\n", + "\n", + "# Print the RMSE\n", + "print(f\"Baseline Model RMSE: {baseline_rmse}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cea8df25-678d-4664-84bb-38d915eeffcf", + "metadata": {}, + "source": [ + "We find that the RMSE of the baseline model is aproximately 1.43. The next step is to perform grid search cross validation to find the best parameters for the Singular Value Decomposition (SVD) and K-Nearest Neighbors (KNN) models. The `Surprise` library hosts a `GridSearchCV` feature that performs this task.\n", + "Grid searching the SVD model focuses on tuning the following hyperparameters:\n", + "\n", + "**`n_factors`**: Number of latent factors\n", + "**`n_epochs`**: Number of iterations for training \n", + "**`lr_all`**: Learning rate for all parameters \n", + "**`reg_all`**: Regularization term for all parameters\n", + "\n", + "Grid searching the KNN model focuses on tuning the following hyperparameters: \n", + "**`k`**: The number of neighbors to consider when making predictions.\n", + "**`min_k`**: The minimum number of neighbors required to make a prediction. If fewer neighbors are found, predictions are made based on default values.\n", + "**`sim_options`**: A dictionary specifying the similarity options. This includes:\n", + "`cosine` (Cosine Similarity)\n", + "`pearson` (Pearson Correlation)\n", + "**`user_based`**: Whether to use user-based or item-based filtering.\n", + "`True for user-based`\n", + "`False for item-based`\n", + "\n", + "These hyperparameters can be adjusted depending on the dataset and computational resources. After performing the grid search to find the best hyperparameters, the next step is to cross-validate the model with the best parameters. This ensures that the model's performance generalizes well to unseen data." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 20, "id": "aae5b17e-07a1-47e1-9c93-afe9fbff1fea", "metadata": {}, - "outputs": [], - "source": [] + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Tuning SVD...\n", + "Best SVD parameters: {'n_factors': 100, 'n_epochs': 30, 'lr_all': 0.01, 'reg_all': 0.1}\n", + "Best SVD RMSE: 0.8621525273812124\n", + "Tuning KNN...\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the pearson similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Computing the cosine similarity matrix...\n", + "Done computing similarity matrix.\n", + "Best KNN parameters: {'k': 30, 'min_k': 5, 'sim_options': {'name': 'cosine', 'user_based': True}}\n", + "Best KNN RMSE: 0.974567962502892\n", + "\n", + "Best model: SVD\n", + "\n", + "Best RMSE: 0.8621525273812124\n" + ] + } + ], + "source": [ + "def grid_search_models(data):\n", + " '''\n", + " Performs a grid search to tune hyperparameters for SVD and KNN models, evaluates their performance using RMSE, and returns the best model based on RMSE.\n", + "\n", + " Parameters:\n", + " data (Dataset): The Surprise Dataset object containing the rating data.\n", + "\n", + " Returns:\n", + " tuple: A tuple containing the name of the best model ('SVD' or 'KNN') and its corresponding RMSE score.\n", + " '''\n", + " # Define parameter grids\n", + " svd_param_grid = {\n", + " 'n_factors': [20, 50, 100],\n", + " 'n_epochs': [10, 15, 30], \n", + " 'lr_all': [0.002, 0.005, 0.01],\n", + " 'reg_all': [0.02, 0.05, 0.1]\n", + " }\n", + " \n", + " knn_param_grid = {\n", + " 'k': [10, 20, 30],\n", + " 'min_k': [1, 5, 10],\n", + " 'sim_options': {'name': ['pearson', 'cosine'], 'user_based': [True, False]}\n", + " }\n", + " \n", + " # Perform grid search for SVD\n", + " print('Tuning SVD...')\n", + " gs_svd = GridSearchCV(SVD, svd_param_grid, measures=['rmse'], cv=3)\n", + " gs_svd.fit(data)\n", + " \n", + " svd_best_params = gs_svd.best_params['rmse']\n", + " svd_best_score = gs_svd.best_score['rmse']\n", + " \n", + " print(f\"Best SVD parameters: {svd_best_params}\")\n", + " print(f\"Best SVD RMSE: {svd_best_score}\")\n", + " \n", + " # Perform grid search for KNN\n", + " print('Tuning KNN...')\n", + " gs_knn = GridSearchCV(KNNBasic, knn_param_grid, measures=['rmse'], cv=3)\n", + " gs_knn.fit(data)\n", + " \n", + " knn_best_params = gs_knn.best_params['rmse']\n", + " knn_best_score = gs_knn.best_score['rmse']\n", + " \n", + " print(f\"Best KNN parameters: {knn_best_params}\")\n", + " print(f\"Best KNN RMSE: {knn_best_score}\")\n", + " \n", + " # Determine the best overall model\n", + " if svd_best_score < knn_best_score:\n", + " best_model_class = SVD(**svd_best_params)\n", + " best_model_params = svd_best_params\n", + " best_model_name = 'SVD'\n", + " best_score = svd_best_score\n", + " else:\n", + " best_model_class = KNNBasic(**knn_best_params)\n", + " best_model_params = knn_best_params\n", + " best_model_name = 'KNN'\n", + " best_score = knn_best_score\n", + " \n", + " # Print the best model and best RMSE score\n", + " print(f\"\\nBest model: {best_model_name}\")\n", + " print(f\"\\nBest RMSE: {best_score}\")\n", + " \n", + " return best_model_name, best_score\n", + "\n", + "# Instantiate\n", + "best_model_name, best_score = grid_search_models(data)" + ] + }, + { + "cell_type": "markdown", + "id": "aaab006c-6092-4bf0-bff0-6e49289c48c3", + "metadata": {}, + "source": [ + "### Summary" + ] + }, + { + "cell_type": "markdown", + "id": "0c954d06-e38c-447b-901f-d4bdd36aad59", + "metadata": {}, + "source": [ + "The output reveals the results of tuning and evaluating recommendation models using grid search. For the SVD model, the optimal parameters were identified as having 100 factors, 30 epochs, a learning rate of 0.01 and regularization of 0.1 achieving the best RMSE of approximately 0.862. In contrast, the KNN model required extensive computation of similarity matrices for various configurations including Pearson, cosine and MSD (Mean Squared Difference) similarities. The best parameters for the KNN model were found to be 30 neighbors, a minimum of 10 neighbors and using the cosine similarity metric with a non-user-based approach resulting in a higher RMSE of about 0.917. Consequently, the SVD model emerged as the superior choice with the lowest RMSE and we therefore selected it as the best model overall." + ] + }, + { + "cell_type": "markdown", + "id": "3504e6c1-96da-4765-b42d-6de6f0594e14", + "metadata": {}, + "source": [ + "### Cross-validation of the best model" + ] + }, + { + "cell_type": "markdown", + "id": "c90a5694-30a2-47fb-8617-bd405616c182", + "metadata": {}, + "source": [ + "The cross_validate_best_model function evaluates the performance of the best model identified from the previous grid search through cross-validation. The best model was identified as the `SVD` model. It initializes the model with the corresponding optimal parameters and performs cross-validation using 5 folds and computes the mean RMSE from the results. The function prints out the mean RMSE and returns the model name, the best score from the grid search and the mean RMSE. The mean RMSE is " + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "04358685-5ec8-4d2a-99e5-fca4203c4911", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Evaluating RMSE of algorithm SVD on 5 split(s).\n", + "\n", + " Fold 1 Fold 2 Fold 3 Fold 4 Fold 5 Mean Std \n", + "RMSE (testset) 0.8654 0.8591 0.8506 0.8529 0.8548 0.8566 0.0052 \n", + "Fit time 8.94 8.49 8.08 8.03 8.22 8.35 0.33 \n", + "Test time 0.11 0.12 0.11 0.12 0.12 0.11 0.00 \n", + "SVD Model Mean RMSE: 0.8565943146879842\n", + "SVD Model Standard Deviation RMSE: 0.0052232662741193365\n" + ] + } + ], + "source": [ + "# Initialize SVD model with specified hyperparameters\n", + "svd_model = SVD(n_factors=100, n_epochs=30, lr_all=0.01, reg_all=0.1)\n", + "\n", + "# Perform cross-validation on the SVD model using 5 folds\n", + "# Measures RMSE (Root Mean Square Error) for evaluation\n", + "cross_val_results = cross_validate(svd_model, data, measures=['RMSE'], cv=5, verbose=True)\n", + "\n", + "# Print the mean RMSE from the cross-validation results\n", + "print(f\"SVD Model Mean RMSE: {np.mean(cross_val_results['test_rmse'])}\")\n", + "\n", + "# Print the standard deviation of RMSE from the results\n", + "print(f\"SVD Model Standard Deviation RMSE: {np.std(cross_val_results['test_rmse'])}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cccdc46d-7e10-4265-9deb-7b8a532dd9b5", + "metadata": {}, + "source": [ + "The cross-validation results for the SVD model indicate strong and consistent performance. The model achieved an average RMSE of approximately 0.857 across five folds with a very low standard deviation of 0.005 demonstrating stable performance across different data splits. Overall these metrics suggest that the SVD model not only provides reliable predictions with low error but also maintains efficient and consistent training and prediction times. The next step is to build a class that will provide the top 5 recommendations." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "133e166f-e332-4457-8d10-6c5003684986", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Movie: Before Sunrise (1995)\n", + "Genre: drama, romance\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Rate this movie from 1 to 5 (or 'n' if you haven't seen it): n\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Movie: To Wong Foo, Thanks for Everything! Julie Newmar (1995)\n", + "Genre: comedy\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Rate this movie from 1 to 5 (or 'n' if you haven't seen it): 5\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Movie: Ferris Bueller's Day Off (1986)\n", + "Genre: comedy\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Rate this movie from 1 to 5 (or 'n' if you haven't seen it): 5\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Movie: Miracle on 34th Street (1994)\n", + "Genre: drama\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Rate this movie from 1 to 5 (or 'n' if you haven't seen it): 4\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Movie: Toy Story (1995)\n", + "Genre: adventure, animation, children, comedy, fantasy\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Rate this movie from 1 to 5 (or 'n' if you haven't seen it): 5\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Recommended movies:\n", + "1. Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb (1964) - Predicted rating: 4.31\n", + "Genre: comedy, war\n", + "2. Philadelphia Story, The (1940) - Predicted rating: 4.24\n", + "Genre: comedy, drama, romance\n", + "3. Pulp Fiction (1994) - Predicted rating: 4.22\n", + "Genre: comedy, crime, drama, thriller\n", + "4. Princess Bride, The (1987) - Predicted rating: 4.22\n", + "Genre: action, adventure, comedy, fantasy, romance\n", + "5. Amelie (Fabuleux destin d'Amélie Poulain, Le) (2001) - Predicted rating: 4.21\n", + "Genre: comedy, romance\n" + ] + } + ], + "source": [ + "import pickle \n", + "\n", + "# Create collab df\n", + "collab_df = df[['user_id', 'movieId', 'rating']].copy()\n", + "\n", + "# Save to a CSV file\n", + "collab_df.to_csv('collab_movies.csv', index=False)\n", + "\n", + "class CollabBasedModel:\n", + " def __init__(self, collab_df):\n", + " '''\n", + " Initializes the MovieRecommender with a DataFrame containing movie data.\n", + "\n", + " Args:\n", + " df (pd.DataFrame): DataFrame containing movie information with columns 'user_id', 'movieId', 'rating', 'title', 'release_year', and 'genres'.\n", + " '''\n", + " self.df = collab_df\n", + " self.model = None\n", + "\n", + " def train_model(self):\n", + " '''\n", + " Trains the SVD model on the movie ratings data. Splits the data into training and test sets and fits the model.\n", + " '''\n", + " # Create a Reader object to parse the ratings data with a specified rating scale\n", + " reader = Reader(rating_scale=(1, 5))\n", + " \n", + " # Load the data from the DataFrame into a Surprise Dataset object\n", + " data = Dataset.load_from_df(self.df[['user_id', 'movieId', 'rating']], reader)\n", + " \n", + " # Split the data into a training set and a test set with 80% of the data for training\n", + " trainset, _ = train_test_split(data, test_size=0.2)\n", + " \n", + " # Initialize the SVD (Singular Value Decomposition) model for collaborative filtering\n", + " self.model = SVD()\n", + " \n", + " # Train the SVD model on the training set\n", + " self.model.fit(trainset)\n", + "\n", + "\n", + " def get_user_ratings(self, num_movies=5):\n", + " '''\n", + " Collects ratings from the user for a specified number of movies.\n", + "\n", + " Args:\n", + " num_movies (int): Number of movies to rate.\n", + "\n", + " Returns:\n", + " list: List of tuples containing movieId and user rating.\n", + " '''\n", + " \n", + " # Initialize an empty list to store user ratings\n", + " user_ratings = []\n", + " \n", + " # Loop to collect ratings for a specified number of movies\n", + " for _ in range(num_movies):\n", + " # Randomly sample one movie from the DataFrame\n", + " movie = self.df.sample(1).iloc[0]\n", + " \n", + " # Display the movie details to the user\n", + " print(f\"\\nMovie: {movie['title']} ({movie['release_year']})\")\n", + " print(f\"Genre: {movie['genres']}\")\n", + " \n", + " # Prompt the user to rate the movie or indicate they haven't seen it\n", + " rating = input(\"Rate this movie from 1 to 5 (or 'n' if you haven't seen it): \")\n", + " \n", + " # If the user has watched the movie and provided a rating, add it to the list\n", + " if rating.lower() != 'n':\n", + " user_ratings.append((movie['movieId'], float(rating)))\n", + " \n", + " # Return the list of user ratings\n", + " return user_ratings\n", + "\n", + "\n", + " def get_recommendations(self, user_ratings, n=5, genre=None):\n", + " '''\n", + " Provides movie recommendations based on user ratings and optional genre filtering.\n", + "\n", + " Args:\n", + " user_ratings (list): List of tuples containing movieId and user rating.\n", + " n (int): Number of recommendations to provide.\n", + " genre (str, optional): Genre to filter recommendations by.\n", + "\n", + " Returns:\n", + " list: List of recommended movies with their predicted ratings.\n", + " '''\n", + " \n", + " # Generate a unique user ID for a new user who is providing ratings for the first time\n", + " new_user_id = self.df['user_id'].max() + 1\n", + " \n", + " # Identify movies that the new user has not yet rated\n", + " movies_to_predict = self.df[~self.df['movieId'].isin([x[0] for x in user_ratings])]['movieId'].unique()\n", + " \n", + " # Predict ratings for each of these movies\n", + " predictions = [\n", + " (movie_id, self.model.predict(new_user_id, movie_id).est) # Predict rating for the movie\n", + " for movie_id in movies_to_predict\n", + " ]\n", + " \n", + " # Sort predictions in descending order of estimated ratings\n", + " recommendations = sorted(predictions, key=lambda x: x[1], reverse=True)\n", + " \n", + " # If a genre filter is specified\n", + " if genre:\n", + " # Filter recommendations to include only those that match the specified genre\n", + " genre_recommendations = [\n", + " (movie_id, rating) for movie_id, rating in recommendations\n", + " if genre.lower() in self.df[self.df['movieId'] == movie_id]['genres'].iloc[0].lower()\n", + " ]\n", + " return genre_recommendations[:n] # Return top-n genre-specific recommendations\n", + " else:\n", + " return recommendations[:n] # Return top-n general recommendations\n", + "\n", + "\n", + " def print_recommendations(self, recommendations):\n", + " '''\n", + " Prints the recommended movies with their predicted ratings.\n", + "\n", + " Args:\n", + " recommendations (list): List of recommended movies with their predicted ratings.\n", + " '''\n", + " # Enumerate through the sorted recommendations with an index starting at 1\n", + " for i, (movie_id, predicted_rating) in enumerate(recommendations, 1):\n", + " # Retrieve the movie details from the DataFrame using the movie_id\n", + " movie = self.df[self.df['movieId'] == movie_id].iloc[0]\n", + " \n", + " # Print the recommendation number, movie title, release year, and predicted rating formatted to two decimal places\n", + " print(f\"{i}. {movie['title']} ({movie['release_year']}) - Predicted rating: {predicted_rating:.2f}\")\n", + " \n", + " # Print the genre(s) of the movie\n", + " print(f\"Genre: {movie['genres']}\")\n", + "\n", + " def recommend_movies(self, num_ratings=5, num_recommendations=5, genre=None):\n", + " '''\n", + " Recommends movies based on user input ratings and optionally filters by genre.\n", + " \n", + " Args:\n", + " num_ratings (int): Number of movies to rate.\n", + " num_recommendations (int): Number of recommendations to provide.\n", + " genre (str, optional): Genre to filter recommendations by.\n", + " '''\n", + " \n", + " # Retrieve the user's ratings based on the number of ratings specified\n", + " user_ratings = self.get_user_ratings(num_ratings)\n", + " \n", + " # Get movie recommendations based on the user's ratings, desired number of recommendations, and optional genre filter\n", + " recommendations = self.get_recommendations(user_ratings, num_recommendations, genre)\n", + " \n", + " # Print a header for the recommended movies\n", + " print(\"\\nRecommended movies:\")\n", + " \n", + " # Print the list of recommended movies\n", + " self.print_recommendations(recommendations)\n", + "\n", + "\n", + "# Instantiate\n", + "recommender = CollabBasedModel(df)\n", + "recommender.train_model()\n", + "\n", + "# Save the trained model using pickle\n", + "with open('collaborative_model.pkl', 'wb') as f:\n", + " pickle.dump(recommender.model, f)\n", + " \n", + "# Get recommendations\n", + "recommender.recommend_movies(num_ratings=5, num_recommendations=5, genre='Comedy')\n" + ] + }, + { + "cell_type": "markdown", + "id": "8f9045e0-ca85-4ebf-a75d-a68c2a73006f", + "metadata": {}, + "source": [ + "### Summary" + ] + }, + { + "cell_type": "markdown", + "id": "675c21e7-e54e-4d8a-a958-cb572905d592", + "metadata": {}, + "source": [ + "The recommendation process involves getting user ratings for a few sample movies then uses the ratings to predict the user's preferences for unseen movies. The system can filter recommendations by genre if specified. The recommend_movies method chains all steps together prompting the user for ratings, generating recommendations and provides the output. In this case, the `recommend_movies` method uses the collected ratings to predict how the user would rate movies they haven’t rated yet. The method then sorts these predictions to find the highest-rated movies. The genre filteer applied in this case is 'Comedy' and the recommendations are further filtered to include only movies that match the specified genre as illustrated by the output above." + ] + }, + { + "cell_type": "markdown", + "id": "db412a64-bbf2-4a51-8d39-4cca89fd7c29", + "metadata": {}, + "source": [ + "## Content Based" + ] + }, + { + "cell_type": "markdown", + "id": "5682dd7a-ffa5-4238-828d-1eda7c95e082", + "metadata": {}, + "source": [ + "A content based recommender system is a type of recommendation algorithm that suggests items to users based on the characteristics or features of the items they have previously liked or interacted with. It analyzes the content or attributes of items such as movies to find similarities and make recommendations. This content-based filtering approach recommends movies based on the similarity of their genres. It utilizes TF-IDF to represent genres as vectors, calculates cosine similarity to assess the similarity between movies and retrieves movies that are most similar to a given movie. \n", + "The TfidfVectorizer from sklearn.feature_extraction.text is used to convert the genre descriptions into numerical vectors. Each genre is transformed into a TF-IDF (Term Frequency-Inverse Document Frequency) matrix which captures the importance of genres across the dataset. The TF-IDF matrix represents how important each genre is in relation to the other genres in the dataset.\n", + "The cosine similarity matrix is then computed using cosine_similarity from sklearn.metrics.pairwise. This matrix measures the similarity between movies based on their genre vectors with values ranging from 0 (no similarity) to 1 (identical genre profiles)." + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "0660dda9-1dd6-42b7-9e9c-0b64deef12aa", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "41657 Antz\n", + "51710 Toy Story 2\n", + "57060 Adventures of Rocky and Bullwinkle, The\n", + "59397 Emperor's New Groove, The\n", + "64009 Monsters, Inc.\n", + "Name: title, dtype: object\n" + ] + } + ], + "source": [ + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "import pickle\n", + "\n", + "# Drop duplicates to get unique movie titles\n", + "content_df = df.drop_duplicates(subset='title')[['movieId', 'title', 'genres', 'release_year']]\n", + "# Save to a CSV file\n", + "content_df.to_csv('content_movies.csv', index=False)\n", + "\n", + "class ContentBasedModel:\n", + " '''\n", + " A content-based recommender system for movies based on genre similarity.\n", + " '''\n", + "\n", + " def __init__(self, df):\n", + " '''\n", + " Initialize the ContentBasedModel with a dataframe of movie information.\n", + "\n", + " Args:\n", + " df (pd.DataFrame): Dataframe containing movie information.\n", + " '''\n", + " self.df = content_df\n", + " self.tfidf_matrix = None\n", + " self.cosine_sim = None\n", + " self.indices = None\n", + "\n", + " def train_model(self):\n", + " '''\n", + " Train the content-based model by creating a TF-IDF matrix and calculating cosine similarity.\n", + "\n", + " Args: \n", + " self (ContentBasedModel): The instance of the ContentBasedModel class.\n", + " '''\n", + " # Define the TF-IDF vectorizer\n", + " tfidf = TfidfVectorizer(stop_words='english')\n", + " \n", + " # Fit and transform the genres\n", + " self.tfidf_matrix = tfidf.fit_transform(self.df['genres'])\n", + " \n", + " # Calculate the cosine similarity matrix\n", + " self.cosine_sim = cosine_similarity(self.tfidf_matrix, self.tfidf_matrix)\n", + " \n", + " # Create a reverse mapping of indices and movie titles\n", + " self.indices = pd.Series(self.df.index, index=self.df['title']).drop_duplicates()\n", + "\n", + " def get_recommendations(self, title, k=5):\n", + " '''\n", + " Get movie recommendations based on genre similarity to the input movie.\n", + "\n", + " Args:\n", + " title (str): Title of the movie to base recommendations on.\n", + " k (int): Number of recommendations to return.\n", + "\n", + " Returns:\n", + " pd.Series: Series of recommended movie titles.\n", + " '''\n", + " # Get the index of the movie that matches the title\n", + " idx = self.indices[title]\n", + " \n", + " # Get the pairwise similarity scores of all movies with that movie\n", + " sim_scores = list(enumerate(self.cosine_sim[idx]))\n", + " \n", + " # Sort the movies based on the similarity scores\n", + " sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True)\n", + " \n", + " # Get the scores of the k most similar movies\n", + " sim_scores = sim_scores[1:k+1]\n", + " \n", + " # Get the movie indices\n", + " movie_indices = [i[0] for i in sim_scores]\n", + " \n", + " # Return the top k most similar movies\n", + " return self.df['title'].iloc[movie_indices]\n", + "\n", + "# Instantiate\n", + "content_recommender = ContentBasedModel(df)\n", + "# Train the model\n", + "content_recommender.train_model()\n", + "# Get recommendations for a specific movie\n", + "recommendations = content_recommender.get_recommendations('Toy Story')\n", + "# Print the recommendations\n", + "print(recommendations)\n", + "\n", + "# Save the trained TF-IDF matrix and cosine similarity matrix using pickle\n", + "content_model = {\n", + " 'tfidf_matrix': content_recommender.tfidf_matrix,\n", + " 'cosine_sim': content_recommender.cosine_sim,\n", + " 'indices': content_recommender.indices\n", + "}\n", + "\n", + "with open('contentbased_model.pkl', 'wb') as f:\n", + " pickle.dump(content_model, f)\n" + ] + }, + { + "cell_type": "markdown", + "id": "0e1fd17e-6eb7-49f5-9bb5-b6cb231f45b5", + "metadata": {}, + "source": [ + "### Summary" + ] + }, + { + "cell_type": "markdown", + "id": "ba4d1bad-c35d-4932-bdf9-4ded5bd19984", + "metadata": {}, + "source": [ + "The class above implements a content based movie recommender system using genres as the primary feature. It begins by importing necessary libraries and preparing the dataset ensuring only unique movie titles are retained. The `ContentBasedModel` class encapsulates the core functionality. Within its `train_model` method, movie genres are converted into numerical features using TF-IDF (Term Frequency-Inverse Document Frequency) vectorization creating a matrix of genres. The cosine similarity between all pairs of movies is then calculated based on these TF-IDF representations producing a similarity matrix that identifies movies with similar genres. The `get_recommendations` method uses this similarity matrix to provide the top 5 most similar movies to a given title. The code demonstrates the class's usage by creating an instance, training the model and getting recommendations for 'Toy Story'. Finally, it saves the trained model including the TF-IDF matrix, cosine similarity matrix and movie indices to a pickle file for future use.\n", + "The recommender system outputs the top 5 movies similar to 'Toy Story' based on genre similarity i.e. 'Antz','Toy Story 2', 'The Adventures of Rocky and Bullwinkle', 'The Emperor's New Groove' and 'Monsters, Inc.'. Each recommendation shares key characteristics with 'Toy Story' being that they are family-friendly animations with a focus on adventure and comedy. These recommendations are evidently relevant and appealing to fans of Toy Story.\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "fcd47967-e4a5-4915-a4ad-5ba0ebf8cae4", + "metadata": {}, + "source": [ + "## Hybrid System" + ] + }, + { + "cell_type": "markdown", + "id": "c07a19f0-fcde-450c-8fd0-eac2c0eac9f7", + "metadata": {}, + "source": [ + "A hybrid recommendation system integrates multiple techniques such as collaborative filtering and content-based filtering to enhance recommendation accuracy. Collaborative filtering relies on user interactions to suggest items based on similar users' preferences while content-based filtering recommends items based on features and past user preferences. The hybrid system combines these approaches to address their individual weaknesses—like the cold start problem in collaborative filtering and limited feature scope in content-based filtering. It can use methods such as weighted hybrids where recommendations from both techniques are averaged with specific weights, switching hybrids which chooses methods based on conditions or feature augmentation where one technique is enhanced with features from another. This integration aims to provide more accurate and personalized recommendations by leveraging the strengths of each method and mitigating their limitations.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 49, + "id": "29fa9bcb-c197-453e-b5f5-0bf14cc8943b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Movie: Dick (1999)\n", + "Genre: comedy\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Rate this movie from 1 to 5 (or 'x' if you haven't watched it): 3\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Movie: Girl Who Kicked the Hornet's Nest, The (Luftslottet som sprängdes) (2009)\n", + "Genre: action, crime, mystery\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Rate this movie from 1 to 5 (or 'x' if you haven't watched it): 5\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Movie: Return to Treasure Island (1988)\n", + "Genre: adventure, animation, comedy\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Rate this movie from 1 to 5 (or 'x' if you haven't watched it): 5\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Movie: Center Stage (2000)\n", + "Genre: drama, musical\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Rate this movie from 1 to 5 (or 'x' if you haven't watched it): x\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Movie: Sleepaway Camp (1983)\n", + "Genre: horror\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Rate this movie from 1 to 5 (or 'x' if you haven't watched it): 3\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Hybrid Recommended movies:\n", + "1. Shawshank Redemption, The (1994) - Hybrid score: 4.20\n", + " Genre: crime, drama\n", + "2. Lawrence of Arabia (1962) - Hybrid score: 4.19\n", + " Genre: adventure, drama, war\n", + "3. Cool Hand Luke (1967) - Hybrid score: 4.14\n", + " Genre: drama\n", + "4. Raiders of the Lost Ark (Indiana Jones and the Raiders of the Lost Ark) (1981) - Hybrid score: 3.63\n", + " Genre: action, adventure\n", + "5. Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb (1964) - Hybrid score: 3.14\n", + " Genre: comedy, war\n" + ] + } + ], + "source": [ + "class HybridModel:\n", + " '''\n", + " A hybrid recommender system that combines collaborative filtering and content-based filtering.\n", + " '''\n", + "\n", + " def __init__(self, collab_df, content_df):\n", + " '''\n", + " Initialize the HybridModel with collaborative and content-based dataframes.\n", + "\n", + " Args:\n", + " collab_df (pd.DataFrame): Dataframe for collaborative filtering.\n", + " content_df (pd.DataFrame): Dataframe for content-based filtering.\n", + " '''\n", + " self.collab_model = CollabBasedModel(collab_df)\n", + " self.content_model = ContentBasedModel(content_df)\n", + " self.df = pd.merge(collab_df, content_df, on='movieId').drop_duplicates(subset=['movieId'])\n", + "\n", + " def train_models(self):\n", + " '''\n", + " Train both collaborative and content-based models.\n", + " '''\n", + " self.collab_model.train_model()\n", + " self.content_model.train_model()\n", + "\n", + " def get_user_ratings(self, num_movies=5):\n", + " '''\n", + " Get user ratings for a specified number of random movies.\n", + "\n", + " Args:\n", + " num_movies (int): Number of movies to rate. Defaults to 5.\n", + "\n", + " Returns:\n", + " list: List of tuples containing movie IDs and ratings.\n", + " '''\n", + " # Initialize an empty list to store user ratings\n", + " user_ratings = []\n", + "\n", + " # Loop to collect ratings for a specified number of movies\n", + " for _ in range(num_movies):\n", + " \n", + " # Randomly sample one movie from the DataFrame\n", + " movie = self.df.sample(1).iloc[0]\n", + " \n", + " # Display the movie details to the user\n", + " print(f\"\\nMovie: {movie['title']} ({movie['release_year']})\")\n", + " print(f\"Genre: {movie['genres']}\")\n", + " \n", + " # Prompt the user to rate the movie or indicate they haven't watched it\n", + " rating = input(\"Rate this movie from 1 to 5 (or 'x' if you haven't watched it): \")\n", + " \n", + " # If the user has watched the movie and provided a rating, add it to the list\n", + " if rating.lower() != 'x':\n", + " user_ratings.append((movie['movieId'], float(rating)))\n", + " \n", + " # Return the list of user ratings\n", + " return user_ratings\n", + "\n", + "\n", + " def get_hybrid_recommendations(self, user_ratings, n=5, collab_weight=0.5):\n", + " '''\n", + " Get hybrid recommendations based on user ratings.\n", + "\n", + " Args:\n", + " user_ratings (list): List of tuples containing movie IDs and ratings.\n", + " n (int): Number of recommendations to return.\n", + " collab_weight (float): Weight for collaborative filtering (0 to 1).\n", + "\n", + " Returns:\n", + " list: List of tuples containing recommended movie IDs and hybrid scores.\n", + " '''\n", + " # Generate a new user ID by incrementing the maximum user ID in the DataFrame\n", + " new_user_id = self.df['user_id'].max() + 1\n", + " \n", + " # Get collaborative filtering recommendations\n", + " collab_recommendations = self.collab_model.get_recommendations(user_ratings, n)\n", + " \n", + " # Extract movie IDs from collaborative filtering recommendations\n", + " collab_movie_ids = [rec[0] for rec in collab_recommendations]\n", + " \n", + " # Extract scores from collaborative filtering recommendations\n", + " collab_scores = np.array([rec[1] for rec in collab_recommendations])\n", + " \n", + " # Initialize a list to store content-based scores\n", + " content_scores = []\n", + " \n", + " # Loop through each movie ID from collaborative filtering recommendations\n", + " for movie_id in collab_movie_ids:\n", + " \n", + " # Get the title of the movie corresponding to the movie ID\n", + " title = self.df[self.df['movieId'] == movie_id]['title'].values[0]\n", + " \n", + " # Get the top content-based recommendation for the movie\n", + " content_rec = self.content_model.get_recommendations(title, k=1)\n", + " \n", + " # Calculate the average rating of the content-based recommendation\n", + " content_score = self.df[self.df['title'] == content_rec.iloc[0]]['rating'].mean()\n", + " \n", + " # Append the content-based score to the list\n", + " content_scores.append(content_score)\n", + " \n", + " # Convert the list of content-based scores to a NumPy array\n", + " content_scores = np.array(content_scores)\n", + " \n", + " # Combine collaborative and content-based scores using a weighted average\n", + " hybrid_scores = collab_weight * collab_scores + (1 - collab_weight) * content_scores\n", + " \n", + " # Combine movie IDs with their hybrid scores and sort them in descending order of scores\n", + " hybrid_recommendations = sorted(zip(collab_movie_ids, hybrid_scores), key=lambda x: x[1], reverse=True)\n", + " \n", + " # Return the top n hybrid recommendations\n", + " return hybrid_recommendations[:n]\n", + "\n", + "\n", + " def print_recommendations(self, recommendations):\n", + " '''\n", + " Print the recommended movies with their hybrid scores.\n", + "\n", + " Args:\n", + " recommendations (list): List of tuples containing movie IDs and hybrid scores.\n", + " '''\n", + " # Loop through the recommendations and print the details of each recommended movie\n", + " for i, (movie_id, score) in enumerate(recommendations, 1):\n", + " \n", + " # Retrieve the movie details based on the movie ID\n", + " movie = self.df[self.df['movieId'] == movie_id].iloc[0]\n", + " \n", + " # Print the movie rank, title, release year and hybrid score\n", + " print(f\"{i}. {movie['title']} ({movie['release_year']}) - Hybrid score: {score:.2f}\")\n", + " \n", + " # Print the genre of the movie\n", + " print(f\" Genre: {movie['genres']}\")\n", + "\n", + "\n", + " def recommend_movies(self, num_ratings=5, num_recommendations=5, collab_weight=0.5):\n", + " '''\n", + " Get user ratings and provide hybrid movie recommendations.\n", + "\n", + " Args:\n", + " num_ratings (int): Number of movies to rate.\n", + " num_recommendations (int): Number of recommendations to provide.\n", + " collab_weight (float): Weight for collaborative filtering (0 to 1).\n", + " \n", + " Returns:\n", + " list: A list of recommended movies based on the hybrid model.\n", + " '''\n", + " # Get user ratings for a specified number of movies\n", + " user_ratings = self.get_user_ratings(num_ratings)\n", + " \n", + " # Generate hybrid recommendations based on the user ratings\n", + " recommendations = self.get_hybrid_recommendations(user_ratings, num_recommendations, collab_weight)\n", + " \n", + " # Print the hybrid recommended movies\n", + " print(\"\\nHybrid Recommended movies:\")\n", + " self.print_recommendations(recommendations)\n", + "\n", + "\n", + "# Instantiate\n", + "hybrid_model = HybridModel(collab_df, content_df)\n", + "# Train both the collaborative filtering and content-based models\n", + "hybrid_model.train_models()\n", + "\n", + "# Recommend movies using the hybrid model specifying the number of user ratings to collect,\n", + "# the number of movie recommendations to generate and the weight for collaborative filtering in the hybrid model\n", + "hybrid_model.recommend_movies(num_ratings=5, num_recommendations=5, collab_weight=0.5)\n", + "\n", + "# Save the hybrid model\n", + "with open('hybrid_model.pkl', 'wb') as f:\n", + " pickle.dump(hybrid_model, f)" + ] + }, + { + "cell_type": "markdown", + "id": "79b94085-598d-41f5-9ed1-3eb9e3492352", + "metadata": {}, + "source": [ + "### Summary\n", + "The `HybridRecommender` class combines two types of recommendation systems namely content-based and collaborative filtering into a hybrid model to enhance the top-5 movie recommendations. This class takes in two datasets (one for each recommendation model) and provides a unified recommendation list based on both models. It initializes with separate dataframes for each filtering type and merges them for a comprehensive recommendation system. The class trains both collaborative and content-based models, allows users to rate movies on a scale of 1 to 5 and 'x' if they have not seen the movie then generates hybrid recommendations by blending the two models' scores based on a specified weight in this case 0.5 meaning half of each model's. The model is designed for user interaction, training and evaluation with capabilities for saving and restoring its state." + ] + }, + { + "cell_type": "markdown", + "id": "edacbc2c-dcaf-40ec-b840-1805755548c4", + "metadata": {}, + "source": [ + "#### Evaluating RMSE of the hybrid model\n", + "Evaluating the RMSE (Root Mean Squared Error) of the hybrid model involves measuring the accuracy of its predictions by comparing the predicted ratings to actual ratings. RMSE quantifies the average magnitude of the prediction errors, where a lower RMSE indicates better predictive accuracy. To calculate RMSE, you first predict ratings for a set of items, then compute the difference between these predicted ratings and the actual ratings, square these differences, average them and finally take the square root of the result. For a hybrid model, RMSE helps assess how well the model integrates these methods in predicting user preferences with the goal of achieving a lower RMSE to reflect more precise and reliable recommendations." + ] + }, + { + "cell_type": "code", + "execution_count": 77, + "id": "e4244309-de25-4da0-b248-561f2cfbc79e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Starting prediction generation...\n", + "Finished prediction generation.\n", + "Root Mean Square Error (RMSE): 1.2463\n" + ] + } + ], + "source": [ + "from surprise import Dataset, Reader, accuracy\n", + "from surprise.model_selection import train_test_split\n", + "\n", + "def evaluate_rmse(hybrid_model, test_size=0.2, collab_weight = None, random_state=42):\n", + " '''\n", + " Evaluate the RMSE of the hybrid model.\n", + "\n", + " Args:\n", + " hybrid_model: The hybrid recommendation model to be evaluated.\n", + " test_size (float): The proportion of the dataset to include in the test split.\n", + " collab_weight (float, optional): The weight for collaborative filtering in the hybrid model. Defaults to None.\n", + " random_state (int, optional): The seed used by the random number generator for reproducibility. Defaults to 42.\n", + "\n", + " Returns:\n", + " float: The RMSE of the hybrid model.\n", + " '''\n", + " # Extract collaborative data from the model\n", + " collab_df = hybrid_model.collab_model.df\n", + " \n", + " reader = Reader(rating_scale=(1, 5))\n", + " data = Dataset.load_from_df(collab_df[['user_id', 'movieId', 'rating']], reader)\n", + " \n", + " # Split the data into training and test sets\n", + " trainset, testset = train_test_split(data, test_size=test_size, random_state=random_state)\n", + " \n", + " # Train both models\n", + " hybrid_model.train_models()\n", + "\n", + " # Generate predictions for the test set\n", + " true_ratings = []\n", + " pred_ratings = []\n", + "\n", + " print('Starting prediction generation...')\n", + " for idx, (user_id, movie_id, true_rating) in enumerate(testset):\n", + " recommendations = hybrid_model.get_hybrid_recommendations([(movie_id, true_rating)], n=1, collab_weight=0.5)\n", + " pred_rating = recommendations[0][1] if recommendations else 0\n", + " true_ratings.append(true_rating)\n", + " pred_ratings.append(pred_rating)\n", + "\n", + " print('Finished prediction generation.')\n", + "\n", + " # Calculate RMSE using numpy vectorized operations\n", + " true_ratings = np.array(true_ratings)\n", + " pred_ratings = np.array(pred_ratings)\n", + " rmse = np.sqrt(np.mean((true_ratings - pred_ratings) ** 2))\n", + "\n", + " return rmse\n", + "\n", + "# Instantiate the hybrid model\n", + "hybrid_model = HybridModel(collab_df, content_df)\n", + "# Evaluate RMSE\n", + "rmse = evaluate_rmse(hybrid_model, collab_weight = 0.5)\n", + "print(f\"Root Mean Square Error (RMSE): {rmse:.4f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "d10a43f7-781a-4e35-816c-2ecd91c60743", + "metadata": {}, + "source": [ + "With an RMSE of 1.2463, the hybrid model's performance shows that on average its predictions deviate from the actual ratings by approximately 1.25 units. While RMSE provides a measure of accuracy, a lower RMSE is generally preferred. An RMSE of 1.2463 suggests that the model's predictions are somewhat accurate but there is room for improvement. It indicates that while the hybrid model integrates multiple recommendation techniques, its predictions still have a notable level of error and further refinement or optimization may be needed to enhance accuracy. " + ] + }, + { + "cell_type": "markdown", + "id": "b9dd9949-f5af-40fc-97a7-6274b47ae5fb", + "metadata": {}, + "source": [ + "#### Optimizing hybrid model parameters" + ] + }, + { + "cell_type": "markdown", + "id": "358c0076-07da-4785-bbfc-e033f45b9c57", + "metadata": {}, + "source": [ + "In this next step, different collaborative filtering weights are tested to determine their impact on the hybrid model's performance. The weights list contains values representing the proportion of influence from collaborative filtering in the hybrid model. The loop iterates over each weight, adjusting the model's configuration accordingly and evaluates the RMSE for each setting using the `evaluate_rmse` function. The RMSE values are then printed alongside their corresponding weight providing insights into how varying the balance between collaborative and content-based filtering affects the model's prediction accuracy. This process helps identify the optimal weight for achieving the best performance of the hybrid recommendation system." + ] + }, + { + "cell_type": "code", + "execution_count": 78, + "id": "3b94a93f-7012-4b75-b72e-63b04b8ae40b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Starting prediction generation...\n", + "Finished prediction generation.\n", + "RMSE with collab_weight 0.2: 1.2559\n", + "Starting prediction generation...\n", + "Finished prediction generation.\n", + "RMSE with collab_weight 0.4: 1.2523\n", + "Starting prediction generation...\n", + "Finished prediction generation.\n", + "RMSE with collab_weight 0.6: 1.1263\n", + "Starting prediction generation...\n", + "Finished prediction generation.\n", + "RMSE with collab_weight 0.8: 1.1221\n" + ] + } + ], + "source": [ + "# List of different collaborative filtering weights to test\n", + "weights = [0.2, 0.4, 0.6, 0.8]\n", + "\n", + "# Iterate over each weight value\n", + "for weight in weights:\n", + " \n", + " # Evaluate the RMSE of the hybrid model with the current collaborative filtering weight\n", + " rmse = evaluate_rmse(hybrid_model, collab_weight=weight)\n", + " \n", + " # Print the RMSE result with the corresponding collaborative filtering weight\n", + " print(f\"RMSE with collab_weight {weight}: {rmse:.4f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "215ef4ab-e717-40f1-9599-d86b5adbf0cf", + "metadata": {}, + "source": [ + "### Summary\n", + "The `evaluate_rmse` function evaluates the performance of the hybrid recommender model by calculating the Root Mean Squared Error (RMSE). It prepares test data, generates predictions using the hybrid model with a specified weight for collaborative filtering and then merges these predictions with the true ratings. By computing the RMSE, the function quantifies the accuracy of the model's predictions. As the weight assigned to collaborative filtering increases, starting from 0.2 up to 0.8, the RMSE values decrease indicating improved prediction accuracy. With a weight of 0.2 the RMSE is 1.2559 and with a weight of 0.4 it slightly improves to 1.2523. The RMSE significantly drops to 1.1263 with a weight of 0.6 and further decreases to 1.1221 with a weight of 0.8. This suggests that higher collaborative filtering weight tends to enhance the model's accuracy leading to lower prediction errors and helps in identifying the most effective balance between collaborative and content-based filtering for better performance.\n", + "The function below plots the different RMSE scores against the different collaborative weights in order to visualize the outcome.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 83, + "id": "4a98a25c-2ed9-419c-9f04-e04670db469c", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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m7eo78E+ffoIIf79LJ98TUhIiG751q1bAoCYNGmSwf47d+4satWqJR48eKC3fujQocLW1lbcvXtXCCHE3r17BQDRoUMHg30UbNu7d69uXf/+/QUAsW7dOr2xXbt2FQ0aNNAtL1iwQAAQ27dv1xv37rvvGnzPFUar1Qo3NzcREREhhBAiPz9fVKlSRXz33XdCCCF69uwpevToIYQQQqVSCTs7O9GzZ08hhBApKSlCLpeLDz/8UG+fmZmZokaNGrpxQhh+zgV/z4wePVrvtatXrxYA9L5XCn5Wnvw+njlzpgAg0tLSdOsaN26sVzsiMn88c0VEZq1NmzZQKBRwcnJCly5d4Orqis2bNxd6GV+BgQMHIjExEX/++SeWLFmCunXrokOHDoWOrVq1KhISEnD8+HF88cUXeOWVV3DhwgWMHTsWTZs2NTgbUrduXRw/ftzgq7BLwh7XtGlTNG/eHMuWLdOtS05OxrFjx3SXqwFAq1atcOrUKXzwwQfYuXOnwZmuslQwEYi3t7fe+gEDBiAnJweHDx8u0f7++ecfvPfee/D29oZcLodCoYCvry+AR+/9SQVndwr07NkTcrlcdxai4L9PXmLVqlUr+Pv7Y/fu3XrrXV1dC70c8vLly+jduzdq1KgBa2trKBQKhISEFJnLGJGRkahRo4ZefXfu3IkbN27o1fe3335DWFgYvLy89M48RUZGAoBRszcW9j342WeflSr343Jzc7F792689tprsLe318vXtWtX5Obm4siRI3qvef31143ev0wm0zs7CADNmjXDtWvXdMv79u3T/aw/rlevXkYfIyQkBAcPHoRarUZSUhLu37+vm7gjJCQE8fHxEELgyJEjevdb7dy5ExqNBv369dN777a2trrXFaWgbj179tRb36NHjyL/rurWrZvBZwFA7/MgooqHE1oQkVlbvnw5/P39kZmZibVr12LRokXo1asXtm/fXuRrOnTogHr16mHRokVYt24dRowY8dSZwFq0aKG7n0utVmP06NGYPXs2Zs6cqXdJnK2trW5cSQ0cOBBDhgzBuXPn0LBhQyxbtgxKpVLvF8exY8fCwcEBP/30E7777jtYW1ujQ4cOmDFjRomPW3Av1ZUrV9CgQYOnjr9z506hl715eXnpthtLq9UiIiICN27cwIQJE9C0aVM4ODhAq9WiTZs2ePjwocFratSoobcs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vHnT4PObMWOGqF+/vlAoFMLd3V307dtXXL9+XW9ccd8Phw4dEkFBQcLe3l54eHiIwYMHi99//93gPalUKjF48GDh4eEhZDKZ3vt7crbAAg8ePBB2dnYCgFi8eHGhx79165YYNmyYqFOnjlAoFMLNzU00b95cjBs3TmRlZRX6GmPelxDGzRYohBC7du0SgYGBQqlUGsxmd+XKFTFw4EBRs2ZNoVAohIeHhwgODhZTp07VjSn4Hvz5558NMhQ1W6CDg4NReVNSUkT37t113wevv/662LZtm8Esk8VZuHChACA8PDwMtiUlJel+Zv/66y+D7Zs2bRJhYWHC2dlZKJVK4evrK3r06CF27dpVbO7c3FwRHR0tqlWrJmxtbUWbNm3E4cOHhYuLi95MmEX93VbY53b16lUREREhnJycBADh6+tr1PsnIunIhBCiHHo4IiIiolKZNm0axo8fj5SUlDI/s2dKhw4dQtu2bbFy5Ur07t1b6jhEVA54WSARERGZjfnz5wN4dGmpWq3Gnj17MG/ePPTt29esG6u4uDgcPnwYzZs3h52dHU6dOoUvvvgC9erV07u3i4gsG5srIiIiMhv29vaYPXs2rl69CpVKBR8fH4wePRrjx4+XOlqxnJ2dERsbizlz5iAzMxPu7u6IjIzE9OnTDaagJyLLxcsCiYiIiIiITKB009cQERERERGRHjZXREREREREJsDmioiIiIiIyAQ4oUUhtFotbty4AScnJ8hkMqnjEBERERGRRIQQyMzMhJeX11MfCs7mqhA3btyAt7e31DGIiIiIiMhMXL9+/amPhGBzVQgnJycAjz5AZ2dnidMAarUasbGxiIiIgEKhkDoOmQBranlYU8vEuloe1tQysa6Wx5xqmpGRAW9vb12PUBw2V4UouBTQ2dnZbJore3t7ODs7S/7NRabBmloe1tQysa6WhzW1TKyr5THHmhpzuxAntCAiIiIiIjIBSZur/fv3IyoqCl5eXpDJZNi0aVOx4zds2IDw8HB4eHjA2dkZQUFB2Llzp8G4+/fvY8iQIfD09IStrS38/f2xbdu2MnoXREREREREEjdX2dnZCAgIwPz5840av3//foSHh2Pbtm04ceIEwsLCEBUVhZMnT+rG5OXlITw8HFevXsX69etx/vx5LF68GDVr1iyrt0FERERERCTtPVeRkZGIjIw0evycOXP0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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "def plot_rmse_vs_weight(weights, rmse_scores):\n", + " '''\n", + " Plot RMSE against collaborative filtering weights.\n", + "\n", + " Args:\n", + " weights (list): A list of collaborative filtering weights.\n", + " rmse_scores (list): A list of RMSE scores corresponding to the weights.\n", + "\n", + " Returns:\n", + " None\n", + " '''\n", + " plt.figure(figsize=(10, 6))\n", + " plt.plot(weights, rmse_scores, marker='o', linestyle='--', color='purple')\n", + "\n", + " # Add titles and labels\n", + " plt.title('RMSE vs. Collaborative Filtering Weight')\n", + " plt.xlabel('Collaborative Filtering Weight')\n", + " plt.ylabel('RMSE Score')\n", + " plt.grid(True)\n", + "\n", + " # Show the plot\n", + " plt.show()\n", + "\n", + "# Initializing weights and scores\n", + "weights = [0.2, 0.4, 0.6, 0.8]\n", + "rmse_scores = [1.2559, 1.2523, 1.1263, 1.1221]\n", + "plot_rmse_vs_weight(weights, rmse_scores)\n" + ] + }, + { + "cell_type": "markdown", + "id": "53328243-2794-41fc-aff0-0899b13c0652", + "metadata": {}, + "source": [ + "## Deployment on Streamlit" + ] + }, + { + "cell_type": "markdown", + "id": "1c0548a9-2cec-43b9-9cb1-03523fa09506", + "metadata": {}, + "source": [ + "Streamlit is a Python library that simplifies the creation of web applications for data science and machine learning projects. In this deployment, Streamlit is used to create an interactive movie recommendation system. The app features the collaborative filtering model using the SVD algorithm which was trained on user-movie ratings data. The model was chosen due to its better accuracy score as continuous improvement is performed on the hybrid model. \n" + ] + }, + { + "cell_type": "markdown", + "id": "986c0d7d-209c-4860-83fa-cec0add9783d", + "metadata": {}, + "source": [ + "# CONCLUSIONS" + ] + }, + { + "cell_type": "markdown", + "id": "f7cdfb43-e9bf-44c6-b095-8e41e7d91c6a", + "metadata": {}, + "source": [ + "After evaluating the collaborative filtering, content-based filtering and hybrid models, we can conclude that:\n", + "\n", + "The collaborative filtering model, with an RMSE of 0.86, performs exceptionally well by accurately predicting user preferences based on interaction data. In contrast, the hybrid model which leverages all the features included in both the collaborative and content based models shows higher RMSE indicating it may not capture user preferences as effectively. The hybrid model, in theory, benefits from integrating collaborative filtering more heavily achieving the lower RMSE scores as collaborative weight increases. This confirms that a hybrid approach with a focus on collaborative filtering provides superior accuracy and recommendation quality compared to content-based filtering alone.\n", + "By refining the collaborative filtering weight and exploring additional evaluation methods, the recommendation system can be further optimized to deliver more accurate and relevant movie recommendations." + ] + }, + { + "cell_type": "markdown", + "id": "8b70bea7-8f23-449c-929b-439f1f9c67a4", + "metadata": {}, + "source": [ + "# RECOMMENDATIONS" + ] + }, + { + "cell_type": "markdown", + "id": "71a175f9-408c-4272-b74e-ed1406e7d38f", + "metadata": {}, + "source": [ + "1. Experiment further with collaborative filtering weights in finer increments around the optimal value (e.g., between 0.6 and 0.8) to provide additional insights into achieving even better performance.\n", + "\n", + "2. Implement cross-validation to ensure that the observed improvements in RMSE are consistent across different subsets of the data. This helps in verifying that the results are not due to random chance or overfitting.\n", + "\n", + "3. Enhance the content-based model by incorporating more detailed item features such as plot summaries which could provide value especially for users with limited interaction history.\n", + "\n", + "4. Explore other hyperparameters and configurations for both collaborative filtering and content-based components of the hybrid model to potentially enhance performance.\n", + "\n", + "5. Evaluate the model using additional metrics such as Mean Average Precision (MAP) or Precision@K to gain a more comprehensive understanding of its recommendation quality.\n", + "\n", + "6. Incorporate user feedback and real-world testing to validate the model's effectiveness in practical scenarios and ensure it aligns with user preferences and expectations.\n", + "\n", + "7. Regularly evaluate the recommendation system with updated data and metrics to ensure it adapts to changing user preferences and content.\n", + "\n", + "8. Integrate additional techniques such as deep learning-based model to further enhance the system's capabilities and address any remaining limitations." + ] } ], "metadata": { diff --git a/movie_recommendor.ipynb b/movie_recommendor.ipynb index 752b0b8..b433d82 100644 --- a/movie_recommendor.ipynb +++ b/movie_recommendor.ipynb @@ -961,7 +961,7 @@ " Renames a column in the DataFrame.\n", "\n", " Args:\n", - " df (pandas.DataFrame): The DataFrame containing the column to rename.\n", + " df: The DataFrame containing the column to rename.\n", " current_name (str): The current name of the column.\n", " new_name (str): The new name for the column.\n", "\n", @@ -1403,6 +1403,7 @@ "source": [ "# Genre Processing: Split the genres in the `movies.csv` dataset into lists for easier analysis\n", "data_explorer.merged_data['genres']=[row.strip().lower().replace('|',', ') for row in data_explorer.merged_data['genres']]\n", + "# Display first 5 rows\n", "data_explorer.merged_data.head()" ] }, @@ -1574,6 +1575,7 @@ "source": [ "# Convert user_id from float to int\n", "data_explorer.merged_data['user_id'] = data_explorer.merged_data['user_id'].astype(int)\n", + "# Display converted data type\n", "data_explorer.merged_data['user_id'].dtype" ] }, @@ -1724,6 +1726,7 @@ } ], "source": [ + "# Sanity check\n", "df = data_explorer.merged_data\n", "df.head()" ] @@ -1881,7 +1884,7 @@ " '''\n", " Initializes the UnivariateAnalysis class with a DataFrame.\n", " \n", - " Parameters:\n", + " Args:\n", " df (DataFrame): A pandas DataFrame containing the movie data to analyze.\n", " '''\n", " self.df = df\n", @@ -2228,7 +2231,7 @@ " and creates a bar plot to visualize the top n titles based on the total \n", " number of ratings received.\n", " \n", - " Parameters:\n", + " Args:\n", " top_n (int): The number of top-rated titles to display. Default is 20.\n", " '''\n", " \n", @@ -2366,6 +2369,7 @@ "predictions = dummy_model.test(testset)\n", "baseline_rmse = accuracy.rmse(predictions)\n", "\n", + "# Print the RMSE\n", "print(f\"Baseline Model RMSE: {baseline_rmse}\")" ] }, @@ -2757,10 +2761,17 @@ } ], "source": [ + "# Initialize SVD model with specified hyperparameters\n", "svd_model = SVD(n_factors=100, n_epochs=30, lr_all=0.01, reg_all=0.1)\n", + "\n", + "# Perform cross-validation on the SVD model using 5 folds\n", + "# Measures RMSE (Root Mean Square Error) for evaluation\n", "cross_val_results = cross_validate(svd_model, data, measures=['RMSE'], cv=5, verbose=True)\n", "\n", + "# Print the mean RMSE from the cross-validation results\n", "print(f\"SVD Model Mean RMSE: {np.mean(cross_val_results['test_rmse'])}\")\n", + "\n", + "# Print the standard deviation of RMSE from the results\n", "print(f\"SVD Model Standard Deviation RMSE: {np.std(cross_val_results['test_rmse'])}\")\n" ] }, @@ -2891,8 +2902,8 @@ " '''\n", " Initializes the MovieRecommender with a DataFrame containing movie data.\n", "\n", - " Parameters:\n", - " df (pd.DataFrame): DataFrame containing movie information with columns 'user_id', 'movieId', 'rating', 'title', 'release_year', and 'genres'.\n", + " Args:\n", + " df (pd.DataFrame): DataFrame containing movie information with columns 'user_id', 'movieId', 'rating', 'title', 'release_year', and 'genres'.\n", " '''\n", " self.df = collab_df\n", " self.model = None\n", @@ -2920,6 +2931,12 @@ " def get_user_ratings(self, num_movies=5):\n", " '''\n", " Collects ratings from the user for a specified number of movies.\n", + "\n", + " Args:\n", + " num_movies (int): Number of movies to rate.\n", + "\n", + " Returns:\n", + " list: List of tuples containing movieId and user rating.\n", " '''\n", " \n", " # Initialize an empty list to store user ratings\n", @@ -2948,7 +2965,16 @@ " def get_recommendations(self, user_ratings, n=5, genre=None):\n", " '''\n", " Provides movie recommendations based on user ratings and optional genre filtering.\n", + "\n", + " Args:\n", + " user_ratings (list): List of tuples containing movieId and user rating.\n", + " n (int): Number of recommendations to provide.\n", + " genre (str, optional): Genre to filter recommendations by.\n", + "\n", + " Returns:\n", + " list: List of recommended movies with their predicted ratings.\n", " '''\n", + " \n", " # Generate a unique user ID for a new user who is providing ratings for the first time\n", " new_user_id = self.df['user_id'].max() + 1\n", " \n", @@ -2979,8 +3005,10 @@ " def print_recommendations(self, recommendations):\n", " '''\n", " Prints the recommended movies with their predicted ratings.\n", + "\n", + " Args:\n", + " recommendations (list): List of recommended movies with their predicted ratings.\n", " '''\n", - " \n", " # Enumerate through the sorted recommendations with an index starting at 1\n", " for i, (movie_id, predicted_rating) in enumerate(recommendations, 1):\n", " # Retrieve the movie details from the DataFrame using the movie_id\n", @@ -2995,6 +3023,11 @@ " def recommend_movies(self, num_ratings=5, num_recommendations=5, genre=None):\n", " '''\n", " Recommends movies based on user input ratings and optionally filters by genre.\n", + " \n", + " Args:\n", + " num_ratings (int): Number of movies to rate.\n", + " num_recommendations (int): Number of recommendations to provide.\n", + " genre (str, optional): Genre to filter recommendations by.\n", " '''\n", " \n", " # Retrieve the user's ratings based on the number of ratings specified\n", @@ -3105,6 +3138,9 @@ " def train_model(self):\n", " '''\n", " Train the content-based model by creating a TF-IDF matrix and calculating cosine similarity.\n", + "\n", + " Args: \n", + " self (ContentBasedModel): The instance of the ContentBasedModel class.\n", " '''\n", " # Define the TF-IDF vectorizer\n", " tfidf = TfidfVectorizer(stop_words='english')\n", @@ -3341,7 +3377,7 @@ " Get user ratings for a specified number of random movies.\n", "\n", " Args:\n", - " num_movies (int): Number of movies to rate.\n", + " num_movies (int): Number of movies to rate. Defaults to 5.\n", "\n", " Returns:\n", " list: List of tuples containing movie IDs and ratings.\n", @@ -3453,6 +3489,9 @@ " num_ratings (int): Number of movies to rate.\n", " num_recommendations (int): Number of recommendations to provide.\n", " collab_weight (float): Weight for collaborative filtering (0 to 1).\n", + " \n", + " Returns:\n", + " list: A list of recommended movies based on the hybrid model.\n", " '''\n", " # Get user ratings for a specified number of movies\n", " user_ratings = self.get_user_ratings(num_ratings)\n", @@ -3518,9 +3557,18 @@ "from surprise.model_selection import train_test_split\n", "\n", "def evaluate_rmse(hybrid_model, test_size=0.2, collab_weight = None, random_state=42):\n", - " \"\"\"\n", + " '''\n", " Evaluate the RMSE of the hybrid model.\n", - " \"\"\"\n", + "\n", + " Args:\n", + " hybrid_model: The hybrid recommendation model to be evaluated.\n", + " test_size (float): The proportion of the dataset to include in the test split.\n", + " collab_weight (float, optional): The weight for collaborative filtering in the hybrid model. Defaults to None.\n", + " random_state (int, optional): The seed used by the random number generator for reproducibility. Defaults to 42.\n", + "\n", + " Returns:\n", + " float: The RMSE of the hybrid model.\n", + " '''\n", " # Extract collaborative data from the model\n", " collab_df = hybrid_model.collab_model.df\n", " \n", @@ -3656,8 +3704,11 @@ " Plot RMSE against collaborative filtering weights.\n", "\n", " Args:\n", - " weights (list): List of collaborative filtering weights.\n", - " rmse_scores (list): List of RMSE scores corresponding to the weights.\n", + " weights (list): A list of collaborative filtering weights.\n", + " rmse_scores (list): A list of RMSE scores corresponding to the weights.\n", + "\n", + " Returns:\n", + " None\n", " '''\n", " plt.figure(figsize=(10, 6))\n", " plt.plot(weights, rmse_scores, marker='o', linestyle='--', color='purple')\n",