Created
December 3, 2024 04:27
-
-
Save myui/83ec5077d0e582de5943b98941b59f71 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"cells": [ | |
{ | |
"cell_type": "code", | |
"execution_count": 195, | |
"id": "f26a223d-db9c-463d-b52b-2ea532accd13", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"from typing import List\n", | |
"import numpy as np\n", | |
"from numpy.typing import ArrayLike\n", | |
"import scipy.sparse as sp\n", | |
"from sklearn.linear_model import ElasticNet, SGDRegressor\n", | |
"import warnings\n", | |
"from sklearn.exceptions import ConvergenceWarning\n", | |
"from tqdm import tqdm\n", | |
"from scipy.sparse.linalg import cg\n", | |
"\n", | |
"class ColumnarView:\n", | |
" def __init__(self, csr_matrix: sp.csr_matrix):\n", | |
" self.csr_matrix = csr_matrix\n", | |
" # create the columnar view of the matrix\n", | |
" ind = self.csr_matrix.copy()\n", | |
" ind.data = np.arange(len(ind.data)) # store the original data indices\n", | |
" self.col_view = ind.tocsc()\n", | |
"\n", | |
" @property\n", | |
" def shape(self) -> tuple:\n", | |
" return self.csr_matrix.shape\n", | |
" \n", | |
" def get_col(self, j: int) -> sp.csc_matrix:\n", | |
" \"\"\"\n", | |
" Return the j-th column of the matrix.\n", | |
"\n", | |
" Parameters\n", | |
" ----------\n", | |
" j : int\n", | |
" The column index.\n", | |
"\n", | |
" Returns\n", | |
" -------\n", | |
" scipy.sparse.csc_matrix\n", | |
" The j-th column of the matrix.\n", | |
" \"\"\"\n", | |
"\n", | |
" col = self.col_view[:, j].copy()\n", | |
" col.data = self.csr_matrix.data[col.data]\n", | |
" return col\n", | |
" \n", | |
" def set_col(self, j: int, values: ArrayLike) -> None:\n", | |
" \"\"\"\n", | |
" Set the j-th column of the matrix to the given values.\n", | |
"\n", | |
" Parameters\n", | |
" ----------\n", | |
" j : int\n", | |
" The column index.\n", | |
" values : ArrayLike\n", | |
" The new values for the column.\n", | |
" \"\"\"\n", | |
" self.csr_matrix.data[self.col_view[:, j].data] = values\n", | |
"\n", | |
"class FeatureSelectionWrapper:\n", | |
"\n", | |
" def __init__(self, model: ElasticNet, n_neighbors: int = 30):\n", | |
" assert n_neighbors > 0, f\"n_neighbors must be a positive integer: {n_neighbors}\"\n", | |
" self.model = model\n", | |
" self.n_neighbors = n_neighbors\n", | |
" self.coef_ = None\n", | |
"\n", | |
" def fit(self, X: sp.csr_matrix, y: np.ndarray):\n", | |
" # Compute dot products between items and the target\n", | |
" feature_scores = X.T.dot(y).flatten()\n", | |
" # Select the top-k similar items to the target item by sorting the dot products\n", | |
" selected_features = np.argsort(feature_scores)[-1:-1-self.n_neighbors:-1]\n", | |
"\n", | |
" # Only fit the model with the selected features\n", | |
" self.model.fit(X[:, selected_features], y)\n", | |
" \n", | |
" # Store the coefficients of the fitted model\n", | |
" coef = np.zeros(X.shape[1])\n", | |
" coef[selected_features] = self.model.coef_\n", | |
" self.coef_ = coef\n", | |
" return self\n", | |
"\n", | |
"class SLIMElastic:\n", | |
" \"\"\"\n", | |
" SLIMElastic is a sparse linear method for top-K recommendation, which learns\n", | |
" a sparse aggregation coefficient matrix by solving an L1-norm and L2-norm\n", | |
" regularized optimization problem.\n", | |
" \"\"\"\n", | |
"\n", | |
" def __init__(self, config: dict={}):\n", | |
" \"\"\"\n", | |
" Initialize the SLIMElastic model.\n", | |
" \n", | |
" Args:\n", | |
" alpha (float): Regularization strength.\n", | |
" l1_ratio (float): The ratio between L1 and L2 regularization.\n", | |
" positive_only (bool): Whether to enforce positive coefficients.\n", | |
" \"\"\"\n", | |
" self.model_name = config.get(\"model_name\", \"cd\")\n", | |
" self.eta0 = config.get(\"eta0\", 0.001)\n", | |
" self.alpha = config.get(\"alpha\", 0.1)\n", | |
" self.l1_ratio = config.get(\"l1_ratio\", 0.1)\n", | |
" self.positive_only = config.get(\"positive_only\", True)\n", | |
" self.max_iter = config.get(\"max_iter\", 30)\n", | |
" self.tol = config.get(\"tol\", 1e-4)\n", | |
" self.random_state = config.get(\"random_state\", 43)\n", | |
" self.nn_feature_selection = config.get(\"nn_feature_selection\", None)\n", | |
"\n", | |
" # Initialize an empty item similarity matrix (will be computed during fit)\n", | |
" self.item_similarity = None\n", | |
"\n", | |
" def get_model(self):\n", | |
" if self.model_name == \"cd\" or self.model_name == \"coordinate_descent\":\n", | |
" return ElasticNet(\n", | |
" alpha=self.alpha, # Regularization strength\n", | |
" l1_ratio=self.l1_ratio,\n", | |
" positive=self.positive_only, # Enforce positive coefficients\n", | |
" fit_intercept=False,\n", | |
" copy_X=False, # Avoid copying the input matrix\n", | |
" precompute=True, # Precompute Gram matrix for faster computation\n", | |
" max_iter=self.max_iter,\n", | |
" tol=self.tol,\n", | |
" selection='random', # Randomize the order of features\n", | |
" random_state=self.random_state,\n", | |
" )\n", | |
" elif self.model_name == \"sgd\":\n", | |
" return SGDRegressor(\n", | |
" penalty='elasticnet',\n", | |
" eta0 = self.eta0,\n", | |
" alpha=self.alpha,\n", | |
" l1_ratio=self.l1_ratio,\n", | |
" fit_intercept=False,\n", | |
" max_iter=self.max_iter,\n", | |
" tol=self.tol,\n", | |
" random_state=self.random_state,\n", | |
" )\n", | |
" else:\n", | |
" raise ValueError(f\"Invalid model name: {self.model_name}\")\n", | |
"\n", | |
" def fit(self, interaction_matrix: sp.csr_matrix):\n", | |
" \"\"\"\n", | |
" Fit the SLIMElastic model to the interaction matrix.\n", | |
"\n", | |
" Args:\n", | |
" interaction_matrix (csr_matrix): User-item interaction matrix (sparse).\n", | |
" \"\"\"\n", | |
" if not isinstance(interaction_matrix, sp.csr_matrix):\n", | |
" raise ValueError(\"Interaction matrix must be a scipy.sparse.csr_matrix of user-item interactions.\")\n", | |
"\n", | |
" X = ColumnarView(interaction_matrix)\n", | |
" num_items = X.shape[1]\n", | |
"\n", | |
" self.item_similarity = np.zeros((num_items, num_items)) # Initialize similarity matrix\n", | |
" \n", | |
" model = self.get_model()\n", | |
"\n", | |
" if self.nn_feature_selection is not None:\n", | |
" model = FeatureSelectionWrapper(model, n_neighbors=int(self.nn_feature_selection))\n", | |
"\n", | |
" # Ignore convergence warnings for ElasticNet\n", | |
" with warnings.catch_warnings():\n", | |
" warnings.simplefilter(\"ignore\", category=ConvergenceWarning)\n", | |
"\n", | |
" # Iterate through each item (column) and fit the model\n", | |
" for j in tqdm(range(num_items), desc=\"Fitting SLIMElastic\"):\n", | |
" # Target column (current item)\n", | |
" y = X.get_col(j)\n", | |
"\n", | |
" # Set the target item column to 0\n", | |
" X.set_col(j, np.zeros_like(y.data))\n", | |
"\n", | |
" # Fit the model\n", | |
" model.fit(X.csr_matrix, y.toarray().ravel())\n", | |
"\n", | |
" # Update the item similarity matrix with new coefficients (weights for each user-item interaction)\n", | |
" self.item_similarity[:, j] = model.coef_\n", | |
"\n", | |
" # Reattach the item column after training\n", | |
" X.set_col(j, y.data)\n", | |
"\n", | |
" return self\n", | |
"\n", | |
" def partial_fit(self, interaction_matrix: sp.csr_matrix, user_ids: List[int]):\n", | |
" \"\"\"\n", | |
" Incrementally fit the SLIMElastic model with new or updated users.\n", | |
"\n", | |
" Args:\n", | |
" interaction_matrix (coo_matrix): user-item interaction matrix (sparse).\n", | |
" user_ids (list): List of user indices that were updated.\n", | |
" \"\"\" \n", | |
" user_items = set()\n", | |
" for user_id in user_ids:\n", | |
" user_items.update(interaction_matrix[user_id].indices.tolist())\n", | |
" return self.partial_fit_items(interaction_matrix, list(user_items))\n", | |
"\n", | |
" def partial_fit_items(self, interaction_matrix: sp.csr_matrix, updated_items: List[int]):\n", | |
" \"\"\"\n", | |
" Incrementally fit the SLIMElastic model with new or updated items.\n", | |
"\n", | |
" Args:\n", | |
" interaction_matrix (coo_matrix): user-item interaction matrix (sparse).\n", | |
" updated_items (list): List of item indices that were updated.\n", | |
" \"\"\"\n", | |
" if not isinstance(interaction_matrix, sp.csr_matrix):\n", | |
" raise ValueError(\"Interaction matrix must be a scipy.sparse.csr_matrix of user-item interactions.\")\n", | |
"\n", | |
" X = ColumnarView(interaction_matrix)\n", | |
"\n", | |
" model = self.get_model()\n", | |
"\n", | |
" if self.nn_feature_selection is not None:\n", | |
" model = FeatureSelectionWrapper(model, n_neighbors=int(self.nn_feature_selection))\n", | |
"\n", | |
" old_size = self.item_similarity.shape[1]\n", | |
" new_size = max(updated_items) + 1\n", | |
" if new_size > old_size:\n", | |
" # Expand both rows and columns symmetrically\n", | |
" expanded_similarity = np.zeros((new_size, new_size))\n", | |
" expanded_similarity[:old_size, :old_size] = self.item_similarity\n", | |
" self.item_similarity = expanded_similarity\n", | |
"\n", | |
" # Iterate through the updated items and fit the model incrementally\n", | |
" with warnings.catch_warnings():\n", | |
" warnings.simplefilter(\"ignore\", category=ConvergenceWarning)\n", | |
"\n", | |
" for j in tqdm(updated_items):\n", | |
" # Target column (current item)\n", | |
" y = X.get_col(j)\n", | |
"\n", | |
" # Set the target item column to 0\n", | |
" X.set_col(j, np.zeros_like(y.data))\n", | |
"\n", | |
" # Fit the model for the updated item\n", | |
" model.fit(X.csr_matrix, y.toarray().ravel())\n", | |
"\n", | |
" # Update the item similarity matrix with new coefficients (weights for each user-item interaction)\n", | |
" self.item_similarity[:, j] = model.coef_\n", | |
"\n", | |
" # Reattach the item column after training\n", | |
" X.set_col(j, y.data)\n", | |
"\n", | |
" return self\n", | |
"\n", | |
" def predict(self, user_id: int, interaction_matrix: sp.csr_matrix):\n", | |
" \"\"\"\n", | |
" Compute the predicted scores for a specific user across all items.\n", | |
"\n", | |
" Args:\n", | |
" user_id (int): The user ID (row index in interaction_matrix).\n", | |
" interaction_matrix (csr_matrix): User-item interaction matrix.\n", | |
"\n", | |
" Returns:\n", | |
" numpy.ndarray: Predicted scores for the user across all items.\n", | |
" \"\"\"\n", | |
" if self.item_similarity is None:\n", | |
" raise RuntimeError(\"Model must be fitted before calling predict.\")\n", | |
"\n", | |
" # Compute the predicted scores by performing dot product between the user interaction vector\n", | |
" # and the item similarity matrix\n", | |
" return interaction_matrix[user_id].dot(self.item_similarity)\n", | |
"\n", | |
" def predict_all(self, interaction_matrix: sp.csr_matrix):\n", | |
" \"\"\"\n", | |
" Compute the predicted scores for all users and items.\n", | |
"\n", | |
" Args:\n", | |
" interaction_matrix (csr_matrix): User-item interaction matrix.\n", | |
"\n", | |
" Returns:\n", | |
" numpy.ndarray: Predicted scores for all users and items.\n", | |
" \"\"\"\n", | |
" if self.item_similarity is None:\n", | |
" raise RuntimeError(\"Model must be fitted before calling predict_all.\")\n", | |
"\n", | |
" # Compute the predicted scores for all users by performing dot product between the interaction matrix\n", | |
" # and the item similarity matrix\n", | |
" return interaction_matrix.dot(self.item_similarity)\n", | |
"\n", | |
" def recommend(self, user_id: int, interaction_matrix: sp.csr_matrix, top_k: int=10, exclude_seen: bool=True) -> List[int]:\n", | |
" \"\"\"\n", | |
" Recommend top-K items for a given user.\n", | |
"\n", | |
" Args:\n", | |
" user_id (int): ID of the user (row index in interaction_matrix).\n", | |
" interaction_matrix (csr_matrix): User-item interaction matrix (sparse).\n", | |
" top_k (int): Number of recommendations to return.\n", | |
" exclude_seen (bool): Whether to exclude items the user has already interacted with.\n", | |
"\n", | |
" Returns:\n", | |
" List of recommended item indices.\n", | |
" \"\"\"\n", | |
" # Get predicted scores for all items for the given user\n", | |
" user_scores = self.predict(user_id, interaction_matrix).ravel()\n", | |
"\n", | |
" # Exclude items that the user has already interacted with\n", | |
" if exclude_seen:\n", | |
" seen_items = interaction_matrix[user_id].indices\n", | |
" user_scores[seen_items] = -np.inf # Exclude seen items by setting scores to -inf\n", | |
"\n", | |
" # Get the top-K items by sorting the predicted scores in descending order\n", | |
" # [::-1] reverses the order to get the items with the highest scores first\n", | |
" top_items = np.argsort(user_scores)[-top_k:][::-1]\n", | |
"\n", | |
" return top_items" | |
] | |
}, | |
{ | |
"cell_type": "markdown", | |
"id": "e4d16d42-e30e-4a8b-85ff-9b18f8ceb798", | |
"metadata": {}, | |
"source": [ | |
"## Movielens 1M Temporal User split" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 196, | |
"id": "740b40a9-29fd-4873-bc6a-cf9aaef52fac", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"import sys\n", | |
"\n", | |
"sys.path.append(\"/home/td-user/rtrec\")" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 197, | |
"id": "1aacde5c-d39d-4ba3-8a29-315fbf67771c", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Using existing ratings.dat file.\n" | |
] | |
} | |
], | |
"source": [ | |
"from rtrec.experiments.datasets import load_movielens_1m\n", | |
"df = load_movielens_1m()\n", | |
"\n", | |
"#from rtrec.experiments.datasets import load_movielens_20m\n", | |
"#df = load_movielens_20m()\n", | |
"\n", | |
"#from rtrec.experiments.datasets import load_movielens_100k\n", | |
"#df = load_movielens_100k()" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 198, | |
"id": "a2eecf7c-851f-4156-8385-c8cc2d0305c5", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"df['user'] = df['user'].astype(\"category\")\n", | |
"df['item'] = df['item'].astype(\"category\")\n", | |
"df['user_id'] = df['user'].cat.codes\n", | |
"df['item_id'] = df['item'].cat.codes" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 199, | |
"id": "2e24aef4-3825-4153-9fe1-5936bfef13a7", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"iid2id = dict(enumerate(df['item'].cat.categories))" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 200, | |
"id": "c262cade-c806-4786-b621-e96ecd2383e8", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"/home/td-user/rtrec/rtrec/experiments/split.py:104: FutureWarning: The default of observed=False is deprecated and will be changed to True in a future version of pandas. Pass observed=False to retain current behavior or observed=True to adopt the future default and silence this warning.\n", | |
" for user, user_df in df.groupby('user'):\n" | |
] | |
} | |
], | |
"source": [ | |
"from rtrec.experiments.split import temporal_user_split, temporal_split, random_split\n", | |
"train_df, test_df = temporal_user_split(df, test_frac=0.2)\n", | |
"#train_df, test_df = random_split(df, test_frac=0.2)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 201, | |
"id": "08de34fe-a833-49eb-bad8-e71e915fcd3a", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"import scipy.sparse as sp\n", | |
"train_interactions = sp.csr_matrix((train_df['rating'].astype(float), (train_df['user_id'], train_df['item_id'])))" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 202, | |
"id": "f26aa7ac-2286-4014-8995-8c2402dac850", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"Fitting SLIMElastic: 100%|██████████████████| 3706/3706 [00:51<00:00, 72.16it/s]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"CPU times: user 51.1 s, sys: 299 ms, total: 51.4 s\n", | |
"Wall time: 51.4 s\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"\n" | |
] | |
}, | |
{ | |
"data": { | |
"text/plain": [ | |
"<__main__.SLIMElastic at 0xffff51cff4a0>" | |
] | |
}, | |
"execution_count": 202, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"%%time\n", | |
"\n", | |
"slim = SLIMElastic()\n", | |
"#slim = SLIMElastic({\"max_iter\": 100})\n", | |
"#slim = SLIMElastic({\"model_name\": \"cd\"})\n", | |
"#slim = SLIMElastic({\"model_name\": \"sgd\"})\n", | |
"slim.fit(train_interactions)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 203, | |
"id": "c2d51880-a0ae-41d7-8942-841c867279b2", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"recos = []\n", | |
"ground_truths = []\n", | |
"for row in test_df.groupby('user_id')['item'].apply(list).reset_index(name='ground_truth').itertuples():\n", | |
" recommended = slim.recommend(row.user_id, train_interactions, top_k=10, exclude_seen=True)\n", | |
" recos.append([iid2id[iid] for iid in recommended])\n", | |
" ground_truths.append(row.ground_truth)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 204, | |
"id": "1d4ba2cf-55c4-472e-9fe7-34cce1f6b373", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"data": { | |
"text/plain": [ | |
"{'precision': 0.13653973509934245,\n", | |
" 'recall': 0.07254307328372998,\n", | |
" 'f1': 0.07699715413884953,\n", | |
" 'ndcg': 0.1502815555406185,\n", | |
" 'hit_rate': 0.6360927152317881,\n", | |
" 'mrr': 0.2907404341427504,\n", | |
" 'map': 0.07394531840514003,\n", | |
" 'tp': 8247,\n", | |
" 'auc': 0.33803536607799917}" | |
] | |
}, | |
"execution_count": 204, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"from rtrec.utils.metrics import compute_scores\n", | |
"\n", | |
"compute_scores(zip(recos, ground_truths), recommend_size=10)" | |
] | |
}, | |
{ | |
"cell_type": "markdown", | |
"id": "536b20bb-23ee-486a-ac97-fd37f6f4331e", | |
"metadata": {}, | |
"source": [ | |
"### With NN feature selection to speed up training" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 208, | |
"id": "126e7f7a-4e3d-44f3-8159-fc9685827ac5", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"Fitting SLIMElastic: 100%|█████████████████| 3706/3706 [00:13<00:00, 275.48it/s]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"CPU times: user 13.2 s, sys: 311 ms, total: 13.5 s\n", | |
"Wall time: 13.5 s\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"\n" | |
] | |
}, | |
{ | |
"data": { | |
"text/plain": [ | |
"<__main__.SLIMElastic at 0xffff38ebef90>" | |
] | |
}, | |
"execution_count": 208, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"%%time\n", | |
"\n", | |
"slim = SLIMElastic({\"nn_feature_selection\": 50})\n", | |
"#slim = SLIMElastic({\"nn_feature_selection\": 30, \"max_iter\": 100})\n", | |
"slim.fit(train_interactions)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 209, | |
"id": "518d8eba-abf2-452f-bd07-da3ca85463de", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"recos = []\n", | |
"ground_truths = []\n", | |
"for row in test_df.groupby('user_id')['item'].apply(list).reset_index(name='ground_truth').itertuples():\n", | |
" recommended = slim.recommend(row.user_id, train_interactions, top_k=10, exclude_seen=True)\n", | |
" recos.append([iid2id[iid] for iid in recommended])\n", | |
" ground_truths.append(row.ground_truth)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 210, | |
"id": "1c17525d-42f9-42af-8344-de864e21ae89", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"data": { | |
"text/plain": [ | |
"{'precision': 0.13162251655629614,\n", | |
" 'recall': 0.0673229648143244,\n", | |
" 'f1': 0.07279097819160797,\n", | |
" 'ndcg': 0.14422455075026505,\n", | |
" 'hit_rate': 0.6352649006622516,\n", | |
" 'mrr': 0.28689963733837737,\n", | |
" 'map': 0.06884724159316662,\n", | |
" 'tp': 7950,\n", | |
" 'auc': 0.33836494796594224}" | |
] | |
}, | |
"execution_count": 210, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"from rtrec.utils.metrics import compute_scores\n", | |
"\n", | |
"compute_scores(zip(recos, ground_truths), recommend_size=10)" | |
] | |
}, | |
{ | |
"cell_type": "markdown", | |
"id": "bf390e02-8fda-446e-be56-0cd7597bbe7b", | |
"metadata": {}, | |
"source": [ | |
"### Test for partial fit" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 128, | |
"id": "11db8175-4cef-4f49-84f8-0b807b4a448f", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"test_df1, test_df2 = temporal_split(test_df, test_frac=0.5)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 129, | |
"id": "13f3ad47-180f-43ea-82d0-c2d213d131f0", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"Fitting SLIMElastic: 100%|█████████████████| 3706/3706 [00:11<00:00, 317.69it/s]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"CPU times: user 11.4 s, sys: 251 ms, total: 11.7 s\n", | |
"Wall time: 11.7 s\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"\n" | |
] | |
}, | |
{ | |
"data": { | |
"text/plain": [ | |
"<__main__.SLIMElastic at 0xffff7c322d20>" | |
] | |
}, | |
"execution_count": 129, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"%%time\n", | |
"\n", | |
"slim = SLIMElastic({\"nn_feature_selection\": 30})\n", | |
"slim.fit(train_interactions)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 130, | |
"id": "53b21898", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"100%|██████████████████████████████████████| 3563/3563 [00:12<00:00, 292.59it/s]\n" | |
] | |
}, | |
{ | |
"data": { | |
"text/plain": [ | |
"<__main__.SLIMElastic at 0xffff7c322d20>" | |
] | |
}, | |
"execution_count": 130, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"import scipy.sparse as sp\n", | |
"import pandas as pd\n", | |
"\n", | |
"past_df = pd.concat([train_df, test_df1])\n", | |
"past_interactions = sp.csr_matrix((past_df['rating'].astype(float), (past_df['user_id'], past_df['item_id'])))\n", | |
"\n", | |
"slim.partial_fit(past_interactions, test_df1.user_id.unique())" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 131, | |
"id": "235fc09a-9790-41e1-ab56-4a886c97850d", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"recos = []\n", | |
"ground_truths = []\n", | |
"for row in test_df2.groupby('user_id')['item'].apply(list).reset_index(name='ground_truth').itertuples():\n", | |
" recommended = slim.recommend(row.user_id, past_interactions, top_k=10, exclude_seen=True)\n", | |
" recos.append([iid2id[iid] for iid in recommended])\n", | |
" ground_truths.append(row.ground_truth)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 132, | |
"id": "698230df-35f9-434b-83dd-465f628a52dd", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"data": { | |
"text/plain": [ | |
"{'precision': 0.145448054266334,\n", | |
" 'recall': 0.06708294204397466,\n", | |
" 'f1': 0.07410286587963384,\n", | |
" 'ndcg': 0.15939018809141217,\n", | |
" 'hit_rate': 0.6583363084612638,\n", | |
" 'mrr': 0.31001824745130796,\n", | |
" 'map': 0.07853834303499396,\n", | |
" 'tp': 4074,\n", | |
" 'auc': 0.3599746547434874}" | |
] | |
}, | |
"execution_count": 132, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"from rtrec.utils.metrics import compute_scores\n", | |
"\n", | |
"compute_scores(zip(recos, ground_truths), recommend_size=10)" | |
] | |
}, | |
{ | |
"cell_type": "markdown", | |
"id": "d3c1b64a-439a-4b20-817b-e7fab99ed5d6", | |
"metadata": {}, | |
"source": [ | |
"# ImplicitFM for Implicit Feedbacks (WARP/BPR)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 17, | |
"id": "8cd296e2-9490-4167-8100-af071ec68513", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"import bisect\n", | |
"import numpy as np\n", | |
"from typing import List, Optional\n", | |
"from scipy.sparse import csr_matrix\n", | |
"from tqdm import tqdm\n", | |
"\n", | |
"class ImplicitFactorizationMachines:\n", | |
" def __init__(self,\n", | |
" num_factors: int = 10,\n", | |
" learning_rate: float = 0.05,\n", | |
" reg: float = 0.0001,\n", | |
" loss: str = 'warp',\n", | |
" random_state: Optional[int] = None,\n", | |
" epsilon: float = 1.0,\n", | |
" max_sampled: int = 10,\n", | |
" max_loss: float = 10.0):\n", | |
" \"\"\"\n", | |
" Factorization Machines for implicit feedback.\n", | |
"\n", | |
" Args:\n", | |
" num_factors (int): Number of latent factors.\n", | |
" learning_rate (float): Learning rate for updates.\n", | |
" reg (float): Regularization parameter.\n", | |
" loss (str): Loss function to use ('bpr' or 'warp').\n", | |
" random_state (Optional[int]): Random seed for reproducibility.\n", | |
" epsilon (float): Smoothing term for AdaGrad.\n", | |
" \"\"\"\n", | |
" self.num_factors = num_factors\n", | |
" self.learning_rate = learning_rate\n", | |
" self.reg = reg\n", | |
" self.loss = loss.lower()\n", | |
" assert self.loss in {'bpr', 'warp'}, \"Loss must be 'bpr' or 'warp'\"\n", | |
" self.random_state = random_state\n", | |
" self.rng = np.random.default_rng(random_state)\n", | |
" self.max_sampled = max_sampled\n", | |
" self.max_loss = max_loss\n", | |
"\n", | |
" self.user_factors = None\n", | |
" self.item_factors = None\n", | |
" # User and item biases\n", | |
" self.user_biases = None\n", | |
" self.item_biases = None\n", | |
"\n", | |
" # AdaGrad caches\n", | |
" self.adagrad_cache_user = None\n", | |
" self.adagrad_cache_item = None\n", | |
" self.adagrad_cache_user_bias = None\n", | |
" self.adagrad_cache_item_bias = None\n", | |
"\n", | |
" self.epsilon = epsilon\n", | |
"\n", | |
" def fit(self, interactions: csr_matrix, epochs: int = 10) -> None:\n", | |
" \"\"\"\n", | |
" Fits the model to the provided interaction data.\n", | |
"\n", | |
" Args:\n", | |
" interactions (csr_matrix): User-item interaction matrix (implicit feedback).\n", | |
" epochs (int): Number of training epochs.\n", | |
" \"\"\"\n", | |
" num_users, num_items = interactions.shape\n", | |
" if self.user_factors is None:\n", | |
" self.user_factors = self.rng.normal(0, 0.01, (num_users, self.num_factors))\n", | |
" if self.item_factors is None:\n", | |
" self.item_factors = self.rng.normal(0, 0.01, (num_items, self.num_factors))\n", | |
" if self.user_biases is None:\n", | |
" self.user_biases = np.zeros(num_users)\n", | |
" if self.item_biases is None:\n", | |
" self.item_biases = np.zeros(num_items)\n", | |
"\n", | |
" # Initialize AdaGrad caches\n", | |
" self.adagrad_cache_user = np.zeros_like(self.user_factors)\n", | |
" self.adagrad_cache_item = np.zeros_like(self.item_factors)\n", | |
" self.adagrad_cache_user_bias = np.zeros(num_users)\n", | |
" self.adagrad_cache_item_bias = np.zeros(num_items)\n", | |
"\n", | |
" with tqdm(total = num_users * epochs) as pbar:\n", | |
" for epoch in range(epochs):\n", | |
" for user in range(num_users):\n", | |
" user_interactions = interactions[user].indices\n", | |
" if len(user_interactions) == 0:\n", | |
" pbar.update(1)\n", | |
" continue\n", | |
"\n", | |
" for pos_item in user_interactions:\n", | |
" if self.loss == 'bpr':\n", | |
" self._update_bpr(interactions, user, pos_item)\n", | |
" elif self.loss == 'warp':\n", | |
" self._update_warp(interactions, user, pos_item)\n", | |
" pbar.update(1)\n", | |
"\n", | |
" def partial_fit(self, interactions: csr_matrix, users: np.ndarray, items: np.ndarray) -> None:\n", | |
" \"\"\"\n", | |
" Incrementally fits the model to new interaction data, considering new users and items.\n", | |
"\n", | |
" Args:\n", | |
" interactions (csr_matrix): User-item interaction matrix (implicit feedback).\n", | |
" users (np.ndarray): Array of user indices to update.\n", | |
" items (np.ndarray): Array of item indices to update.\n", | |
" \"\"\"\n", | |
" num_users, num_items = interactions.shape\n", | |
"\n", | |
" for user, pos_item in zip(users, items):\n", | |
" # Ensure user and item factors are initialized for new users/items\n", | |
" while user >= self.user_factors.shape[0]:\n", | |
" new_user_factors = self.rng.normal(0, 0.01, (1, self.num_factors))\n", | |
" self.user_factors = np.vstack((self.user_factors, new_user_factors))\n", | |
" self.adagrad_cache_user = np.vstack((self.adagrad_cache_user, np.zeros((1, self.num_factors))))\n", | |
"\n", | |
" while pos_item >= self.item_factors.shape[0]:\n", | |
" new_item_factors = self.rng.normal(0, 0.01, (1, self.num_factors))\n", | |
" self.item_factors = np.vstack((self.item_factors, new_item_factors))\n", | |
" self.adagrad_cache_item = np.vstack((self.adagrad_cache_item, np.zeros((1, self.num_factors))))\n", | |
"\n", | |
" if self.loss == 'bpr': \n", | |
" self._update_bpr(interactions, user, pos_item)\n", | |
" elif self.loss == 'warp':\n", | |
" self._update_warp(interactions, user, pos_item)\n", | |
"\n", | |
" def _update_bpr(self, interactions: csr_matrix, user: int, pos_item: int) -> None:\n", | |
" def _sample_negative(interactions: csr_matrix, user: int) -> int:\n", | |
" \"\"\"Samples a random negative item for a given user using binary search and random number generation.\"\"\"\n", | |
" num_items = interactions.shape[1]\n", | |
" positives = interactions[user].indices # Already sorted as per the csr_matrix format\n", | |
" while True:\n", | |
" # Generate a random item index\n", | |
" item = self.rng.integers(0, num_items)\n", | |
" # Check if the item is not in the positives list using binary search\n", | |
" idx = bisect.bisect_left(positives, item)\n", | |
" if idx == len(positives) or positives[idx] != item:\n", | |
" return item\n", | |
"\n", | |
" \"\"\"Performs an update using the BPR loss with AdaGrad.\"\"\"\n", | |
" neg_item = _sample_negative(interactions, user)\n", | |
"\n", | |
" user_factors = self.user_factors[user]\n", | |
" pos_factors = self.item_factors[pos_item]\n", | |
" neg_factors = self.item_factors[neg_item]\n", | |
" user_bias = self.user_biases[user]\n", | |
" pos_bias = self.item_biases[pos_item]\n", | |
" neg_bias = self.item_biases[neg_item]\n", | |
"\n", | |
" x_uij = (user_factors @ (pos_factors - neg_factors)) + (pos_bias - neg_bias) + user_bias\n", | |
" dloss = 1 / (1 + np.exp(-x_uij)) # Sigmoid\n", | |
"\n", | |
" # Compute gradients\n", | |
" grad_user = (pos_factors - neg_factors) * dloss + self.reg * user_factors\n", | |
" grad_pos = user_factors * dloss + self.reg * pos_factors\n", | |
" grad_neg = -user_factors * dloss + self.reg * neg_factors\n", | |
" grad_user_bias = dloss + self.reg * user_bias\n", | |
" grad_pos_bias = dloss + self.reg * pos_bias\n", | |
" grad_neg_bias = -dloss + self.reg * neg_bias\n", | |
" \n", | |
" # Update AdaGrad cache for user and items\n", | |
" self.adagrad_cache_user[user] += grad_user**2\n", | |
" self.adagrad_cache_item[pos_item] += grad_pos**2\n", | |
" self.adagrad_cache_item[neg_item] += grad_neg**2\n", | |
" self.adagrad_cache_user_bias[user] += grad_user_bias**2\n", | |
" self.adagrad_cache_item_bias[pos_item] += grad_pos_bias**2\n", | |
" self.adagrad_cache_item_bias[neg_item] += grad_neg_bias**2\n", | |
"\n", | |
" # Calculate learning rates\n", | |
" user_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_user[user] + self.epsilon))\n", | |
" pos_item_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_item[pos_item] + self.epsilon))\n", | |
" neg_item_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_item[neg_item] + self.epsilon))\n", | |
" user_bias_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_user_bias[user]) + self.epsilon)\n", | |
" pos_bias_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_item_bias[pos_item]) + self.epsilon)\n", | |
" neg_bias_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_item_bias[neg_item]) + self.epsilon)\n", | |
"\n", | |
" # Apply updates\n", | |
" self.user_factors[user] += user_lr * grad_user\n", | |
" self.item_factors[pos_item] += pos_item_lr * grad_pos\n", | |
" self.item_factors[neg_item] += neg_item_lr * grad_neg\n", | |
" self.user_biases[user] += user_bias_lr * grad_user_bias\n", | |
" self.item_biases[pos_item] += pos_bias_lr * grad_pos_bias\n", | |
" self.item_biases[neg_item] += neg_bias_lr * grad_neg_bias\n", | |
"\n", | |
" def _update_warp(self, interactions: csr_matrix, user: int, pos_item: int) -> None:\n", | |
" \"\"\"Performs an update using the WARP (Weighted Approximate-Rank Pairwise) loss with AdaGrad.\"\"\"\n", | |
" num_items = interactions.shape[1] # Total number of items\n", | |
" pos_item_vector = self.item_factors[pos_item] # Vector for the positive item\n", | |
" user_vector = self.user_factors[user] # Vector for the user\n", | |
"\n", | |
" # Compute the prediction for the positive item\n", | |
" positive_prediction = user_vector @ pos_item_vector + self.user_biases[user] + self.item_biases[pos_item]\n", | |
"\n", | |
" negative_items = np.setdiff1d(np.arange(num_items), interactions[user].indices)\n", | |
" self.rng.shuffle(negative_items)\n", | |
" \n", | |
" # Initialize rank and loss weight\n", | |
" sampled = 0\n", | |
" loss = 0.0\n", | |
"\n", | |
" for neg_item in negative_items[:self.max_sampled]:\n", | |
" sampled += 1\n", | |
"\n", | |
" # Compute the negative item vector\n", | |
" neg_item_vector = self.item_factors[neg_item]\n", | |
"\n", | |
" # Compute the prediction for the negative item\n", | |
" negative_prediction = user_vector @ neg_item_vector + self.user_biases[user] + self.item_biases[neg_item]\n", | |
" \n", | |
" # Negative items are sampled until a \"violator\" is found.\n", | |
" # A violator is a negative item where the positive item's score is not sufficiently higher\n", | |
" if positive_prediction - negative_prediction < 1:\n", | |
" # Approx warp loss used in LightFM\n", | |
" # see https://building-babylon.net/2016/03/18/warp-loss-for-implicit-feedback-recommendation/ for WARP loss derivation\n", | |
" # loss = np.log(num_items - 1 // sampled) # LightFM's WARP loss\n", | |
" # Non approximated WARP loss is slightly better in my experiments \n", | |
" loss = sum(1.0 / k for k in range(1, sampled + 1)) # WARP loss\n", | |
" self._warp_update(loss, user, pos_item, neg_item, user_vector, pos_item_vector, neg_item_vector)\n", | |
" break\n", | |
"\n", | |
" def _warp_update(self, loss: float, user: int, pos_item: int, neg_item: int, user_vector: np.ndarray, pos_item_vector: np.ndarray, neg_item_vector: np.ndarray) -> None:\n", | |
" \"\"\"Performs a WARP update with AdaGrad.\"\"\"\n", | |
" # Compute the gradient\n", | |
" grad_user = loss * (pos_item_vector - neg_item_vector) + self.reg * user_vector\n", | |
" grad_pos = loss * user_vector + self.reg * pos_item_vector\n", | |
" grad_neg = -loss * user_vector + self.reg * neg_item_vector\n", | |
" grad_user_bias = loss + self.reg * self.user_biases[user]\n", | |
" grad_pos_item_bias = loss + self.reg * self.item_biases[pos_item]\n", | |
" grad_neg_item_bias = -loss + self.reg * self.item_biases[neg_item]\n", | |
"\n", | |
" # Update the AdaGrad cache\n", | |
" self.adagrad_cache_user[user] += grad_user**2\n", | |
" self.adagrad_cache_item[pos_item] += grad_pos**2\n", | |
" self.adagrad_cache_item[neg_item] += grad_neg**2\n", | |
" self.adagrad_cache_user_bias[user] += grad_user_bias**2\n", | |
" self.adagrad_cache_item_bias[pos_item] += grad_pos_item_bias**2\n", | |
" self.adagrad_cache_item_bias[neg_item] += grad_neg_item_bias**2\n", | |
"\n", | |
" # Compute the learning rates\n", | |
" user_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_user[user]) + self.epsilon)\n", | |
" pos_item_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_item[pos_item]) + self.epsilon)\n", | |
" neg_item_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_item[neg_item]) + self.epsilon)\n", | |
" user_bias_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_user_bias[user]) + self.epsilon)\n", | |
" pos_item_bias_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_item_bias[pos_item]) + self.epsilon)\n", | |
" neg_item_bias_lr = self.learning_rate / (np.sqrt(self.adagrad_cache_item_bias[neg_item]) + self.epsilon)\n", | |
"\n", | |
" # Update the user and item factors\n", | |
" self.user_factors[user] += user_lr * (grad_user + self.reg * user_vector)\n", | |
" self.item_factors[pos_item] += pos_item_lr * (grad_pos + self.reg * pos_item_vector)\n", | |
" self.item_factors[neg_item] += neg_item_lr * (grad_neg + self.reg * neg_item_vector)\n", | |
" self.user_biases[user] += user_bias_lr * grad_user_bias\n", | |
" self.item_biases[pos_item] += pos_item_bias_lr * grad_pos_item_bias\n", | |
" self.item_biases[neg_item] += neg_item_bias_lr * grad_neg_item_bias\n", | |
"\n", | |
" def predict(self, user: int, items: np.ndarray) -> np.ndarray:\n", | |
" \"\"\"\n", | |
" Predicts the scores for a user and a set of items.\n", | |
"\n", | |
" Args:\n", | |
" user (int): User index.\n", | |
" items (np.ndarray): Array of item indices.\n", | |
"\n", | |
" Returns:\n", | |
" np.ndarray: Predicted scores for the items.\n", | |
" \"\"\"\n", | |
" user_factors = self.user_factors[user]\n", | |
" item_factors = self.item_factors[items]\n", | |
" user_bias = self.user_biases[user]\n", | |
" item_bias = self.item_biases[items]\n", | |
" return user_factors @ item_factors.T + user_bias + item_bias\n", | |
"\n", | |
" def predict_all(self, user: int) -> np.ndarray:\n", | |
" \"\"\"\n", | |
" Predicts the scores for all items for a given user.\n", | |
"\n", | |
" Args:\n", | |
" user (int): User index.\n", | |
"\n", | |
" Returns:\n", | |
" np.ndarray: Predicted scores for all items.\n", | |
" \"\"\"\n", | |
" return self.user_factors[user] @ self.item_factors.T + self.user_biases[user] + self.item_biases\n", | |
"\n", | |
" def recommend(self, user_id: int, interactions: csr_matrix, top_k: int = 10, exclude_seen: bool = True) -> List[int]:\n", | |
" \"\"\"\n", | |
" Recommend top-K items for a given user.\n", | |
"\n", | |
" Args:\n", | |
" user_id (int): ID of the user (row index in interactions).\n", | |
" interactions (csr_matrix): User-item interaction matrix.\n", | |
" top_k (int): Number of recommendations to return.\n", | |
" exclude_seen (bool): Whether to exclude items the user has already interacted with.\n", | |
"\n", | |
" Returns:\n", | |
" List of recommended item indices.\n", | |
" \"\"\"\n", | |
" if exclude_seen:\n", | |
" num_items = interactions.shape[1]\n", | |
" seen_items = interactions[user_id].indices\n", | |
" candidate_items = np.setdiff1d(np.arange(num_items), seen_items)\n", | |
" scores = self.predict(user_id, candidate_items)\n", | |
" # Get the top-K items by sorting the predicted scores in descending order\n", | |
" top_items = candidate_items[np.argsort(-scores)][:top_k]\n", | |
" else:\n", | |
" scores = self.predict_all(user_id)\n", | |
" top_items = np.argsort(-scores)[:top_k]\n", | |
"\n", | |
" return top_items\n" | |
] | |
}, | |
{ | |
"cell_type": "markdown", | |
"id": "04506f01-b18c-45a1-99b3-b211dfc07ab8", | |
"metadata": {}, | |
"source": [ | |
"### WARP loss" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 20, | |
"id": "9d4055f8-645f-47d3-bf83-521da2934db1", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"implicit_fm = ImplicitFactorizationMachines(num_factors=10, loss='warp')" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 21, | |
"id": "16099b8e-4380-4e6a-84fd-51530e4cc2ee", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"100%|█████████████████████████████████████| 60400/60400 [18:21<00:00, 54.83it/s]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"CPU times: user 18min 21s, sys: 1.05 s, total: 18min 22s\n", | |
"Wall time: 18min 21s\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"\n" | |
] | |
} | |
], | |
"source": [ | |
"%%time\n", | |
"\n", | |
"implicit_fm.fit(train_interactions, epochs=10)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 22, | |
"id": "c1762da8-723b-4d0c-879c-5fe67e04438f", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"recos = []\n", | |
"ground_truths = []\n", | |
"for row in test_df.groupby('user_id')['item'].apply(list).reset_index(name='ground_truth').itertuples():\n", | |
" recommended = implicit_fm.recommend(row.user_id, train_interactions, top_k=10, exclude_seen=True)\n", | |
" recos.append([iid2id[iid] for iid in recommended])\n", | |
" ground_truths.append(row.ground_truth)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 23, | |
"id": "85387b91-7e47-4d3e-8e71-aa14a8ba9d24", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"data": { | |
"text/plain": [ | |
"{'precision': 0.12519867549669247,\n", | |
" 'recall': 0.05452121327320763,\n", | |
" 'f1': 0.0627046655670288,\n", | |
" 'ndcg': 0.13758538152201555,\n", | |
" 'hit_rate': 0.5528145695364238,\n", | |
" 'mrr': 0.2730837406706596,\n", | |
" 'map': 0.06979350033642343,\n", | |
" 'tp': 7562,\n", | |
" 'auc': 0.30544857694733635}" | |
] | |
}, | |
"execution_count": 23, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"from rtrec.utils.metrics import compute_scores\n", | |
"\n", | |
"compute_scores(zip(recos, ground_truths), recommend_size=10)" | |
] | |
}, | |
{ | |
"cell_type": "markdown", | |
"id": "ee987370-b1f2-47cd-acc0-5d2a6306a03d", | |
"metadata": {}, | |
"source": [ | |
"### BPR loss" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 24, | |
"id": "6141cf36-33b4-4344-905a-b57f9a9134a1", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
" 57%|████████████████████▍ | 34360/60400 [03:30<02:31, 172.39it/s]/tmp/ipykernel_2780/1650988818.py:143: RuntimeWarning: overflow encountered in exp\n", | |
" dloss = 1 / (1 + np.exp(-x_uij)) # Sigmoid\n", | |
"100%|████████████████████████████████████| 60400/60400 [06:08<00:00, 163.95it/s]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"CPU times: user 6min 15s, sys: 24 s, total: 6min 39s\n", | |
"Wall time: 6min 8s\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"\n" | |
] | |
} | |
], | |
"source": [ | |
"%%time\n", | |
"\n", | |
"implicit_fm = ImplicitFactorizationMachines(num_factors=10, loss='bpr')\n", | |
"implicit_fm.fit(train_interactions, epochs=10)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 25, | |
"id": "c0fe0133-a2c7-4ede-9b15-40b4ab21e6f3", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"recos = []\n", | |
"ground_truths = []\n", | |
"for row in test_df.groupby('user_id')['item'].apply(list).reset_index(name='ground_truth').itertuples():\n", | |
" recommended = implicit_fm.recommend(row.user_id, train_interactions, top_k=10, exclude_seen=True)\n", | |
" recos.append([iid2id[iid] for iid in recommended])\n", | |
" ground_truths.append(row.ground_truth)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 26, | |
"id": "1aa2af87-33e9-4437-8c17-8c326de0fdd4", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"data": { | |
"text/plain": [ | |
"{'precision': 0.09071192052980298,\n", | |
" 'recall': 0.03468395778253375,\n", | |
" 'f1': 0.041657320292548816,\n", | |
" 'ndcg': 0.09786905305986224,\n", | |
" 'hit_rate': 0.43211920529801323,\n", | |
" 'mrr': 0.19890472248502067,\n", | |
" 'map': 0.0473134919852784,\n", | |
" 'tp': 5479,\n", | |
" 'auc': 0.2315746741301384}" | |
] | |
}, | |
"execution_count": 26, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"from rtrec.utils.metrics import compute_scores\n", | |
"\n", | |
"compute_scores(zip(recos, ground_truths), recommend_size=10)" | |
] | |
}, | |
{ | |
"cell_type": "markdown", | |
"id": "4a98912a-9f82-4965-8ccc-fe7d036bb3a8", | |
"metadata": {}, | |
"source": [ | |
"# Factorization Machines with Explicit Feedbacks" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 27, | |
"id": "9ea5a857-8490-4ef3-aad2-8a7c1d93455e", | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"from rtrec.recommender import Recommender\n", | |
"from rtrec.models import FactorizationMachines\n", | |
"\n", | |
"#model = FactorizationMachines(n_factors=10, alpha=0.001, decay_in_days=None)\n", | |
"explicit_fm = FactorizationMachines(n_factors=10, alpha=0.001, decay_in_days=None)\n", | |
"recommender = Recommender(explicit_fm)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 28, | |
"id": "73500c70-ac57-47fe-b919-5adfcf51007a", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
" 0%| | 0/10 [00:00<?, ?it/s]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Starting epoch 1/10\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
" 10%|████▍ | 1/10 [00:14<02:07, 14.13s/it]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Epoch 1 completed in 14.04 seconds\n", | |
"Throughput: 56822.53 samples/sec\n", | |
"Empirical loss after epoch 1: 0.9040485061051541\n", | |
"Starting epoch 2/10\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
" 20%|████████▊ | 2/10 [00:29<01:57, 14.64s/it]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Epoch 2 completed in 14.93 seconds\n", | |
"Throughput: 53422.34 samples/sec\n", | |
"Empirical loss after epoch 2: 0.8848850820186098\n", | |
"Starting epoch 3/10\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
" 30%|█████████████▏ | 3/10 [00:43<01:43, 14.74s/it]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Epoch 3 completed in 14.84 seconds\n", | |
"Throughput: 53743.98 samples/sec\n", | |
"Empirical loss after epoch 3: 0.8709187531438037\n", | |
"Starting epoch 4/10\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
" 40%|█████████████████▌ | 4/10 [00:58<01:28, 14.83s/it]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Epoch 4 completed in 14.93 seconds\n", | |
"Throughput: 53426.64 samples/sec\n", | |
"Empirical loss after epoch 4: 0.8597163788716761\n", | |
"Starting epoch 5/10\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
" 50%|██████████████████████ | 5/10 [01:14<01:14, 14.95s/it]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Epoch 5 completed in 15.16 seconds\n", | |
"Throughput: 52638.86 samples/sec\n", | |
"Empirical loss after epoch 5: 0.850401642913619\n", | |
"Starting epoch 6/10\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
" 60%|██████████████████████████▍ | 6/10 [01:29<00:59, 14.99s/it]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Epoch 6 completed in 15.06 seconds\n", | |
"Throughput: 52971.76 samples/sec\n", | |
"Empirical loss after epoch 6: 0.8424714351463535\n", | |
"Starting epoch 7/10\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
" 70%|██████████████████████████████▊ | 7/10 [01:44<00:45, 15.00s/it]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Epoch 7 completed in 15.02 seconds\n", | |
"Throughput: 53127.80 samples/sec\n", | |
"Empirical loss after epoch 7: 0.8356077018215934\n", | |
"Starting epoch 8/10\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
" 80%|███████████████████████████████████▏ | 8/10 [01:59<00:30, 15.04s/it]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Epoch 8 completed in 15.11 seconds\n", | |
"Throughput: 52795.26 samples/sec\n", | |
"Empirical loss after epoch 8: 0.8295762436695558\n", | |
"Starting epoch 9/10\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
" 90%|███████████████████████████████████████▌ | 9/10 [02:14<00:15, 15.04s/it]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Epoch 9 completed in 15.02 seconds\n", | |
"Throughput: 53124.21 samples/sec\n", | |
"Empirical loss after epoch 9: 0.8242226909950443\n", | |
"Starting epoch 10/10\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"100%|███████████████████████████████████████████| 10/10 [02:29<00:00, 14.92s/it]" | |
] | |
}, | |
{ | |
"name": "stdout", | |
"output_type": "stream", | |
"text": [ | |
"Epoch 10 completed in 14.76 seconds\n", | |
"Throughput: 54039.27 samples/sec\n", | |
"Empirical loss after epoch 10: 0.8194283203577859\n", | |
"CPU times: user 2min 29s, sys: 162 ms, total: 2min 29s\n", | |
"Wall time: 2min 29s\n" | |
] | |
}, | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"\n" | |
] | |
} | |
], | |
"source": [ | |
"%%time\n", | |
"#%pdb on\n", | |
"\n", | |
"recommender.fit(train_df, epochs=10, bulk_identify=False)" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 37, | |
"id": "3ce4c580-c38c-4ca8-b8c2-8e230a8e5bd9", | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"name": "stderr", | |
"output_type": "stream", | |
"text": [ | |
"100%|███████████████████████████████████████████| 61/61 [01:55<00:00, 1.90s/it]\n" | |
] | |
}, | |
{ | |
"data": { | |
"text/plain": [ | |
"{'precision': 0.07622516556291505,\n", | |
" 'recall': 0.028854421300375105,\n", | |
" 'f1': 0.03458337370708451,\n", | |
" 'ndcg': 0.08065297737220792,\n", | |
" 'hit_rate': 0.37649006622516556,\n", | |
" 'mrr': 0.1610233364869128,\n", | |
" 'map': 0.038115181616551544,\n", | |
" 'tp': 4604,\n", | |
" 'auc': 0.19739979501734475}" | |
] | |
}, | |
"execution_count": 37, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"recommender.evaluate(test_df, recommend_size=10, filter_interacted=True)" | |
] | |
} | |
], | |
"metadata": { | |
"kernelspec": { | |
"display_name": "Python 3 (ipykernel)", | |
"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.12.7" | |
} | |
}, | |
"nbformat": 4, | |
"nbformat_minor": 5 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment