REK-proj-1/class_3_content_based_recommenders.ipynb

1132 lines
40 KiB
Plaintext
Raw Normal View History

2021-05-18 16:18:33 +02:00
{
"cells": [
{
"cell_type": "markdown",
"id": "literary-toyota",
"metadata": {},
"source": [
"# Content-based recommenders\n",
"\n",
"Content-based recommenders in their recommendations rely purely on the features of items. Conceptually it can be expressed as a model of the form (personalized):\n",
"<center>\n",
"$$\n",
" score \\sim (user, item\\_feature_1, item\\_feature_2, ..., item\\_feature_n)\n",
"$$\n",
"</center>\n",
"or (not personalized)\n",
"<center>\n",
"$$\n",
" score \\sim (item\\_feature_1, item\\_feature_2, ..., item\\_feature_n)\n",
"$$\n",
"</center>\n",
"\n",
" + Content-based recommenders do not suffer from the cold-start problem for new items.\n",
" - They do not use information about complex patterns of user-item interactions - what other similar users have already discovered and liked."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "exciting-specific",
"metadata": {},
"outputs": [],
"source": [
"%matplotlib inline\n",
"%load_ext autoreload\n",
"%autoreload 2\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"import seaborn as sns\n",
"from IPython.display import Markdown, display, HTML\n",
"from collections import defaultdict\n",
"from sklearn.model_selection import KFold\n",
"\n",
"# Fix the dying kernel problem (only a problem in some installations - you can remove it, if it works without it)\n",
"import os\n",
"os.environ['KMP_DUPLICATE_LIB_OK'] = 'True'"
]
},
{
"cell_type": "markdown",
"id": "administrative-charleston",
"metadata": {},
"source": [
"# Load the data"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "architectural-andrews",
"metadata": {},
"outputs": [],
"source": [
"ml_ratings_df = pd.read_csv(os.path.join(\"data\", \"movielens_small\", \"ratings.csv\")).rename(columns={'userId': 'user_id', 'movieId': 'item_id'})\n",
"ml_movies_df = pd.read_csv(os.path.join(\"data\", \"movielens_small\", \"movies.csv\")).rename(columns={'movieId': 'item_id'})\n",
"ml_df = pd.merge(ml_ratings_df, ml_movies_df, on='item_id')\n",
"ml_df.head(10)\n",
"\n",
"display(HTML(ml_movies_df.head(10).to_html()))\n",
"\n",
"# Filter the data to reduce the number of movies\n",
"rng = np.random.RandomState(seed=6789)\n",
"left_ids = rng.choice(ml_movies_df['item_id'], size=100, replace=False)\n",
"\n",
"ml_ratings_df = ml_ratings_df.loc[ml_ratings_df['item_id'].isin(left_ids)]\n",
"ml_movies_df = ml_movies_df.loc[ml_movies_df['item_id'].isin(left_ids)]\n",
"ml_df = ml_df.loc[ml_df['item_id'].isin(left_ids)]\n",
"\n",
"print(\"Number of left interactions: {}\".format(len(ml_ratings_df)))"
]
},
{
"cell_type": "markdown",
"id": "effective-renaissance",
"metadata": {},
"source": [
"# Recommender class\n",
"\n",
"Remark: Docstrings written in reStructuredText (reST) used by Sphinx to automatically generate code documentation. It is also used by default by PyCharm (type triple quotes after defining a class or a method and hit enter)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cooperative-synthesis",
"metadata": {},
"outputs": [],
"source": [
"class Recommender(object):\n",
" \"\"\"\n",
" Base recommender class.\n",
" \"\"\"\n",
" \n",
" def __init__(self):\n",
" \"\"\"\n",
" Initialize base recommender params and variables.\n",
" \"\"\"\n",
" pass\n",
" \n",
" def fit(self, interactions_df, users_df, items_df):\n",
" \"\"\"\n",
" Training of the recommender.\n",
" \n",
" :param pd.DataFrame interactions_df: DataFrame with recorded interactions between users and items \n",
" defined by user_id, item_id and features of the interaction.\n",
" :param pd.DataFrame users_df: DataFrame with users and their features defined by user_id and the user feature columns.\n",
" :param pd.DataFrame items_df: DataFrame with items and their features defined by item_id and the item feature columns.\n",
" \"\"\"\n",
" pass\n",
" \n",
" def recommend(self, users_df, items_df, n_recommendations=1):\n",
" \"\"\"\n",
" Serving of recommendations. Scores items in items_df for each user in users_df and returns \n",
" top n_recommendations for each user.\n",
" \n",
" :param pd.DataFrame users_df: DataFrame with users and their features for which recommendations should be generated.\n",
" :param pd.DataFrame items_df: DataFrame with items and their features which should be scored.\n",
" :param int n_recommendations: Number of recommendations to be returned for each user.\n",
" :return: DataFrame with user_id, item_id and score as columns returning n_recommendations top recommendations \n",
" for each user.\n",
" :rtype: pd.DataFrame\n",
" \"\"\"\n",
" \n",
" recommendations = pd.DataFrame(columns=['user_id', 'item_id', 'score'])\n",
" \n",
" for ix, user in users_df.iterrows():\n",
" user_recommendations = pd.DataFrame({'user_id': user['user_id'],\n",
" 'item_id': [-1] * n_recommendations,\n",
" 'score': [3.0] * n_recommendations})\n",
"\n",
" recommendations = pd.concat([recommendations, user_recommendations])\n",
"\n",
" return recommendations"
]
},
{
"cell_type": "markdown",
"id": "cleared-warehouse",
"metadata": {},
"source": [
"# Evaluation measures"
]
},
{
"cell_type": "markdown",
"id": "overall-perspective",
"metadata": {},
"source": [
"## Explicit feedback - ratings"
]
},
{
"cell_type": "markdown",
"id": "tamil-anderson",
"metadata": {},
"source": [
"### RMSE - Root Mean Squared Error\n",
"\n",
"<center>\n",
"$$\n",
" RMSE = \\sqrt{\\frac{\\sum_{i}^N (\\hat{r}_i - r_i)^2}{N}}\n",
"$$\n",
"</center>\n",
"\n",
"where $\\hat{r}_i$ are the predicted ratings and $r_i$ are the real ratings and $N$ is the number of items in the test set.\n",
"\n",
" + Very well-behaved analytically and therefore extensively used to train models, especially neural networks.\n",
" - The scale of errors dependent on data which reduced comparability between different datasets."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "entitled-snake",
"metadata": {},
"outputs": [],
"source": [
"def rmse(r_pred, r_real):\n",
" return np.sqrt(np.sum(np.power(r_pred - r_real, 2)) / len(r_pred))\n",
"\n",
"# Test\n",
"\n",
"print(\"RMSE = {:.2f}\".format(rmse(np.array([2.1, 1.2, 3.8, 4.2, 3.6]), np.array([3, 2, 4, 5, 1]))))"
]
},
{
"cell_type": "markdown",
"id": "unknown-arrival",
"metadata": {},
"source": [
"### MRE - Mean Relative Error\n",
"\n",
"<center>\n",
"$$\n",
" MRE = \\frac{1}{N} \\sum_{i}^N \\frac{|\\hat{r}_i - r_i|}{|r_i|}\n",
"$$\n",
"</center>\n",
"\n",
"where $\\hat{r}_i$ are the predicted ratings and $r_i$ are the real ratings and $N$ is the number of items in the test set.\n",
"\n",
" + Easily interpretable (average percentage error) and with a meaning understandable for business.\n",
" - Blows up when there are values close to zero among the predicted values."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "dying-course",
"metadata": {},
"outputs": [],
"source": [
"def mre(r_pred, r_real):\n",
" return 1 / len(r_pred) * np.sum(np.abs(r_pred - r_real) / np.abs(r_real))\n",
"\n",
"# Test\n",
"\n",
"print(\"MRE = {:.4f}\".format(mre(np.array([2.1, 1.2, 3.8, 4.2, 3.6]), np.array([3, 2, 4, 5, 1]))))"
]
},
{
"cell_type": "markdown",
"id": "imported-contribution",
"metadata": {},
"source": [
"### TRE - Total Relative Error\n",
"\n",
"<center>\n",
"$$\n",
" TRE = \\frac{\\sum_{i}^N |\\hat{r}_i - r_i|}{\\sum_{i}^N |r_i|}\n",
"$$\n",
"</center>\n",
"\n",
"where $\\hat{r}_i$ are the predicted ratings and $r_i$ are the real ratings and $N$ is the number of items in the test set.\n",
"\n",
" + Easily interpretable (total percentage error) and with a meaning understandable for business.\n",
" + Reliable even for very small predicted values.\n",
" - Does not distinguish between a case when one prediction is very bad and other are very good and a case when all predictions are mediocre."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "premium-trouble",
"metadata": {},
"outputs": [],
"source": [
"def tre(r_pred, r_real):\n",
" return np.sum(np.abs(r_pred - r_real)) / np.sum(np.abs(r_real))\n",
"\n",
"# Test\n",
"\n",
"print(\"TRE = {:.4f}\".format(tre(np.array([2.1, 1.2, 3.8, 4.2, 3.6]), np.array([3, 2, 4, 5, 1]))))"
]
},
{
"cell_type": "markdown",
"id": "quantitative-navigation",
"metadata": {},
"source": [
"## Implicit feedback - binary indicators of interactions"
]
},
{
"cell_type": "markdown",
"id": "obvious-egypt",
"metadata": {},
"source": [
"### HR@n - Hit Ratio \n",
"How many hits did we score in the first n recommendations.\n",
"<br/>\n",
"<br/>\n",
"<center>\n",
"$$\n",
" \\text{HR@}n = \\frac{\\sum_{u} \\sum_{i \\in I_u} r_{u, i} \\cdot 1_{\\hat{D}_n(u)}(i)}{M}\n",
"$$\n",
"</center>\n",
"\n",
"where:\n",
" * $r_{u, i}$ is $1$ if there was an interaction between user $u$ and item $i$ in the test set and $0$ otherwise, \n",
" * $\\hat{D}_n$ is the set of the first $n$ recommendations for user $u$, \n",
" * $1_{\\hat{D}_n}(i)$ is $1$ if and only if $i \\in \\hat{D}_n$, otherwise it's equal to $0$,\n",
" * $M$ is the number of users.\n",
"\n",
"\n",
" + Easily interpretable.\n",
" - Does not take the rank of each recommendation into account."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "found-amazon",
"metadata": {},
"outputs": [],
"source": [
"def hr(recommendations, real_interactions, n=1):\n",
" \"\"\"\n",
" Assumes recommendations are ordered by user_id and then by score.\n",
" \"\"\"\n",
" # Transform real_interactions to a dict for a large speed-up\n",
" rui = defaultdict(lambda: 0)\n",
" \n",
" for idx, row in real_interactions.iterrows():\n",
" rui[(row['user_id'], row['item_id'])] = 1\n",
" \n",
" hr = 0.0\n",
" \n",
" previous_user_id = -1\n",
" rank = 0\n",
" for idx, row in recommendations.iterrows():\n",
" if previous_user_id == row['user_id']:\n",
" rank += 1\n",
" else:\n",
" rank = 1\n",
" \n",
" if rank <= n:\n",
" hr += rui[(row['user_id'], row['item_id'])]\n",
" \n",
" previous_user_id = row['user_id']\n",
" \n",
" hr /= len(recommendations['user_id'].unique())\n",
" \n",
" return hr\n",
"\n",
" \n",
"recommendations = pd.DataFrame(\n",
" [\n",
" [1, 13, 0.9],\n",
" [1, 45, 0.8],\n",
" [1, 22, 0.71],\n",
" [1, 77, 0.55],\n",
" [1, 9, 0.52],\n",
" [2, 11, 0.85],\n",
" [2, 13, 0.69],\n",
" [2, 25, 0.64],\n",
" [2, 6, 0.60],\n",
" [2, 77, 0.53]\n",
" \n",
" ], columns=['user_id', 'item_id', 'score'])\n",
"\n",
"display(HTML(recommendations.to_html()))\n",
"\n",
"real_interactions = pd.DataFrame(\n",
" [\n",
" [1, 45],\n",
" [1, 22],\n",
" [1, 77],\n",
" [2, 13],\n",
" [2, 77]\n",
" \n",
" ], columns=['user_id', 'item_id'])\n",
"\n",
"display(HTML(real_interactions.to_html()))\n",
" \n",
"print(\"HR@3 = {:.4f}\".format(hr(recommendations, real_interactions, n=3)))"
]
},
{
"cell_type": "markdown",
"id": "behind-munich",
"metadata": {},
"source": [
"### NDCG@n - Normalized Discounted Cumulative Gain\n",
"\n",
"How many hits did we score in the first n recommendations discounted by the position of each recommendation.\n",
"<br/>\n",
"<br/>\n",
"<center>\n",
"$$\n",
" \\text{NDCG@}n = \\frac{\\sum_{u} \\sum_{i \\in I_u} \\frac{r_{u, i}}{log\\left(1 + v_{\\hat{D}_n(u)}(i)\\right)}}{M}\n",
"$$\n",
"</center>\n",
"\n",
"where:\n",
" * $r_{u, i}$ is $1$ if there was an interaction between user $u$ and item $i$ in the test set and $0$ otherwise, \n",
" * $\\hat{D}_n(u)$ is the set of the first $n$ recommendations for user $u$, \n",
" * $v_{\\hat{D}_n(u)}(i)$ is the position of item $i$ in recommendations $\\hat{D}_n$,\n",
" * $M$ is the number of users.\n",
"\n",
"\n",
" - Takes the rank of each recommendation into account."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "floral-anatomy",
"metadata": {},
"outputs": [],
"source": [
"def ndcg(recommendations, real_interactions, n=1):\n",
" \"\"\"\n",
" Assumes recommendations are ordered by user_id and then by score.\n",
" \"\"\"\n",
" # Transform real_interactions to a dict for a large speed-up\n",
" rui = defaultdict(lambda: 0)\n",
" \n",
" for idx, row in real_interactions.iterrows():\n",
" rui[(row['user_id'], row['item_id'])] = 1\n",
" \n",
" ndcg = 0.0\n",
" \n",
" previous_user_id = -1\n",
" rank = 0\n",
" for idx, row in recommendations.iterrows():\n",
" if previous_user_id == row['user_id']:\n",
" rank += 1\n",
" else:\n",
" rank = 1\n",
" \n",
" if rank <= n:\n",
" ndcg += rui[(row['user_id'], row['item_id'])] / np.log2(1 + rank)\n",
" \n",
" previous_user_id = row['user_id']\n",
" \n",
" ndcg /= len(recommendations['user_id'].unique())\n",
" \n",
" return ndcg\n",
"\n",
" \n",
"recommendations = pd.DataFrame(\n",
" [\n",
" [1, 13, 0.9],\n",
" [1, 45, 0.8],\n",
" [1, 22, 0.71],\n",
" [1, 77, 0.55],\n",
" [1, 9, 0.52],\n",
" [2, 11, 0.85],\n",
" [2, 13, 0.69],\n",
" [2, 25, 0.64],\n",
" [2, 6, 0.60],\n",
" [2, 77, 0.53]\n",
" \n",
" ], columns=['user_id', 'item_id', 'score'])\n",
"\n",
"display(HTML(recommendations.to_html()))\n",
"\n",
"real_interactions = pd.DataFrame(\n",
" [\n",
" [1, 45],\n",
" [1, 22],\n",
" [1, 77],\n",
" [2, 13],\n",
" [2, 77]\n",
" \n",
" ], columns=['user_id', 'item_id'])\n",
"\n",
"display(HTML(real_interactions.to_html()))\n",
" \n",
"print(\"NDCG@3 = {:.4f}\".format(ndcg(recommendations, real_interactions, n=3)))"
]
},
{
"cell_type": "markdown",
"id": "appointed-baltimore",
"metadata": {},
"source": [
"# Testing routines (offline)"
]
},
{
"cell_type": "markdown",
"id": "bizarre-elevation",
"metadata": {},
"source": [
"## Train and test set split"
]
},
{
"cell_type": "markdown",
"id": "fatty-blackjack",
"metadata": {},
"source": [
"### Explicit feedback"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "negative-cigarette",
"metadata": {},
"outputs": [],
"source": [
"def evaluate_train_test_split_explicit(recommender, interactions_df, items_df, seed=6789):\n",
" rng = np.random.RandomState(seed=seed)\n",
" \n",
" # Split the dataset into train and test\n",
" \n",
" shuffle = np.arange(len(interactions_df))\n",
" rng.shuffle(shuffle)\n",
" shuffle = list(shuffle)\n",
"\n",
" train_test_split = 0.8\n",
" split_index = int(len(interactions_df) * train_test_split)\n",
"\n",
" interactions_df_train = interactions_df.iloc[shuffle[:split_index]]\n",
" interactions_df_test = interactions_df.iloc[shuffle[split_index:]]\n",
" \n",
" # Train the recommender\n",
" \n",
" recommender.fit(interactions_df_train, None, items_df)\n",
" \n",
" # Gather predictions\n",
" \n",
" r_pred = []\n",
" \n",
" for idx, row in interactions_df_test.iterrows():\n",
" users_df = pd.DataFrame([row['user_id']], columns=['user_id'])\n",
" eval_items_df = pd.DataFrame([row['item_id']], columns=['item_id'])\n",
" eval_items_df = pd.merge(eval_items_df, items_df, on='item_id')\n",
" recommendations = recommender.recommend(users_df, eval_items_df, n_recommendations=1)\n",
" \n",
" r_pred.append(recommendations.iloc[0]['score'])\n",
" \n",
" # Gather real ratings\n",
" \n",
" r_real = np.array(interactions_df_test['rating'].tolist())\n",
" \n",
" # Return evaluation metrics\n",
" \n",
" return rmse(r_pred, r_real), mre(r_pred, r_real), tre(r_pred, r_real)\n",
"\n",
"recommender = Recommender()\n",
"\n",
"results = [['BaseRecommender'] + list(evaluate_train_test_split_explicit(\n",
" recommender, ml_ratings_df.loc[:, ['user_id', 'item_id', 'rating']], ml_movies_df))]\n",
"\n",
"results = pd.DataFrame(results, \n",
" columns=['Recommender', 'RMSE', 'MRE', 'TRE'])\n",
"\n",
"display(HTML(results.to_html()))"
]
},
{
"cell_type": "markdown",
"id": "naval-croatia",
"metadata": {},
"source": [
"### Implicit feedback"
]
},
{
"cell_type": "markdown",
"id": "separated-enclosure",
"metadata": {},
"source": [
"**Task 1.** Implement the following method for train-test split evaluation for implicit feedback."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "considerable-sunrise",
"metadata": {},
"outputs": [],
"source": [
"def evaluate_train_test_split_implicit(recommender, interactions_df, items_df, seed=6789):\n",
" # Write your code here\n",
" pass"
]
},
{
"cell_type": "markdown",
"id": "muslim-tunisia",
"metadata": {},
"source": [
"## Leave-one-out, leave-k-out, cross-validation"
]
},
{
"cell_type": "markdown",
"id": "adjusted-spirit",
"metadata": {},
"source": [
"### Explicit feedback"
]
},
{
"cell_type": "markdown",
"id": "alpine-luxembourg",
"metadata": {},
"source": [
"**Task 2.** Implement the following method for leave-one-out evaluation for explicit feedback."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "flexible-runner",
"metadata": {},
"outputs": [],
"source": [
"def evaluate_leave_one_out_explicit(recommender, interactions_df, items_df, max_evals=100, seed=6789):\n",
" # Write your code here\n",
" pass"
]
},
{
"cell_type": "markdown",
"id": "engaged-lloyd",
"metadata": {},
"source": [
"### Implicit feedback"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "surrounded-newton",
"metadata": {},
"outputs": [],
"source": [
"def evaluate_leave_one_out_implicit(recommender, interactions_df, items_df, max_evals=10, seed=6789):\n",
" rng = np.random.RandomState(seed=seed)\n",
" \n",
" # Prepare splits of the datasets\n",
" kf = KFold(n_splits=len(interactions_df), random_state=rng, shuffle=True)\n",
" \n",
" hr_1 = []\n",
" hr_3 = []\n",
" hr_5 = []\n",
" hr_10 = []\n",
" ndcg_1 = []\n",
" ndcg_3 = []\n",
" ndcg_5 = []\n",
" ndcg_10 = []\n",
" \n",
" # For each split of the dataset train the recommender, generate recommendations and evaluate\n",
" \n",
" n_eval = 1\n",
" for train_index, test_index in kf.split(interactions_df.index):\n",
" interactions_df_train = interactions_df.loc[interactions_df.index[train_index]]\n",
" interactions_df_test = interactions_df.loc[interactions_df.index[test_index]]\n",
" \n",
" recommender.fit(interactions_df_train, None, items_df)\n",
" recommendations = recommender.recommend(interactions_df_test.loc[:, ['user_id']], items_df, n_recommendations=10)\n",
" \n",
" hr_1.append(hr(recommendations, interactions_df_test, n=1))\n",
" hr_3.append(hr(recommendations, interactions_df_test, n=3))\n",
" hr_5.append(hr(recommendations, interactions_df_test, n=5))\n",
" hr_10.append(hr(recommendations, interactions_df_test, n=10))\n",
" ndcg_1.append(ndcg(recommendations, interactions_df_test, n=1))\n",
" ndcg_3.append(ndcg(recommendations, interactions_df_test, n=3))\n",
" ndcg_5.append(ndcg(recommendations, interactions_df_test, n=5))\n",
" ndcg_10.append(ndcg(recommendations, interactions_df_test, n=10))\n",
" \n",
" if n_eval == max_evals:\n",
" break\n",
" n_eval += 1\n",
" \n",
" hr_1 = np.mean(hr_1)\n",
" hr_3 = np.mean(hr_3)\n",
" hr_5 = np.mean(hr_5)\n",
" hr_10 = np.mean(hr_10)\n",
" ndcg_1 = np.mean(ndcg_1)\n",
" ndcg_3 = np.mean(ndcg_3)\n",
" ndcg_5 = np.mean(ndcg_5)\n",
" ndcg_10 = np.mean(ndcg_10)\n",
" \n",
" return hr_1, hr_3, hr_5, hr_10, ndcg_1, ndcg_3, ndcg_5, ndcg_10\n",
"\n",
"recommender = Recommender()\n",
"\n",
"results = [['BaseRecommender'] + list(evaluate_leave_one_out_implicit(\n",
" recommender, ml_ratings_df.loc[:, ['user_id', 'item_id']], ml_movies_df))]\n",
"\n",
"results = pd.DataFrame(results, \n",
" columns=['Recommender', 'HR@1', 'HR@3', 'HR@5', 'HR@10', 'NDCG@1', 'NDCG@3', 'NDCG@5', 'NDCG@10'])\n",
"\n",
"display(HTML(results.to_html()))"
]
},
{
"cell_type": "markdown",
"id": "optional-chain",
"metadata": {},
"source": [
"# Linear Regression Recommender\n",
"\n",
"For every movie we transform its genres into one-hot encoded features and then fit a linear regression model to those features and actual ratings."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "sonic-horror",
"metadata": {},
"outputs": [],
"source": [
"from sklearn.linear_model import LinearRegression\n",
"from sklearn.preprocessing import MultiLabelBinarizer\n",
"\n",
"class LinearRegressionRecommender(object):\n",
" \"\"\"\n",
" Base recommender class.\n",
" \"\"\"\n",
" \n",
" def __init__(self):\n",
" \"\"\"\n",
" Initialize base recommender params and variables.\n",
" \"\"\"\n",
" self.model = None\n",
" self.mlb = None\n",
" \n",
" def fit(self, interactions_df, users_df, items_df):\n",
" \"\"\"\n",
" Training of the recommender.\n",
" \n",
" :param pd.DataFrame interactions_df: DataFrame with recorded interactions between users and items \n",
" defined by user_id, item_id and features of the interaction.\n",
" :param pd.DataFrame users_df: DataFrame with users and their features defined by user_id and the user feature columns.\n",
" :param pd.DataFrame items_df: DataFrame with items and their features defined by item_id and the item feature columns.\n",
" \"\"\"\n",
" \n",
" interactions_df = pd.merge(interactions_df, items_df, on='item_id')\n",
" interactions_df.loc[:, 'genres'] = interactions_df['genres'].str.replace(\"-\", \"_\", regex=False)\n",
" interactions_df.loc[:, 'genres'] = interactions_df['genres'].str.replace(\" \", \"_\", regex=False)\n",
" interactions_df.loc[:, 'genres'] = interactions_df['genres'].str.lower()\n",
" interactions_df.loc[:, 'genres'] = interactions_df['genres'].str.split(\"|\")\n",
" \n",
" self.mlb = MultiLabelBinarizer()\n",
" interactions_df = interactions_df.join(\n",
" pd.DataFrame(self.mlb.fit_transform(interactions_df.pop('genres')),\n",
" columns=self.mlb.classes_,\n",
" index=interactions_df.index))\n",
" \n",
"# print(interactions_df.head())\n",
" \n",
" x = interactions_df.loc[:, self.mlb.classes_].values\n",
" y = interactions_df['rating'].values\n",
" \n",
" self.model = LinearRegression().fit(x, y)\n",
" \n",
" def recommend(self, users_df, items_df, n_recommendations=1):\n",
" \"\"\"\n",
" Serving of recommendations. Scores items in items_df for each user in users_df and returns \n",
" top n_recommendations for each user.\n",
" \n",
" :param pd.DataFrame users_df: DataFrame with users and their features for which recommendations should be generated.\n",
" :param pd.DataFrame items_df: DataFrame with items and their features which should be scored.\n",
" :param int n_recommendations: Number of recommendations to be returned for each user.\n",
" :return: DataFrame with user_id, item_id and score as columns returning n_recommendations top recommendations \n",
" for each user.\n",
" :rtype: pd.DataFrame\n",
" \"\"\"\n",
" \n",
" # Transform the item to be scored into proper features\n",
" \n",
" items_df = items_df.copy()\n",
" items_df.loc[:, 'genres'] = items_df['genres'].str.replace(\"-\", \"_\", regex=False)\n",
" items_df.loc[:, 'genres'] = items_df['genres'].str.replace(\" \", \"_\", regex=False)\n",
" items_df.loc[:, 'genres'] = items_df['genres'].str.lower()\n",
" items_df.loc[:, 'genres'] = items_df['genres'].str.split(\"|\")\n",
" \n",
" items_df = items_df.join(\n",
" pd.DataFrame(self.mlb.transform(items_df.pop('genres')),\n",
" columns=self.mlb.classes_,\n",
" index=items_df.index))\n",
" \n",
"# print(items_df)\n",
" \n",
" # Score the item\n",
" \n",
" recommendations = pd.DataFrame(columns=['user_id', 'item_id', 'score'])\n",
" \n",
" for ix, user in users_df.iterrows():\n",
" score = self.model.predict(items_df.loc[:, self.mlb.classes_].values)[0]\n",
" \n",
" user_recommendations = pd.DataFrame({'user_id': [user['user_id']],\n",
" 'item_id': items_df.iloc[0]['item_id'],\n",
" 'score': score})\n",
"\n",
" recommendations = pd.concat([recommendations, user_recommendations])\n",
"\n",
" return recommendations"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "colored-favorite",
"metadata": {},
"outputs": [],
"source": [
"# Quick test of the recommender\n",
"\n",
"lr_recommender = LinearRegressionRecommender()\n",
"lr_recommender.fit(ml_ratings_df, None, ml_movies_df)\n",
"recommendations = lr_recommender.recommend(pd.DataFrame([[1], [2]], columns=['user_id']), ml_movies_df, 1)\n",
"\n",
"recommendations = pd.merge(recommendations, ml_movies_df, on='item_id')\n",
"display(HTML(recommendations.to_html()))"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "national-sight",
"metadata": {},
"outputs": [],
"source": [
"lr_recommender = LinearRegressionRecommender()\n",
"\n",
"results = [['LinearRegressionRecommender'] + list(evaluate_train_test_split_explicit(\n",
" lr_recommender, ml_ratings_df.loc[:, ['user_id', 'item_id', 'rating']], ml_movies_df, seed=6789))]\n",
"\n",
"results = pd.DataFrame(results, \n",
" columns=['Recommender', 'RMSE', 'MRE', 'TRE'])\n",
"\n",
"display(HTML(results.to_html()))"
]
},
{
"cell_type": "markdown",
"id": "static-mozambique",
"metadata": {},
"source": [
"# TF-IDF Recommender\n",
"TF-IDF stands for term frequencyinverse document frequency. Typically Tf-IDF method is used to assign keywords (words describing the gist of a document) to documents in a corpus of documents.\n",
"\n",
"In our case we will treat users as documents and genres as words.\n",
"\n",
"Term-frequency is given by the following formula:\n",
"<center>\n",
"$$\n",
" \\text{tf}(g, u) = f_{g, u}\n",
"$$\n",
"</center>\n",
"where $f_{g, i}$ is the number of times genre $g$ appear for movies watched by user $u$.\n",
"\n",
"Inverse document frequency is defined as follows:\n",
"<center>\n",
"$$\n",
" \\text{idf}(g) = \\log \\frac{N}{n_g}\n",
"$$\n",
"</center>\n",
"where $N$ is the number of users and $n_g$ is the number of users with $g$ in their genres list.\n",
"\n",
"Finally, tf-idf is defined as follows:\n",
"<center>\n",
"$$\n",
" \\text{tfidf}(g, u) = \\text{tf}(g, u) \\cdot \\text{idf}(g)\n",
"$$\n",
"</center>\n",
"\n",
"In our case we will measure how often a given genre appears for movies watched by a given user vs how often it appears for all users. To obtain a movie score we will take the average of its genres' scores for this user."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "infrared-southwest",
"metadata": {},
"outputs": [],
"source": [
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
"\n",
"class TFIDFRecommender(object):\n",
" \"\"\"\n",
" Recommender based on the TF-IDF method.\n",
" \"\"\"\n",
" \n",
" def __init__(self):\n",
" \"\"\"\n",
" Initialize base recommender params and variables.\n",
" \"\"\"\n",
" self.tfidf_scores = None\n",
" \n",
" def fit(self, interactions_df, users_df, items_df):\n",
" \"\"\"\n",
" Training of the recommender.\n",
" \n",
" :param pd.DataFrame interactions_df: DataFrame with recorded interactions between users and items \n",
" defined by user_id, item_id and features of the interaction.\n",
" :param pd.DataFrame users_df: DataFrame with users and their features defined by user_id and the user feature columns.\n",
" :param pd.DataFrame items_df: DataFrame with items and their features defined by item_id and the item feature columns.\n",
" \"\"\"\n",
" \n",
" self.tfidf_scores = defaultdict(lambda: 0.0)\n",
"\n",
" # Prepare the corpus for tfidf calculation\n",
" \n",
" interactions_df = pd.merge(interactions_df, items_df, on='item_id')\n",
" user_genres = interactions_df.loc[:, ['user_id', 'genres']]\n",
" user_genres.loc[:, 'genres'] = user_genres['genres'].str.replace(\"-\", \"_\", regex=False)\n",
" user_genres.loc[:, 'genres'] = user_genres['genres'].str.replace(\" \", \"_\", regex=False)\n",
" user_genres = user_genres.groupby('user_id').aggregate(lambda x: \"|\".join(x))\n",
" user_genres.loc[:, 'genres'] = user_genres['genres'].str.replace(\"|\", \" \", regex=False)\n",
"# print(user_genres)\n",
" user_ids = user_genres.index.tolist()\n",
" genres_corpus = user_genres['genres'].tolist()\n",
" \n",
" # Calculate tf-idf scores\n",
" \n",
" vectorizer = TfidfVectorizer()\n",
" tfidf_scores = vectorizer.fit_transform(genres_corpus)\n",
" \n",
" # Transform results into a dict {(user_id, genre): score}\n",
" \n",
" for u in range(tfidf_scores.shape[0]):\n",
" for g in range(tfidf_scores.shape[1]):\n",
" self.tfidf_scores[(user_ids[u], vectorizer.get_feature_names()[g])] = tfidf_scores[u, g]\n",
" \n",
"# print(self.tfidf_scores)\n",
" \n",
" def recommend(self, users_df, items_df, n_recommendations=1):\n",
" \"\"\"\n",
" Serving of recommendations. Scores items in items_df for each user in users_df and returns \n",
" top n_recommendations for each user.\n",
" \n",
" :param pd.DataFrame users_df: DataFrame with users and their features for which recommendations should be generated.\n",
" :param pd.DataFrame items_df: DataFrame with items and their features which should be scored.\n",
" :param int n_recommendations: Number of recommendations to be returned for each user.\n",
" :return: DataFrame with user_id, item_id and score as columns returning n_recommendations top recommendations \n",
" for each user.\n",
" :rtype: pd.DataFrame\n",
" \"\"\"\n",
" \n",
" recommendations = pd.DataFrame(columns=['user_id', 'item_id', 'score'])\n",
" \n",
" # Transform genres to a unified form used by the vectorizer\n",
" \n",
" items_df = items_df.copy()\n",
" items_df.loc[:, 'genres'] = items_df['genres'].str.replace(\"-\", \"_\", regex=False)\n",
" items_df.loc[:, 'genres'] = items_df['genres'].str.replace(\" \", \"_\", regex=False)\n",
" items_df.loc[:, 'genres'] = items_df['genres'].str.lower()\n",
" items_df.loc[:, 'genres'] = items_df['genres'].str.split(\"|\")\n",
" \n",
" # Score items \n",
" \n",
" for uix, user in users_df.iterrows():\n",
" items = []\n",
" for iix, item in items_df.iterrows():\n",
" score = 0.0\n",
" for genre in item['genres']:\n",
" score += self.tfidf_scores[(user['user_id'], genre)]\n",
" score /= len(item['genres'])\n",
" items.append((item['item_id'], score))\n",
" \n",
" items = sorted(items, key=lambda x: x[1], reverse=True)\n",
" user_recommendations = pd.DataFrame({'user_id': user['user_id'],\n",
" 'item_id': [item[0] for item in items][:n_recommendations],\n",
" 'score': [item[1] for item in items][:n_recommendations]})\n",
"\n",
" recommendations = pd.concat([recommendations, user_recommendations])\n",
"\n",
" return recommendations"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "oriented-service",
"metadata": {},
"outputs": [],
"source": [
"# Quick test of the recommender\n",
"\n",
"tfidf_recommender = TFIDFRecommender()\n",
"tfidf_recommender.fit(ml_ratings_df, None, ml_movies_df)\n",
"recommendations = tfidf_recommender.recommend(pd.DataFrame([[1], [2]], columns=['user_id']), ml_movies_df, 3)\n",
"\n",
"recommendations = pd.merge(recommendations, ml_movies_df, on='item_id')\n",
"display(HTML(recommendations.to_html()))"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "qualified-westminster",
"metadata": {},
"outputs": [],
"source": [
"tfidf_recommender = TFIDFRecommender()\n",
"\n",
"results = [['TFIDFRecommender'] + list(evaluate_leave_one_out_implicit(\n",
" tfidf_recommender, ml_ratings_df.loc[:, ['user_id', 'item_id']], ml_movies_df, max_evals=300, seed=6789))]\n",
"\n",
"results = pd.DataFrame(results, \n",
" columns=['Recommender', 'HR@1', 'HR@3', 'HR@5', 'HR@10', 'NDCG@1', 'NDCG@3', 'NDCG@5', 'NDCG@10'])\n",
"\n",
"display(HTML(results.to_html()))"
]
},
{
"cell_type": "markdown",
"id": "beautiful-snapshot",
"metadata": {},
"source": [
"# Tasks"
]
},
{
"cell_type": "markdown",
"id": "growing-maria",
"metadata": {},
"source": [
"**Task 3.** Implement the MostPopularRecommender (check the slides for class 1), evaluate it with leave-one-out procedure for implicit feedback, print HR@1, HR@3, HR@5, HR@10, NDCG@1, NDCG@3, NDCG@5, NDCG@10."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "strategic-commons",
"metadata": {},
"outputs": [],
"source": [
"# Write your code here"
]
},
{
"cell_type": "markdown",
"id": "black-schedule",
"metadata": {},
"source": [
"**Task 4.** Implement the HighestRatedRecommender (check the slides for class 1), evaluate it with leave-one-out procedure for implicit feedback, print HR@1, HR@3, HR@5, HR@10, NDCG@1, NDCG@3, NDCG@5, NDCG@10."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "likely-vatican",
"metadata": {},
"outputs": [],
"source": [
"# Write your code here"
]
},
{
"cell_type": "markdown",
"id": "handy-palmer",
"metadata": {},
"source": [
"**Task 5.** Implement the RandomRecommender (check the slides for class 1), evaluate it with leave-one-out procedure for implicit feedback, print HR@1, HR@3, HR@5, HR@10, NDCG@1, NDCG@3, NDCG@5, NDCG@10."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "minute-cliff",
"metadata": {},
"outputs": [],
"source": [
"# Write your code here"
]
},
{
"cell_type": "markdown",
"id": "animal-heart",
"metadata": {},
"source": [
"**Task 6.** Gather the results for TFIDFRecommender, MostPopularRecommender, HighestRatedRecommender, RandomRecommender in one DataFrame and print it."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "optical-creator",
"metadata": {},
"outputs": [],
"source": [
"# Write your code here"
]
},
{
"cell_type": "markdown",
"id": "visible-burlington",
"metadata": {},
"source": [
"**Task 7\\*.** Implement an SVRRecommender - one-hot encode genres and fit an SVR model to \n",
"\n",
"(genre_1, genre_2, ..., genre_N) -> rating\n",
"\n",
"Tune params of the SVR model to obtain as good results as you can. \n",
"\n",
"To do tuning properly (although in practive people are often happy with leave-one-out and do not bother with dividing the set into training, validation and test sets):\n",
" - divide the set into training, validation and test sets (randomly divide the dataset in proportions 60%-20%-20%),\n",
" - train the model with different sets of tunable parameters on the training set, \n",
" - choose the best tunable params based on results on the validation set, \n",
" - provide the final evaluation metrics on the test set for the best model obtained during tuning.\n",
"\n",
"Recommended method of tuning: use hyperopt. Install the package using the following command: `pip install hyperopt`\n",
" \n",
"Print the RMSE and MAE on the test set generated with numpy with seed 6789."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "promotional-gregory",
"metadata": {},
"outputs": [],
"source": [
"# Write your code here"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.8"
}
},
"nbformat": 4,
"nbformat_minor": 5
}