├── .github └── workflows │ ├── python-publish.yml │ └── python-test.yml ├── .gitignore ├── LICENSE ├── PyTorchCML ├── __init__.py ├── adaptors │ ├── BaseAdaptor.py │ ├── MLPAdaptor.py │ └── __init__.py ├── evaluators │ ├── BaseEvaluator.py │ ├── UserwiseEvaluator.py │ └── __init__.py ├── losses │ ├── BaseLoss.py │ ├── LogitPairwiseLoss.py │ ├── MSEPairwiseLoss.py │ ├── MinTripletLoss.py │ ├── RelevancePairwiseLoss.py │ ├── SumTripletLoss.py │ └── __init__.py ├── models │ ├── BaseEmbeddingModel.py │ ├── CollaborativeMetricLearning.py │ ├── MatrixFactorization.py │ └── __init__.py ├── regularizers │ ├── BaseRegularizer.py │ ├── GlobalOrthogonalRegularizer.py │ ├── L2Regularizer.py │ └── __init__.py ├── samplers │ ├── BaseSampler.py │ ├── TwoStageSampler.py │ └── __init__.py └── trainers │ ├── BaseTrainer.py │ └── __init__.py ├── README.md ├── README_ja.md ├── examples └── notebooks │ ├── movielens_cml.ipynb │ └── movielens_mf.ipynb ├── images ├── diagram.png └── icon.png ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── adaptors ├── __init__.py └── test_MLPAdaptor.py ├── evaluators ├── __init__.py └── test_UserwiseEvaluator.py ├── losses ├── __init__.py ├── test_LogitPairwiseLoss.py ├── test_MSEPairwiseLoss.py ├── test_MinTripletLoss.py ├── test_RelevancePairwiseLoss.py └── test_SumTripletLoss.py ├── models ├── __init__.py ├── test_CollaborativeMetricLearning.py └── test_MatrixFactorization.py ├── regularizers ├── __init__.py ├── test_GlobalOrthogonalRegularizer.py └── test_L2Regularizer.py ├── samplers ├── __init__.py ├── test_BaseSampler.py └── test_TwoStageSampler.py └── trainers ├── __init__.py └── test_BaseTrainer.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install poetry poetry-dynamic-versioning twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | poetry publish --build --username $TWINE_USERNAME --password $TWINE_PASSWORD -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: [3.7, 3.8] 14 | 15 | if: github.repository == 'hand10ryo/PytorchCML' 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Setup Python${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install poetry 30 | poetry install 31 | poetry run flake8 --ignore=E501 PyTorchCML tests 32 | poetry run python -m unittest discover -b 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | .idea 106 | data 107 | results 108 | results_notebook 109 | logs 110 | .vscode 111 | experiments -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ryo Matsui 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PyTorchCML/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hand10ryo/PyTorchCML/59e2808d42557d69a167a1864a07872c6ad89ddc/PyTorchCML/__init__.py -------------------------------------------------------------------------------- /PyTorchCML/adaptors/BaseAdaptor.py: -------------------------------------------------------------------------------- 1 | from torch import nn 2 | 3 | 4 | class BaseAdaptor(nn.Module): 5 | """Astract class of module for domain adaptation.""" 6 | 7 | def __init__(self, weight): 8 | """Set some parameters 9 | 10 | Args: 11 | weight (float, optional): Loss weights for domain adaptation. Defaults to 1e-3. 12 | """ 13 | super().__init__() 14 | self.weight = weight 15 | 16 | def forward(self, indices, embeddings): 17 | """Method to calculate loss for domain adaptation. 18 | 19 | Args: 20 | indices (torch.Tensor): Indices of users or items. size = (n_user, n_sample) 21 | embeddings (torch.Tensor): The embeddings corresponding to indices. size = (n_user, n_sample, n_dim) 22 | 23 | Raises: 24 | NotImplementedError: [description] 25 | """ 26 | raise NotImplementedError 27 | -------------------------------------------------------------------------------- /PyTorchCML/adaptors/MLPAdaptor.py: -------------------------------------------------------------------------------- 1 | import torch 2 | 3 | from torch import nn 4 | 5 | from .BaseAdaptor import BaseAdaptor 6 | 7 | 8 | class MLPAdaptor(BaseAdaptor): 9 | """Class of module for domain adaptation with MLP.""" 10 | 11 | def __init__( 12 | self, 13 | features: torch.Tensor, 14 | n_dim: int = 20, 15 | n_hidden: list = [100], 16 | weight: float = 1e-3, 17 | ): 18 | """Set MLP model for domain adaptation. 19 | 20 | Args: 21 | features (torch.Tensor): A feature of users or items. size = (n_user, n_feature) 22 | n_dim (int, optional): A number of dimention of embeddings. Defaults to 20. 23 | n_hidden (list, optional): A list of numbers of neuron for each hidden layers. Defaults to [100]. 24 | weight (float, optional): Loss weights for domain adaptation. Defaults to 1e-3. 25 | """ 26 | super().__init__(weight) 27 | self.features_embeddings = nn.Embedding.from_pretrained(features) 28 | self.features_embeddings.weight.requires_grad = False 29 | 30 | self.n_input = features.shape[1] 31 | self.n_hidden = n_hidden 32 | self.n_output = n_dim 33 | 34 | projection_layers = [nn.Linear(self.n_input, self.n_hidden[0]), nn.ReLU()] 35 | for i in range(len(self.n_hidden) - 1): 36 | layer = [nn.Linear(self.n_hidden[i], self.n_hidden[i + 1]), nn.ReLU()] 37 | projection_layers += layer 38 | projection_layers += [nn.Linear(self.n_hidden[-1], self.n_output)] 39 | 40 | self.projector = nn.Sequential(*projection_layers) 41 | 42 | def forward(self, indices: torch.Tensor, embeddings: torch.Tensor): 43 | """Method to calculate loss for domain adaptation. 44 | 45 | Args: 46 | indices (torch.Tensor): Indices of users or items. size = (n_user, n_sample) 47 | embeddings (torch.Tensor): The embeddings corresponding to indices. size = (n_user, n_sample, n_dim) 48 | 49 | Returns: 50 | [torch.Tensor]: loss for domain adaptation. dim = 0. 51 | """ 52 | features = self.features_embeddings(indices) 53 | projection = self.projector(features) 54 | dist = torch.sqrt(torch.pow(projection - embeddings, 2).sum(axis=2)) 55 | return self.weight * dist.sum() 56 | -------------------------------------------------------------------------------- /PyTorchCML/adaptors/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .BaseAdaptor import BaseAdaptor 3 | from .MLPAdaptor import MLPAdaptor 4 | -------------------------------------------------------------------------------- /PyTorchCML/evaluators/BaseEvaluator.py: -------------------------------------------------------------------------------- 1 | 2 | import pandas as pd 3 | import torch 4 | 5 | from ..models import BaseEmbeddingModel 6 | 7 | 8 | class BaseEvaluator: 9 | """ Class of abstract evaluator for trainer 10 | """ 11 | 12 | def __init__(self, test_set: torch.Tensor): 13 | """ set test data. 14 | 15 | Args: 16 | test_set (torch.Tensor): tensor of shape (n_pairs, 3) which column is [user_id, item_id, rating] 17 | """ 18 | self.test_set = test_set 19 | 20 | def score(self, model: BaseEmbeddingModel, verbose=True) -> pd.DataFrame: 21 | 22 | raise NotImplementedError() 23 | -------------------------------------------------------------------------------- /PyTorchCML/evaluators/UserwiseEvaluator.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | import torch 4 | from sklearn.metrics import average_precision_score, ndcg_score, recall_score 5 | from tqdm import tqdm 6 | 7 | from ..models import BaseEmbeddingModel 8 | from .BaseEvaluator import BaseEvaluator 9 | 10 | 11 | class UserwiseEvaluator(BaseEvaluator): 12 | """Class of evaluator computing metrics for each user and calcurating average.""" 13 | 14 | def __init__( 15 | self, test_set: torch.Tensor, score_function_dict: dict, ks: list = [5] 16 | ): 17 | """Set test data and metrics. 18 | 19 | Args: 20 | test_set (torch.Tensor): test data which column is [user_id, item_id, rating]. 21 | score_function_dict (dict): dictionary whose keys are metrics name and values are user-wise function. 22 | ks (int, optional): A list of @k. Defaults to [5]. 23 | 24 | for example, score_function_dict is 25 | 26 | score_function_dict = { 27 | "nDCG" : evaluators.ndcg, 28 | "MAP" : evaluators.average_precision, 29 | "Recall": evaluators.recall 30 | } 31 | 32 | arguments of each functions must be 33 | y_test_user (np.ndarray): grand truth for the user 34 | y_hat_user (np.ndarray) : prediction of relevance 35 | k : a number of top item considered. 36 | """ 37 | super().__init__(test_set) 38 | 39 | self.score_function_dict = score_function_dict 40 | self.ks = ks 41 | 42 | self.metrics_names = [ 43 | f"{name}@{k}" for k in ks for name in score_function_dict.keys() 44 | ] 45 | 46 | def compute_score( 47 | self, y_test_user: np.ndarray, y_hat_user: np.ndarray 48 | ) -> pd.DataFrame: 49 | """Method of computing score. 50 | This method make a row of DataFrame which has scores for each metrics and k for the user. 51 | 52 | Args: 53 | y_test_user (np.ndarray): [description] 54 | y_hat_user (np.ndarray): [description] 55 | 56 | Returns: 57 | (pd.DataFrame): a row of DataFrame which has scores for each metrics and k for the user. 58 | """ 59 | 60 | if y_test_user.sum() == 0: 61 | return pd.DataFrame({name: [0] for name in self.metrics_names}) 62 | 63 | else: 64 | df_eval_sub = pd.DataFrame( 65 | { 66 | f"{name}@{k}": [metric(y_test_user, y_hat_user, k)] 67 | for k in self.ks 68 | for name, metric in self.score_function_dict.items() 69 | } 70 | ) 71 | 72 | return df_eval_sub 73 | 74 | def eval_user(self, model: BaseEmbeddingModel, uid: int) -> pd.DataFrame: 75 | """Method of evaluating for given user. 76 | 77 | Args: 78 | model (BaseEmbeddingModel): model which have user and item embeddings. 79 | uid (int): user id 80 | 81 | Returns: 82 | (pd.DataFrame): a row of DataFrame which has scores for each metrics and k for the user. 83 | """ 84 | user_indices = self.test_set[:, 0] == uid 85 | test_set_pair = self.test_set[user_indices, :2] 86 | 87 | y_hat_user = model.predict(test_set_pair).to("cpu").detach().numpy() 88 | y_test_user = self.test_set[user_indices, 2].to("cpu").detach().numpy() 89 | 90 | return self.compute_score(y_test_user, y_hat_user) 91 | 92 | def score( 93 | self, model: BaseEmbeddingModel, reduction="mean", verbose=True 94 | ) -> pd.DataFrame: 95 | """Method of calculating average score for all users. 96 | 97 | Args: 98 | model (BaseEmbeddingModel): model which have user and item embeddings. 99 | reduction (str, optional): reduction method. Defaults to "mean". 100 | verbose (bool, optional): displaying progress bar or not during evaluating. Defaults to True. 101 | 102 | Returns: 103 | pd.DataFrame: a row of DataFrame which has average scores 104 | """ 105 | 106 | users = torch.unique(self.test_set[:, 0]) 107 | df_eval = pd.DataFrame({name: [] for name in self.metrics_names}) 108 | 109 | if verbose: 110 | for uid in tqdm(users): 111 | df_eval_sub = self.eval_user(model, uid) 112 | df_eval = pd.concat([df_eval, df_eval_sub]) 113 | else: 114 | for uid in users: 115 | df_eval_sub = self.eval_user(model, uid) 116 | df_eval = pd.concat([df_eval, df_eval_sub]) 117 | 118 | if reduction == "mean": 119 | score = pd.DataFrame(df_eval.mean(axis=0)).T 120 | 121 | else: 122 | score = df_eval.copy() 123 | 124 | return score 125 | 126 | 127 | def ndcg(y_test_user: np.ndarray, y_hat_user: np.ndarray, k: int) -> float: 128 | """Function for user-wise evaluator calculating ndcg @ k 129 | 130 | Args: 131 | y_test_user (np.ndarray): grand truth for the user 132 | y_hat_user (np.ndarray): prediction of relevance 133 | k (int): a number of top item considered. 134 | 135 | Returns: 136 | (float): ndcg score 137 | """ 138 | y_test_user = y_test_user.reshape(1, -1) 139 | y_hat_user = y_hat_user.reshape(1, -1) 140 | return ndcg_score(y_test_user, y_hat_user, k=k) 141 | 142 | 143 | def average_precision(y_test_user: np.ndarray, y_hat_user: np.ndarray, k: int): 144 | """Function for user-wise evaluator calculating average precision (MAP) @ k 145 | 146 | Args: 147 | y_test_user (np.ndarray): grand truth for the user 148 | y_hat_user (np.ndarray): prediction of relevance 149 | k (int): a number of top item considered. 150 | 151 | Returns: 152 | (float): average precision score 153 | """ 154 | pred_sort_indices = (-y_hat_user).argsort() 155 | topk_y_hat = y_hat_user[pred_sort_indices[:k]] 156 | topk_y_test = y_test_user[pred_sort_indices[:k]] 157 | 158 | if topk_y_test.sum() < 1: 159 | return 0 160 | else: 161 | return average_precision_score(topk_y_test, topk_y_hat) 162 | 163 | 164 | def recall(y_test_user: np.ndarray, y_hat_user: np.ndarray, k: int): 165 | """Function for user-wise evaluator calculating Recall @ k 166 | 167 | Args: 168 | y_test_user (np.ndarray): grand truth for the user 169 | y_hat_user (np.ndarray): prediction of relevance 170 | k (int): a number of top item considered. 171 | 172 | Returns: 173 | (float): recall score 174 | """ 175 | pred_rank = (-y_hat_user).argsort().argsort() + 1 176 | pred_topk_flag = (pred_rank <= k).astype(int) 177 | return recall_score(y_test_user, pred_topk_flag) 178 | -------------------------------------------------------------------------------- /PyTorchCML/evaluators/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .BaseEvaluator import BaseEvaluator 3 | from .UserwiseEvaluator import ( 4 | UserwiseEvaluator, 5 | ndcg, 6 | average_precision, 7 | recall, 8 | ) 9 | -------------------------------------------------------------------------------- /PyTorchCML/losses/BaseLoss.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch import nn 3 | 4 | 5 | class BaseLoss(nn.Module): 6 | """Class of abstract loss module for pairwise loss like matrix factorization.""" 7 | 8 | def __init__(self, regularizers: list = []): 9 | super().__init__() 10 | self.regularizers = regularizers 11 | 12 | def forward( 13 | self, embeddings_dict: dict, batch: torch.Tensor, column_names: dict 14 | ) -> torch.Tensor: 15 | loss = self.main(embeddings_dict, batch, column_names) 16 | loss += self.regularize(embeddings_dict) 17 | return loss 18 | 19 | def main( 20 | self, embeddings_dict: dict, batch: torch.Tensor, column_names: dict 21 | ) -> torch.Tensor: 22 | """ 23 | Args: 24 | embeddings_dict (dict): A dictionary of embddings. 25 | (e.g. It has following key and values.) 26 | user_embedding : embeddings of user, size (n_batch, 1, d) 27 | pos_item_embedding : embeddings of positive item, size (n_batch, 1, d) 28 | neg_item_embedding : embeddings of negative item, size (n_batch, n_neg_samples, d) 29 | user_bias : bias of user, size (n_batch, 1) 30 | pos_item_bias : bias of positive item, size (n_batch, 1) 31 | neg_item_bias : bias of negative item, size (n_batch, n_neg_samples) 32 | 33 | batch (torch.Tensor) : A tensor of batch, size (n_batch, *). 34 | column_names (dict) : A dictionary that maps names to indices of rows of batch. 35 | 36 | Raises: 37 | NotImplementedError: [description] 38 | 39 | Returns: 40 | torch.Tensor: [description] 41 | 42 | --- example code --- 43 | 44 | embeddings_dict = { 45 | "user_embedding": user_embedding, 46 | "pos_item_embedding": pos_item_embedding, 47 | "neg_item_embedding": neg_item_embedding, 48 | "user_bias": user_bias, 49 | "pos_item_bias": pos_item_bias, 50 | "neg_item_bias": neg_item_bias, 51 | } 52 | 53 | loss = loss_function(embeddings_dict, batch, column_names) 54 | 55 | return loss 56 | """ 57 | 58 | raise NotImplementedError 59 | 60 | def regularize(self, embeddings_dict: dict): 61 | reg = 0 62 | for regularizer in self.regularizers: 63 | reg += regularizer(embeddings_dict) 64 | 65 | return reg 66 | -------------------------------------------------------------------------------- /PyTorchCML/losses/LogitPairwiseLoss.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch import nn 3 | 4 | from .BaseLoss import BaseLoss 5 | 6 | 7 | class LogitPairwiseLoss(BaseLoss): 8 | """Class of pairwise logit loss for Logistic Matrix Factorization""" 9 | 10 | def __init__(self, regularizers: list = []): 11 | super().__init__(regularizers) 12 | self.LogSigmoid = nn.LogSigmoid() 13 | 14 | def main( 15 | self, embeddings_dict: dict, batch: torch.Tensor, column_names: dict 16 | ) -> torch.Tensor: 17 | """Method of forwarding main loss 18 | 19 | Args: 20 | embeddings_dict (dict): A dictionary of embddings which has following key and values. 21 | user_embedding : embeddings of user, size (n_batch, 1, d) 22 | pos_item_embedding : embeddings of positive item, size (n_batch, 1, d) 23 | neg_item_embedding : embeddings of negative item, size (n_batch, n_neg_samples, d) 24 | user_bias : bias of user, size (n_batch, 1) 25 | pos_item_bias : bias of positive item, size (n_batch, 1) 26 | neg_item_bias : bias of negative item, size (n_batch, n_neg_samples) 27 | 28 | batch (torch.Tensor) : A tensor of batch, size (n_batch, *). 29 | column_names (dict) : A dictionary that maps names to indices of rows of batch. 30 | """ 31 | 32 | n_batch = embeddings_dict["user_embedding"].shape[0] 33 | n_neg = embeddings_dict["neg_item_bias"].shape[1] 34 | n_pos = 1 35 | 36 | pos_inner = torch.einsum( 37 | "nid,nid->n", 38 | embeddings_dict["user_embedding"], 39 | embeddings_dict["pos_item_embedding"], 40 | ) 41 | 42 | neg_inner = torch.einsum( 43 | "nid,njd->nj", 44 | embeddings_dict["user_embedding"], 45 | embeddings_dict["neg_item_embedding"], 46 | ) 47 | 48 | pos_bias = embeddings_dict["user_bias"] + embeddings_dict["pos_item_bias"] 49 | pos_y_hat = pos_inner + pos_bias.reshape(-1) 50 | 51 | neg_bias = embeddings_dict["user_bias"] + embeddings_dict["neg_item_bias"] 52 | neg_y_hat = neg_inner + neg_bias 53 | 54 | pos_loss = -nn.LogSigmoid()(pos_y_hat).sum() 55 | neg_loss = -nn.LogSigmoid()(-neg_y_hat).sum() 56 | 57 | loss = (pos_loss + neg_loss) / (n_batch * (n_pos + n_neg)) 58 | 59 | return loss 60 | -------------------------------------------------------------------------------- /PyTorchCML/losses/MSEPairwiseLoss.py: -------------------------------------------------------------------------------- 1 | import torch 2 | 3 | from .BaseLoss import BaseLoss 4 | 5 | 6 | class MSEPairwiseLoss(BaseLoss): 7 | """Class of loss for MSE in implicit feedback""" 8 | 9 | def main( 10 | self, embeddings_dict: dict, batch: torch.Tensor, column_names: dict 11 | ) -> torch.Tensor: 12 | """Method of forwarding main loss 13 | 14 | Args: 15 | embeddings_dict (dict): A dictionary of embddings which has following key and values. 16 | "user_embedding" : embeddings of user, size (n_batch, 1, d) 17 | "pos_item_embedding" : embeddings of positive item, size (n_batch, 1, d) 18 | "neg_item_embedding" : embeddings of negative item, size (n_batch, n_neg_samples, d) 19 | "user_bias" : bias of user, size (n_batch, 1) 20 | "pos_item_bias" : bias of positive item, size (n_batch, 1) 21 | "neg_item_bias" : bias of negative item, size (n_batch, n_neg_samples) 22 | 23 | batch (torch.Tensor) : A tensor of batch, size (n_batch, *). 24 | column_names (dict) : A dictionary that maps names to indices of rows of batch which has following key and values. 25 | "user_id" : user id 26 | "item_id" : item id 27 | "pscore" : propensity score 28 | """ 29 | 30 | n_batch = embeddings_dict["user_embedding"].shape[0] 31 | n_neg = embeddings_dict["neg_item_bias"].shape[1] 32 | n_pos = 1 33 | 34 | pos_inner = torch.einsum( 35 | "nid,nid->n", 36 | embeddings_dict["user_embedding"], 37 | embeddings_dict["pos_item_embedding"], 38 | ) 39 | 40 | neg_inner = torch.einsum( 41 | "nid,njd->nj", 42 | embeddings_dict["user_embedding"], 43 | embeddings_dict["neg_item_embedding"], 44 | ) 45 | 46 | pos_bias = embeddings_dict["user_bias"] + embeddings_dict["pos_item_bias"] 47 | neg_bias = embeddings_dict["user_bias"] + embeddings_dict["neg_item_bias"] 48 | 49 | pos_r_hat = pos_inner + pos_bias.reshape(-1) 50 | neg_r_hat = neg_inner + neg_bias 51 | 52 | pos_loss = ((1 - torch.sigmoid(pos_r_hat)) ** 2).sum() 53 | neg_loss = (torch.sigmoid(neg_r_hat) ** 2).sum() 54 | 55 | loss = (pos_loss + neg_loss) / (n_batch * (n_pos + n_neg)) 56 | 57 | return loss 58 | -------------------------------------------------------------------------------- /PyTorchCML/losses/MinTripletLoss.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch import nn 3 | 4 | from .BaseLoss import BaseLoss 5 | 6 | 7 | class MinTripletLoss(BaseLoss): 8 | def __init__(self, margin: float = 1, regularizers: list = []): 9 | """Class of Triplet Loss taking minimum negative sample.""" 10 | super().__init__(regularizers) 11 | self.margin = margin 12 | self.ReLU = nn.ReLU() 13 | 14 | def main( 15 | self, embeddings_dict: dict, batch: torch.Tensor, column_names: dict 16 | ) -> torch.Tensor: 17 | """Method of forwarding main loss 18 | 19 | Args: 20 | embeddings_dict (dict): A dictionary of embddings which has following key and values. 21 | user_embedding : embeddings of user, size (n_batch, 1, d) 22 | pos_item_embedding : embeddings of positive item, size (n_batch, 1, d) 23 | neg_item_embedding : embeddings of negative item, size (n_batch, n_neg_samples, d) 24 | 25 | batch (torch.Tensor) : A tensor of batch, size (n_batch, *). 26 | column_names (dict) : A dictionary that maps names to indices of rows of batch. 27 | 28 | Return: 29 | torch.Tensor: loss, L = Σ [m + pos_dist^2 - min(neg_dist)^2] 30 | """ 31 | 32 | pos_dist = torch.cdist( 33 | embeddings_dict["user_embedding"], embeddings_dict["pos_item_embedding"] 34 | ) 35 | 36 | neg_dist = torch.cdist( 37 | embeddings_dict["user_embedding"], embeddings_dict["neg_item_embedding"] 38 | ) 39 | 40 | min_neg_dist = torch.min(neg_dist, axis=2) 41 | pairwiseloss = self.ReLU(self.margin + pos_dist ** 2 - min_neg_dist.values ** 2) 42 | 43 | loss = torch.mean(pairwiseloss) 44 | 45 | return loss 46 | -------------------------------------------------------------------------------- /PyTorchCML/losses/RelevancePairwiseLoss.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch import nn 3 | 4 | from .BaseLoss import BaseLoss 5 | 6 | 7 | class RelevancePairwiseLoss(BaseLoss): 8 | """Class of loss for Relevance Matrix Factorization 9 | 10 | See below reference for detail. 11 | 12 | Y. Saito, S. Yaginuma, Y. Nishino, H. Sakata, and K. Nakata, 13 | “Unbiased recommender learning from missing-not-at-random implicit feedback,” in WSDM, 2020 14 | 15 | """ 16 | 17 | def __init__(self, regularizers: list = [], delta: str = "logistic"): 18 | super().__init__(regularizers) 19 | self.LogSigmoid = nn.LogSigmoid() 20 | if delta == "logistic": 21 | self.delta_pos = lambda x: -nn.LogSigmoid()(x) 22 | self.delta_neg = lambda x: -nn.LogSigmoid()(-x) 23 | 24 | elif delta == "mse": 25 | self.delta_pos = lambda x: (1 - torch.sigmoid(x)) ** 2 26 | self.delta_neg = lambda x: torch.sigmoid(x) ** 2 27 | 28 | else: 29 | raise NotImplementedError 30 | 31 | def main( 32 | self, embeddings_dict: dict, batch: torch.Tensor, column_names: dict 33 | ) -> torch.Tensor: 34 | """Method of forwarding main loss 35 | 36 | Args: 37 | embeddings_dict (dict): A dictionary of embddings which has following key and values. 38 | "user_embedding" : embeddings of user, size (n_batch, 1, d) 39 | "pos_item_embedding" : embeddings of positive item, size (n_batch, 1, d) 40 | "neg_item_embedding" : embeddings of negative item, size (n_batch, n_neg_samples, d) 41 | "user_bias" : bias of user, size (n_batch, 1) 42 | "pos_item_bias" : bias of positive item, size (n_batch, 1) 43 | "neg_item_bias" : bias of negative item, size (n_batch, n_neg_samples) 44 | 45 | batch (torch.Tensor) : A tensor of batch, size (n_batch, *). 46 | column_names (dict) : A dictionary that maps names to indices of rows of batch which has following key and values. 47 | "user_id" : user id 48 | "item_id" : item id 49 | "pscore" : propensity score 50 | """ 51 | 52 | n_batch = embeddings_dict["user_embedding"].shape[0] 53 | n_neg = embeddings_dict["neg_item_bias"].shape[1] 54 | n_pos = 1 55 | pscore = batch[:, column_names["pscore"]] 56 | 57 | pos_inner = torch.einsum( 58 | "nid,nid->n", 59 | embeddings_dict["user_embedding"], 60 | embeddings_dict["pos_item_embedding"], 61 | ) 62 | 63 | neg_inner = torch.einsum( 64 | "nid,njd->nj", 65 | embeddings_dict["user_embedding"], 66 | embeddings_dict["neg_item_embedding"], 67 | ) 68 | 69 | pos_bias = embeddings_dict["user_bias"] + embeddings_dict["pos_item_bias"] 70 | neg_bias = embeddings_dict["user_bias"] + embeddings_dict["neg_item_bias"] 71 | 72 | pos_r_hat = pos_inner + pos_bias.reshape(-1) 73 | neg_r_hat = neg_inner + neg_bias 74 | 75 | pos_loss_pos = self.delta_pos(pos_r_hat) / pscore 76 | pos_loss_neg = 1 - self.delta_neg(pos_r_hat) / pscore 77 | pos_loss = (pos_loss_pos + pos_loss_neg).sum() 78 | 79 | neg_loss = self.delta_neg(neg_r_hat).sum() 80 | 81 | loss = (pos_loss + neg_loss) / (n_batch * (n_pos + n_neg)) 82 | 83 | return loss 84 | -------------------------------------------------------------------------------- /PyTorchCML/losses/SumTripletLoss.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch import nn 3 | 4 | from .BaseLoss import BaseLoss 5 | 6 | 7 | class SumTripletLoss(BaseLoss): 8 | """Class of Triplet Loss taking sum of negative sample.""" 9 | 10 | def __init__(self, margin: float = 1, regularizers: list = []): 11 | super().__init__(regularizers) 12 | self.margin = margin 13 | self.ReLU = nn.ReLU() 14 | 15 | def main( 16 | self, embeddings_dict: dict, batch: torch.Tensor, column_names: dict 17 | ) -> torch.Tensor: 18 | """Method of forwarding main loss 19 | 20 | Args: 21 | embeddings_dict (dict): A dictionary of embddings which has following key and values 22 | user_embedding : embeddings of user, size (n_batch, 1, d) 23 | pos_item_embedding : embeddings of positive item, size (n_batch, 1, d) 24 | neg_item_embedding : embeddings of negative item, size (n_batch, n_neg_samples, d) 25 | 26 | batch (torch.Tensor) : A tensor of batch, size (n_batch, *). 27 | column_names (dict) : A dictionary that maps names to indices of rows of batch. 28 | 29 | Return: 30 | torch.Tensor : loss, L = Σ [m + pos_dist^2 - min(neg_dist)^2] 31 | """ 32 | pos_dist = torch.cdist( 33 | embeddings_dict["user_embedding"], embeddings_dict["pos_item_embedding"] 34 | ) 35 | 36 | neg_dist = torch.cdist( 37 | embeddings_dict["user_embedding"], embeddings_dict["neg_item_embedding"] 38 | ) 39 | 40 | tripletloss = self.ReLU(self.margin + pos_dist ** 2 - neg_dist ** 2) 41 | loss = torch.mean(tripletloss) 42 | 43 | return loss 44 | -------------------------------------------------------------------------------- /PyTorchCML/losses/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .BaseLoss import BaseLoss 3 | from .MinTripletLoss import MinTripletLoss 4 | from .SumTripletLoss import SumTripletLoss 5 | from .LogitPairwiseLoss import LogitPairwiseLoss 6 | from .RelevancePairwiseLoss import RelevancePairwiseLoss 7 | from .MSEPairwiseLoss import MSEPairwiseLoss 8 | -------------------------------------------------------------------------------- /PyTorchCML/models/BaseEmbeddingModel.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import torch 4 | from torch import nn 5 | 6 | from ..adaptors import BaseAdaptor 7 | 8 | 9 | class BaseEmbeddingModel(nn.Module): 10 | """Class of abstract embeddings model getting embedding or predict relevance from indices.""" 11 | 12 | def __init__( 13 | self, 14 | n_user: int, 15 | n_item: int, 16 | n_dim: int = 20, 17 | max_norm: Optional[float] = 1, 18 | user_embedding_init: Optional[torch.Tensor] = None, 19 | item_embedding_init: Optional[torch.Tensor] = None, 20 | user_adaptor: Optional[BaseAdaptor] = None, 21 | item_adaptor: Optional[BaseAdaptor] = None, 22 | ): 23 | """Set embeddings. 24 | 25 | Args: 26 | n_user (int): A number of users. 27 | n_item (int): A number of items. 28 | n_dim (int, optional): A number of dimention of embeddings. Defaults to 20. 29 | max_norm (Optional[float], optional): Allowed maximum norm. Defaults to 1. 30 | user_embedding_init (Optional[torch.Tensor], optional): Initial user embeddings. Defaults to None. 31 | item_embedding_init (Optional[torch.Tensor], optional): Initial item embeddings. Defaults to None. 32 | """ 33 | super().__init__() 34 | self.n_user = n_user 35 | self.n_item = n_item 36 | self.n_dim = n_dim 37 | self.max_norm = max_norm 38 | self.user_adaptor = user_adaptor 39 | self.item_adaptor = item_adaptor 40 | 41 | if user_embedding_init is None: 42 | self.user_embedding = nn.Embedding( 43 | n_user, n_dim, sparse=False, max_norm=max_norm 44 | ) 45 | 46 | else: 47 | self.user_embedding = nn.Embedding.from_pretrained(user_embedding_init) 48 | self.user_embedding.weight.requires_grad = True 49 | 50 | if item_embedding_init is None: 51 | self.item_embedding = nn.Embedding( 52 | n_item, n_dim, sparse=False, max_norm=max_norm 53 | ) 54 | else: 55 | self.item_embedding = nn.Embedding.from_pretrained(item_embedding_init) 56 | self.item_embedding.weight.requires_grad = True 57 | 58 | def forward( 59 | self, users: torch.Tensor, pos_items: torch.Tensor, neg_items: torch.Tensor 60 | ) -> dict: 61 | """Method of forwarding which returns embeddings 62 | 63 | Args: 64 | users (torch.Tensor): Tensor of indices of user. 65 | pos_items (torch.Tensor): Tensor of indices of positive items. 66 | neg_items (torch.Tensor): Tensor of indices of negative items. 67 | 68 | Raises: 69 | NotImplementedError: [description] 70 | 71 | Returns: 72 | dict: [description] 73 | """ 74 | raise NotImplementedError 75 | 76 | def predict(self, pairs: torch.Tensor) -> torch.Tensor: 77 | """Method of predicting relevance for each pair of user and item. 78 | 79 | Args: 80 | pairs (torch.Tensor): Tensor whose columns are [user_id, item_id] 81 | 82 | Raises: 83 | NotImplementedError: [description] 84 | 85 | Returns: 86 | torch.Tensor: Tensor of relevance size (pairs.shape[0]) 87 | """ 88 | raise NotImplementedError 89 | 90 | def get_item_score(self, users: torch.Tensor) -> torch.Tensor: 91 | """Method of getting scores of all items for each user. 92 | Args: 93 | users (torch.Tensor): 1d tensor of user_id size (n). 94 | 95 | Raises: 96 | NotImplementedError: [description] 97 | 98 | Returns: 99 | torch.Tensor: Tensor of item scores size (n, n_item) 100 | """ 101 | raise NotImplementedError 102 | 103 | def get_item_weight(self, users: torch.Tensor) -> torch.Tensor: 104 | """Method of getting weight for negative sampling 105 | Args: 106 | users (torch.Tensor): 1d tensor of user_id size (n). 107 | 108 | Raises: 109 | NotImplementedError: [description] 110 | 111 | Returns: 112 | torch.Tensor: Tensor of weight size (n, n_item) 113 | """ 114 | raise NotImplementedError 115 | -------------------------------------------------------------------------------- /PyTorchCML/models/CollaborativeMetricLearning.py: -------------------------------------------------------------------------------- 1 | import torch 2 | 3 | from .BaseEmbeddingModel import BaseEmbeddingModel 4 | 5 | 6 | class CollaborativeMetricLearning(BaseEmbeddingModel): 7 | def forward( 8 | self, users: torch.Tensor, pos_items: torch.Tensor, neg_items: torch.Tensor 9 | ) -> dict: 10 | """ 11 | Args: 12 | users : tensor of user indices size (n_batch). 13 | pos_items : tensor of item indices size (n_batch, 1) 14 | neg_items : tensor of item indices size (n_batch, n_neg_samples) 15 | 16 | Returns: 17 | dict: A dictionary of embeddings. 18 | """ 19 | 20 | # get enmbeddigs 21 | embeddings_dict = { 22 | "user_embedding": self.user_embedding(users), 23 | "pos_item_embedding": self.item_embedding(pos_items), 24 | "neg_item_embedding": self.item_embedding(neg_items), 25 | } 26 | 27 | return embeddings_dict 28 | 29 | def spreadout_distance(self, pos_items: torch.Tensor, neg_itmes: torch.Tensor): 30 | """ 31 | Args: 32 | pos_items : tensor of user indices size (n_batch, 1). 33 | neg_itmes : tensor of item indices size (n_neg_candidates) 34 | """ 35 | 36 | # get enmbeddigs 37 | pos_i_emb = self.item_embedding(pos_items) # n_batch × 1 × dim 38 | neg_i_emb = self.item_embedding(neg_itmes) # n_neg_candidates × dim 39 | 40 | # coumute dot product 41 | prod = torch.einsum("nid,md->nm", pos_i_emb, neg_i_emb) 42 | 43 | return prod 44 | 45 | def predict(self, pairs: torch.Tensor) -> torch.Tensor: 46 | """ 47 | Args: 48 | pairs : tensor of indices for user and item pairs size (n_pairs, 2). 49 | Returns: 50 | dist : distance for each users and item pair size (n_pairs) 51 | """ 52 | # set users and user 53 | users = pairs[:, :1] 54 | items = pairs[:, 1:2] 55 | 56 | # get enmbeddigs 57 | u_emb = self.user_embedding(users) 58 | i_emb = self.item_embedding(items) 59 | 60 | # compute distance 61 | dist = torch.cdist(u_emb, i_emb).reshape(-1) 62 | 63 | max_dist = 2 * self.max_norm if self.max_norm is not None else 100 64 | 65 | return max_dist - dist 66 | -------------------------------------------------------------------------------- /PyTorchCML/models/MatrixFactorization.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import torch 4 | from torch import nn 5 | 6 | from .BaseEmbeddingModel import BaseEmbeddingModel 7 | from ..adaptors import BaseAdaptor 8 | 9 | 10 | class LogitMatrixFactorization(BaseEmbeddingModel): 11 | def __init__( 12 | self, 13 | n_user: int, 14 | n_item: int, 15 | n_dim: int = 20, 16 | max_norm: Optional[float] = None, 17 | max_bias: Optional[float] = 1, 18 | user_embedding_init: Optional[torch.Tensor] = None, 19 | item_embedding_init: Optional[torch.Tensor] = None, 20 | user_bias_init: Optional[torch.Tensor] = None, 21 | item_bias_init: Optional[torch.Tensor] = None, 22 | user_adaptor: Optional[BaseAdaptor] = None, 23 | item_adaptor: Optional[BaseAdaptor] = None, 24 | ): 25 | """Set model parameters 26 | 27 | Args: 28 | n_user (int): A number of users 29 | n_item (int): A number of item 30 | n_dim (int, optional): A number of latent dimension. Defaults to 20. 31 | max_norm (Optional[float], optional): upper bound of norm of latent vector. Defaults to None. 32 | max_bias (Optional[float], optional): upper bound of bias. Defaults to 1. 33 | user_embedding_init (Optional[torch.Tensor], optional): initial embeddings for users. Defaults to None. 34 | item_embedding_init (Optional[torch.Tensor], optional): initial embeddings for item. Defaults to None. 35 | user_bias_init (Optional[torch.Tensor], optional): initial biases for users. Defaults to None. 36 | item_bias_init (Optional[torch.Tensor], optional): initial biases for item. Defaults to None. 37 | """ 38 | 39 | super().__init__( 40 | n_user, 41 | n_item, 42 | n_dim, 43 | max_norm, 44 | user_embedding_init, 45 | item_embedding_init, 46 | user_adaptor, 47 | item_adaptor, 48 | ) 49 | self.max_bias = max_bias 50 | self.weight_link = lambda x: torch.sigmoid(-x) 51 | 52 | if user_bias_init is None: 53 | self.user_bias = nn.Embedding(n_user, 1, max_norm=max_bias) 54 | 55 | else: 56 | self.user_bias = nn.Embedding.from_pretrained( 57 | user_bias_init.reshape(-1, 1), max_norm=max_bias 58 | ) 59 | self.user_bias.weight.requires_grad = True 60 | 61 | if item_bias_init is None: 62 | self.item_bias = nn.Embedding(n_item, 1, max_norm=max_bias) 63 | 64 | else: 65 | self.item_bias = nn.Embedding.from_pretrained( 66 | item_bias_init.reshape(-1, 1), max_norm=max_bias 67 | ) 68 | self.item_bias.weight.requires_grad = True 69 | 70 | def forward( 71 | self, users: torch.Tensor, pos_items: torch.Tensor, neg_items: torch.Tensor 72 | ) -> dict: 73 | """Method of forwarding embeddings 74 | 75 | Args: 76 | users : tensor of user indices size (n_batch). 77 | pos_items : tensor of item indices size (n_batch, 1) 78 | neg_items : tensor of item indices size (n_batch, n_neg_samples) 79 | 80 | Returns: 81 | dict: A dictionary of embeddings. 82 | """ 83 | 84 | # get enmbeddigs 85 | embeddings_dict = { 86 | "user_embedding": self.user_embedding(users), 87 | "pos_item_embedding": self.item_embedding(pos_items), 88 | "neg_item_embedding": self.item_embedding(neg_items), 89 | "user_bias": self.user_bias(users), 90 | "pos_item_bias": self.item_bias(pos_items), 91 | "neg_item_bias": self.item_bias(neg_items)[:, :, 0], 92 | } 93 | 94 | return embeddings_dict 95 | 96 | def predict(self, pairs: torch.Tensor) -> torch.Tensor: 97 | """Method of predicting relevance for each pair of user and item. 98 | 99 | Args: 100 | pairs (torch.Tensor): 2d tensor which columns are [user_id, item_id]. 101 | 102 | Raises: 103 | NotImplementedError: [description] 104 | 105 | Returns: 106 | torch.Tensor: inner product for each users and item pair size (n_batch). 107 | """ 108 | 109 | # set users and user 110 | users = pairs[:, 0] 111 | items = pairs[:, 1] 112 | 113 | # get enmbeddigs 114 | u_emb = self.user_embedding(users) # batch_size × dim 115 | i_emb = self.item_embedding(items) # batch_size × dim 116 | 117 | # get bias 118 | u_bias = self.user_bias(users) # batch_size × 1 119 | i_bias = self.item_bias(items) # batch_size × 1 120 | 121 | # compute distance 122 | inner = torch.einsum("nd,nd->n", u_emb, i_emb) 123 | 124 | return inner + u_bias.reshape(-1) + i_bias.reshape(-1) 125 | 126 | def predict_binary(self, pairs: torch.Tensor) -> torch.Tensor: 127 | pred = self.predict(pairs) 128 | sign = torch.sign(pred) 129 | binary = torch.uint8((sign + 1) / 2) 130 | return binary 131 | 132 | def predict_proba(self, pairs: torch.Tensor) -> torch.Tensor: 133 | pred = self.predict(pairs) 134 | proba = torch.sigmoid(pred) 135 | return proba 136 | 137 | def get_item_score(self, users: torch.Tensor) -> torch.Tensor: 138 | """Method of getting scores of all items for each user. 139 | Args: 140 | users (torch.Tensor): 1d tensor of user_id size (n). 141 | 142 | Raises: 143 | NotImplementedError: [description] 144 | 145 | Returns: 146 | torch.Tensor: Tensor of item scores size (n, n_item) 147 | """ 148 | u_emb = self.user_embedding(users) 149 | u_bias = self.user_bias(users) 150 | item_score = u_emb @ self.item_embedding.weight.T 151 | item_score += self.item_bias.weight.T + u_bias 152 | 153 | return item_score 154 | 155 | def get_item_weight(self, users: torch.Tensor) -> torch.Tensor: 156 | """Method of getting weight for negative sampling 157 | Args: 158 | users (torch.Tensor): 1d tensor of user_id size (n). 159 | 160 | Raises: 161 | NotImplementedError: [description] 162 | 163 | Returns: 164 | torch.Tensor: Tensor of weight size (n, n_item) 165 | """ 166 | item_score = self.get_item_score(users) 167 | weight = self.weight_link(item_score) 168 | 169 | return weight 170 | -------------------------------------------------------------------------------- /PyTorchCML/models/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .BaseEmbeddingModel import BaseEmbeddingModel 3 | from .CollaborativeMetricLearning import CollaborativeMetricLearning 4 | from .MatrixFactorization import LogitMatrixFactorization 5 | -------------------------------------------------------------------------------- /PyTorchCML/regularizers/BaseRegularizer.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch import nn 3 | 4 | 5 | class BaseRegularizer(nn.Module): 6 | def __init__(self, weight=1e-2): 7 | super().__init__() 8 | self.weight = weight 9 | 10 | def forward(self, embeddings_dict: dict) -> torch.Tensor: 11 | """method of forwarding 12 | 13 | Args: 14 | embeddings_dict (dict): dictionary of embbedings which will be used. 15 | 16 | Raises: 17 | NotImplementedError: [description] 18 | 19 | Returns: 20 | torch.Tensor: term of regularize 21 | """ 22 | raise NotImplementedError 23 | -------------------------------------------------------------------------------- /PyTorchCML/regularizers/GlobalOrthogonalRegularizer.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch import nn 3 | 4 | from .BaseRegularizer import BaseRegularizer 5 | 6 | 7 | class GlobalOrthogonalRegularizer(BaseRegularizer): 8 | """Class of Global Orthogonal Regularization""" 9 | 10 | def __init__(self, weight: float = 1e-2): 11 | super().__init__(weight) 12 | self.ReLU = nn.ReLU() 13 | 14 | def forward(self, embeddings_dict: dict) -> torch.Tensor: 15 | """Method of comuting regularize term 16 | 17 | Args: 18 | embeddings_dict (dict): dictionary of embeddings which has pos_item_emb and neg_item_emb 19 | 20 | Returns: 21 | torch.Tensor: term of regularize 22 | """ 23 | 24 | pos_item_emb = embeddings_dict["pos_item_embedding"] 25 | neg_item_emb = embeddings_dict["neg_item_embedding"] 26 | 27 | B, N, d = neg_item_emb.shape 28 | Q = B * N 29 | 30 | inner = torch.einsum("bid,bnd->bn", pos_item_emb, neg_item_emb) 31 | 32 | M1 = inner.sum() / Q 33 | M2 = (inner ** 2).sum() / Q 34 | 35 | LGOR = M1 ** 2 + self.ReLU(M2 - (1 / d)) 36 | 37 | return self.weight * LGOR 38 | -------------------------------------------------------------------------------- /PyTorchCML/regularizers/L2Regularizer.py: -------------------------------------------------------------------------------- 1 | import torch 2 | 3 | from .BaseRegularizer import BaseRegularizer 4 | 5 | 6 | class L2Regularizer(BaseRegularizer): 7 | """Class of L2 Regularization""" 8 | 9 | def forward(self, embeddings_dict: dict) -> torch.Tensor: 10 | """Method of comuting regularize term 11 | 12 | Args: 13 | embeddings_dict (dict): dictionary of embeddings which has pos_item_emb and neg_item_emb 14 | 15 | Returns: 16 | torch.Tensor: term of regularize 17 | """ 18 | 19 | user_emb = embeddings_dict["user_embedding"] 20 | pos_item_emb = embeddings_dict["pos_item_embedding"] 21 | neg_item_emb = embeddings_dict["neg_item_embedding"] 22 | 23 | user_norm = (user_emb ** 2).sum() 24 | pos_item_norm = (pos_item_emb ** 2).sum() 25 | neg_item_norm = (neg_item_emb ** 2).sum() 26 | 27 | norm = user_norm + pos_item_norm + neg_item_norm 28 | return self.weight * norm 29 | -------------------------------------------------------------------------------- /PyTorchCML/regularizers/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .BaseRegularizer import BaseRegularizer 3 | from .GlobalOrthogonalRegularizer import GlobalOrthogonalRegularizer 4 | from .L2Regularizer import L2Regularizer 5 | -------------------------------------------------------------------------------- /PyTorchCML/samplers/BaseSampler.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | import numpy as np 4 | import torch 5 | from torch.distributions.categorical import Categorical 6 | from scipy.sparse import csr_matrix 7 | 8 | from ..models import BaseEmbeddingModel 9 | 10 | 11 | class BaseSampler: 12 | def __init__( 13 | self, 14 | train_set: torch.Tensor, 15 | n_user: Optional[int] = None, 16 | n_item: Optional[int] = None, 17 | pos_weight: Optional[np.ndarray] = None, 18 | neg_weight: Union[np.ndarray, BaseEmbeddingModel] = None, 19 | device: Optional[torch.device] = None, 20 | batch_size: int = 256, 21 | n_neg_samples: int = 10, 22 | strict_negative: bool = False, 23 | neutral: torch.Tensor = torch.Tensor([]), 24 | ): 25 | """Class of Base Sampler for get positive and negative batch. 26 | Args: 27 | train_set (torch.Tensor): training interaction data which columns are [user_id, item_id] 28 | n_user (Optional[int], optional): A number of user considered. Defaults to None. 29 | n_item (Optional[int], optional): A number of item considered. Defaults to None. 30 | pos_weight (Optional[np.ndarray], optional): Sampling weight for positive pair. Defaults to None. 31 | neg_weight (Optional[np.ndarray], optional): Sampling weight for negative item. Defaults to None. 32 | device (Optional[torch.device], optional): Device name. Defaults to None. 33 | batch_size (int, optional): Length of mini-batch. Defaults to 256. 34 | n_neg_samples (int, optional): A number of negative samples. Defaults to 10. 35 | strict_negative (bool, optional): If removing positive items from negative samples or not. Defaults to False. 36 | 37 | Raises: 38 | NotImplementedError: [description] 39 | NotImplementedError: [description] 40 | """ 41 | # set positive pairs 42 | self.train_set = train_set 43 | 44 | # set some hyperparameters 45 | self.n_neg_samples = n_neg_samples 46 | self.batch_size = batch_size 47 | self.strict_negative = strict_negative 48 | self.two_stage = False 49 | 50 | if n_user is None: 51 | self.n_user = np.unique(train_set[:, 0].cpu()).shape[0] 52 | else: 53 | self.n_user = n_user 54 | 55 | if n_user is None: 56 | self.n_item = np.unique(train_set[:, 1].cpu()).shape[0] 57 | else: 58 | self.n_item = n_item 59 | 60 | # set flag matrix whose element indicates the pair is not negative. 61 | train_set_cpu = train_set.cpu() 62 | neutral_cpu = neutral.cpu() 63 | not_negative = torch.cat([train_set_cpu, neutral_cpu]) 64 | self.not_negative_flag = csr_matrix( 65 | (np.ones(not_negative.shape[0]), (not_negative[:, 0], not_negative[:, 1])), 66 | [n_user, n_item], 67 | ) 68 | self.not_negative_flag.sum_duplicates() 69 | self.not_negative_flag.data[:] = 1 70 | 71 | # device 72 | if device is None: 73 | self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") 74 | else: 75 | self.device = device 76 | 77 | # set pos weight 78 | if pos_weight is not None: # weighted 79 | if len(pos_weight) == len(train_set): 80 | pos_weight_pair = pos_weight 81 | 82 | elif len(pos_weight) == self.n_item: 83 | pos_weight_pair = pos_weight[train_set[:, 1].cpu()] 84 | 85 | elif len(pos_weight) == self.n_user: 86 | pos_weight_pair = pos_weight[train_set[:, 0].cpu()] 87 | 88 | else: 89 | raise NotImplementedError 90 | 91 | else: # uniform 92 | pos_weight_pair = torch.ones(train_set.shape[0]) 93 | 94 | self.pos_weight_pair = torch.Tensor(pos_weight_pair).to(self.device) 95 | self.pos_sampler = Categorical(probs=self.pos_weight_pair) 96 | 97 | # set neg weight 98 | if neg_weight is None: # uniform 99 | self.negative_weighted_by_model = False 100 | self.neg_item_weight = torch.ones(self.n_item).to(self.device) 101 | 102 | elif isinstance(neg_weight, BaseEmbeddingModel): # user-item weighted 103 | self.negative_weighted_by_model = True 104 | self.neg_weight_model = neg_weight 105 | 106 | elif len(neg_weight) == self.n_item: # item weighted 107 | self.negative_weighted_by_model = False 108 | self.neg_item_weight = torch.Tensor(neg_weight).to(self.device) 109 | 110 | else: 111 | raise NotImplementedError 112 | 113 | def get_pos_batch(self) -> torch.Tensor: 114 | """Method for positive sampling. 115 | 116 | Returns: 117 | torch.Tensor: positive batch. 118 | """ 119 | batch_indices = self.pos_sampler.sample([self.batch_size]) 120 | batch = self.train_set[batch_indices] 121 | return batch 122 | 123 | def get_neg_batch(self, users: torch.Tensor) -> torch.Tensor: 124 | """Method of negative sampling 125 | 126 | Args: 127 | users (torch.Tensor): indices of users in pos pairs. 128 | 129 | Returns: 130 | torch.Tensor: negative samples. 131 | """ 132 | 133 | if self.negative_weighted_by_model and self.strict_negative: 134 | flag = torch.Tensor(self.not_negative_flag[users.to("cpu")].A) 135 | mask = 1 - flag.to(self.device) 136 | weight = self.neg_weight_model.get_item_weight(users) 137 | weight *= mask 138 | neg_sampler = Categorical(probs=weight) 139 | neg_samples = neg_sampler.sample([self.n_neg_samples]).T 140 | 141 | elif self.negative_weighted_by_model and not self.strict_negative: 142 | weight = self.neg_weight_model.get_item_weight(users) 143 | neg_sampler = Categorical(probs=weight) 144 | neg_samples = neg_sampler.sample([self.n_neg_samples]).T 145 | 146 | elif not self.negative_weighted_by_model and self.strict_negative: 147 | flag = torch.Tensor(self.not_negative_flag[users.to("cpu")].A) 148 | mask = 1 - flag.to(self.device) 149 | weight = mask * self.neg_item_weight 150 | neg_sampler = Categorical(probs=weight) 151 | neg_samples = neg_sampler.sample([self.n_neg_samples]).T 152 | 153 | else: 154 | neg_sampler = Categorical(probs=self.neg_item_weight) 155 | neg_samples = neg_sampler.sample([self.batch_size, self.n_neg_samples]) 156 | 157 | return neg_samples 158 | -------------------------------------------------------------------------------- /PyTorchCML/samplers/TwoStageSampler.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import numpy as np 4 | import torch 5 | 6 | from torch.distributions.categorical import Categorical 7 | 8 | from .BaseSampler import BaseSampler 9 | 10 | 11 | class TwoStageSampler(BaseSampler): 12 | def __init__( 13 | self, 14 | train_set: np.ndarray, 15 | n_user: Optional[int] = None, 16 | n_item: Optional[int] = None, 17 | pos_weight: Optional[np.ndarray] = None, 18 | neg_weight: Optional[np.ndarray] = None, 19 | device: Optional[torch.device] = None, 20 | batch_size: int = 256, 21 | n_neg_samples: int = 10, 22 | strict_negative: bool = False, 23 | n_neg_candidates=200, 24 | ): 25 | """Class of Two Stage Sampler for CML. 26 | 27 | Args: 28 | train_set (torch.Tensor): training interaction data which columns are [user_id, item_id] 29 | n_user (Optional[int], optional): A number of user considered. Defaults to None. 30 | n_item (Optional[int], optional): A number of item considered. Defaults to None. 31 | pos_weight (Optional[np.ndarray], optional): Sampling weight for positive pair. Defaults to None. 32 | neg_weight (Optional[np.ndarray], optional): Sampling weight for negative item. Defaults to None. 33 | device (Optional[torch.device], optional): Device name. Defaults to None. 34 | batch_size (int, optional): Length of mini-batch. Defaults to 256. 35 | n_neg_samples (int, optional): A number of negative samples. Defaults to 10. 36 | strict_negative (bool, optional): If removing positive items from negative samples or not. Defaults to False. 37 | n_neg_candidates (int, optional): A number of candidates in 1st stage negative sampling. Defaults to 200. 38 | """ 39 | super().__init__( 40 | train_set, 41 | n_user, 42 | n_item, 43 | pos_weight, 44 | neg_weight, 45 | device, 46 | batch_size, 47 | n_neg_samples, 48 | strict_negative, 49 | ) 50 | 51 | self.two_stage = True 52 | self.n_neg_candidates = n_neg_candidates 53 | self.neg_candidate_sampler = Categorical(probs=self.neg_item_weight) 54 | 55 | def get_pos_batch(self) -> torch.Tensor: 56 | """Method for positive sampling. 57 | 58 | Returns: 59 | torch.Tensor: positive batch. 60 | """ 61 | batch_indices = self.pos_sampler.sample([self.batch_size]) 62 | batch = self.train_set[batch_indices] 63 | return batch 64 | 65 | def get_and_set_candidates(self) -> torch.Tensor: 66 | """Method of getting and setting candidates for 2nd stage. 67 | 68 | Returns: 69 | torch.Tensor: Indices of items of negative sample candidate. 70 | """ 71 | self.candidates = self.neg_candidate_sampler.sample([self.n_neg_candidates]) 72 | return self.candidates 73 | 74 | def set_candidates_weight(self, dist: torch.Tensor, dim: int): 75 | """Method of calclating sampling weight for 2nd stage sampling. 76 | 77 | Args: 78 | dist (torch.Tensor) : spreadout distance (dot product) matrix, size = (n_batch, n_neg_candidates) 79 | dim (int): A number of dimention of embeddings. 80 | """ 81 | # draw beta 82 | beta = ( 83 | torch.distributions.beta.Beta((dim - 1) / 2, 1 / 2) 84 | .sample([1]) 85 | .to(self.device) 86 | ) 87 | 88 | # make mask 89 | mask = (dist > 0) * (dist < 0.99) 90 | 91 | # calc weight 92 | alpha = 1 - (dim - 1) / 2 93 | log_neg_dist = torch.log(1 - torch.square(dist)) # + 1e-6) 94 | log_beta = torch.log(beta) 95 | self.candidates_weight = torch.exp(alpha * log_neg_dist + log_beta) 96 | 97 | # fill zero by mask 98 | self.candidates_weight[~mask] = 0 99 | 100 | # all zero -> uniform 101 | self.candidates_weight[self.candidates_weight.sum(axis=1) == 0] = 1 102 | 103 | def get_neg_batch(self, users: torch.Tensor) -> torch.Tensor: 104 | """Method of negative sampling 105 | 106 | Args: 107 | users (torch.Tensor): indices of users in pos pairs. 108 | 109 | Returns: 110 | torch.Tensor: negative samples. 111 | """ 112 | 113 | if self.strict_negative: 114 | pos_item_mask = torch.Tensor(self.not_negative_flag[users.to("cpu")].A) 115 | pos_item_mask_candidate = pos_item_mask[:, self.candidates].to(self.device) 116 | weight = (1 - pos_item_mask_candidate) * self.candidates_weight 117 | zero_indices = weight.sum(axis=1) <= 1e-10 118 | weight[zero_indices.reshape(-1)] = 1 119 | 120 | else: 121 | weight = self.candidates_weight 122 | 123 | neg_sampler = Categorical(probs=weight) 124 | neg_indices = neg_sampler.sample([self.n_neg_samples]).T 125 | neg_items = self.candidates[neg_indices] 126 | 127 | return neg_items 128 | -------------------------------------------------------------------------------- /PyTorchCML/samplers/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .BaseSampler import BaseSampler 3 | from .TwoStageSampler import TwoStageSampler 4 | -------------------------------------------------------------------------------- /PyTorchCML/trainers/BaseTrainer.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from torch import optim 4 | 5 | import numpy as np 6 | import pandas as pd 7 | from tqdm import tqdm 8 | 9 | from ..evaluators import BaseEvaluator 10 | from ..losses import BaseLoss 11 | from ..models import BaseEmbeddingModel 12 | from ..samplers import BaseSampler 13 | 14 | 15 | class BaseTrainer: 16 | """Class of abstract trainer for redommend system in implicit feedback setting.""" 17 | 18 | def __init__( 19 | self, 20 | model: BaseEmbeddingModel, 21 | optimizer: optim, 22 | criterion: BaseLoss, 23 | sampler: BaseSampler, 24 | column_names: Optional[dict] = None, 25 | ): 26 | """Set components for learning recommend system. 27 | 28 | Args: 29 | model (BaseEmbeddingModel): embedding model 30 | optimizer (optim): pytorch optimizer 31 | criterion (Union[BasePairwiseLoss, BaseTripletLoss]): loss function 32 | sampler (BaseSampler): sampler 33 | column_names (Optional[dict]): sampler 34 | """ 35 | self.model = model 36 | self.optimizer = optimizer 37 | self.criterion = criterion 38 | self.sampler = sampler 39 | 40 | if column_names is not None: 41 | self.column_names = column_names 42 | else: 43 | self.column_names = {"user_id": 0, "item_id": 1} 44 | 45 | def fit( 46 | self, 47 | n_batch: int = 500, 48 | n_epoch: int = 10, 49 | valid_evaluator: Optional[BaseEvaluator] = None, 50 | valid_per_epoch: int = 5, 51 | ): 52 | 53 | # set evaluator and log dataframe 54 | valid_or_not = valid_evaluator is not None 55 | if valid_or_not: 56 | self.valid_scores = valid_evaluator.score(self.model) 57 | self.valid_scores["epoch"] = 0 58 | self.valid_scores["loss"] = np.nan 59 | 60 | # start training 61 | for ep in range(n_epoch): 62 | accum_loss = 0 63 | 64 | # start epoch 65 | with tqdm(range(n_batch), total=n_batch) as pbar: 66 | for b in pbar: 67 | # batch sampling 68 | batch = self.sampler.get_pos_batch() 69 | users = batch[:, self.column_names["user_id"]].reshape(-1, 1) 70 | pos_items = batch[:, self.column_names["item_id"]].reshape(-1, 1) 71 | 72 | if self.sampler.two_stage: 73 | neg_candidates = self.sampler.get_and_set_candidates() 74 | dist = self.model.spreadout_distance(pos_items, neg_candidates) 75 | self.sampler.set_candidates_weight(dist, self.model.n_dim) 76 | 77 | neg_items = self.sampler.get_neg_batch(users.reshape(-1)) 78 | 79 | # initialize gradient 80 | self.model.zero_grad() 81 | 82 | # compute distance 83 | embeddings_dict = self.model(users, pos_items, neg_items) 84 | 85 | # compute loss 86 | loss = self.criterion(embeddings_dict, batch, self.column_names) 87 | 88 | # adding loss for domain adaptation 89 | if self.model.user_adaptor is not None: 90 | loss += self.model.user_adaptor( 91 | users, embeddings_dict["user_embedding"] 92 | ) 93 | if self.model.item_adaptor is not None: 94 | loss += self.model.item_adaptor( 95 | pos_items, embeddings_dict["pos_item_embedding"] 96 | ) 97 | loss += self.model.item_adaptor( 98 | neg_items, embeddings_dict["neg_item_embedding"] 99 | ) 100 | 101 | accum_loss += loss.item() 102 | 103 | # gradient of loss 104 | loss.backward() 105 | 106 | # update model parameters 107 | self.optimizer.step() 108 | 109 | pbar.set_description_str( 110 | f"epoch{ep+1} avg_loss:{accum_loss / (b+1) :.3f}" 111 | ) 112 | 113 | # compute metrics for epoch 114 | if valid_or_not and ( 115 | ((ep + 1) % valid_per_epoch == 0) or (ep == n_epoch - 1) 116 | ): 117 | valid_scores_sub = valid_evaluator.score(self.model) 118 | valid_scores_sub["epoch"] = ep + 1 119 | valid_scores_sub["loss"] = accum_loss / n_batch 120 | self.valid_scores = pd.concat([self.valid_scores, valid_scores_sub]) 121 | -------------------------------------------------------------------------------- /PyTorchCML/trainers/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .BaseTrainer import BaseTrainer 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyTorchCML 2 | 3 | ![https://github.com/hand10ryo/PyTorchCML/blob/image/images/icon.png](https://github.com/hand10ryo/PyTorchCML/blob/image/images/icon.png) 4 | 5 | PyTorchCML is a library of PyTorch implementations of matrix factorization (MF) and collaborative metric learning (CML), algorithms used in recommendation systems and data mining. 6 | 7 | 日本語版READMEは[こちら](https://github.com/hand10ryo/PyTorchCML/blob/main/README_ja.md) 8 | 9 | # What is CML ? 10 | 11 | CML is an algorithm that combines metric learning and MF. It allows us to embed elements of two sets, such as user-item or document-word, into a joint distance metric space using their relational data. 12 | 13 | In particular, CML is known to capture user-user and item-item relationships more precisely than MF and can achieve higher accuracy and interpretability than MF for recommendation systems [1]. In addition, the embeddings can be used for secondary purposes such as friend recommendations on SNS and similar item recommendations on e-commerce sites. 14 | 15 | For more details, please refer to this reference [1]. 16 | 17 | # Installation 18 | 19 | You can install PyTorchCML using Python's package manager pip. 20 | 21 | ```bash 22 | pip install PyTorchCML 23 | ``` 24 | 25 | You can also download the source code directly and build your environment with poetry.。 26 | 27 | ```bash 28 | git clone https://github.com/hand10ryo/PyTorchCML 29 | poetory install 30 | ``` 31 | 32 | ## dependencies 33 | 34 | The dependencies are as follows 35 | 36 | - python = ">=3.7.10,<3.9" 37 | - torch = "^1.8.1" 38 | - scikit-learn = "^0.22.2" 39 | - scipy = "^1.4.1" 40 | - numpy = "^1.19.5" 41 | - pandas = "^1.1.5" 42 | - tqdm = "^4.41.1" 43 | 44 | # Usage 45 | 46 | ## Example 47 | 48 | [This](https://github.com/hand10ryo/PytorchCML/tree/main/examples/notebooks) is a jupyter notebook example using the Movielens 100k dataset. 49 | 50 | ## Overview 51 | 52 | This library consists of the following six modules. 53 | 54 | - trainers 55 | - models 56 | - samplers 57 | - losses 58 | - regularizers 59 | - evaluators 60 | 61 | By combining these modules, you can implement a variety of algorithms. 62 | 63 | The following figure shows the relationship between these modules. 64 | 65 | ![https://github.com/hand10ryo/PyTorchCML/blob/image/images/diagram.png](https://github.com/hand10ryo/PyTorchCML/blob/image/images/diagram.png) 66 | 67 | The most straightforward implementation is as follows. 68 | 69 | ```python 70 | import torch 71 | from torch import optim 72 | import numpy as np 73 | from PyTorchCML import losses, models, samplers, trainers 74 | device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") 75 | 76 | # train dataset (whose columns are [user_id, item_id].) 77 | train_set = np.array([[0, 0], [0, 1], [1, 2], [1, 3]]) 78 | train_set_torch = torch.LongTensor(train_set).to(device) 79 | n_user = train_set[:,0].max() + 1 80 | n_item = train_set[:,1].max() + 1 81 | 82 | # model settings 83 | model = models.CollaborativeMetricLearning(n_user, n_item, n_dim=10).to(device) 84 | optimizer = optim.Adam(model.parameters(), lr=1e-3) 85 | criterion = losses.MinTripletLoss(margin=1).to(device) 86 | sampler = samplers.BaseSampler(train_set_torch, n_user, n_item, device=device) 87 | trainer = trainers.BaseTrainer(model, optimizer, criterion, sampler) 88 | 89 | # run 90 | trainer.fit(n_batch=256, n_epoch=3) 91 | ``` 92 | 93 | The input `train_set` represents a two-column NumPy array whose records are the user IDs and item IDs that received positive feedback. 94 | 95 | The `n_user` and `n_item` are the number of users and items. Here, we assume that user ID and item ID start from 0 and that all users and items are included in the train_set. 96 | 97 | Then, define model, optimizer, criterion, and sampler, input them to a trainer and run the trainer's fit method to start learning CM 98 | 99 | ## models 100 | 101 | The models is the module that handles the embeddings. 102 | 103 | There are currently two models to choose from as follows. 104 | 105 | - models.CollaborativeMetricLearning 106 | - models.LogitMatrixFactorization 107 | 108 | You can predict the relationship between the target user and the item with the `predict` method. 109 | 110 | CML uses vector distance, while MF uses the inner product to represent the relationship. 111 | 112 | You can also set the maximum norm and initial value of the embeddings. 113 | 114 | For example, in `LogitMatrixFactorization`, this is how it works. 115 | 116 | ```python 117 | model = models.LogitMatrixFactorization( 118 | n_user, n_item, n_dim, max_norm=5, 119 | user_embedding_init = torch.Tensor(U), # shape = (n_user, n_dim) 120 | item_embedding_init = torch.Tensor(V.T), # shape = (n_dim, n_item) 121 | ).to(device) 122 | ``` 123 | 124 | ## losses 125 | 126 | The losses module is for handling the loss function for learning embeddings. 127 | We can mainly divide the loss function into PairwiseLoss and TripletLoss. 128 | 129 | PairwiseLoss is the loss for each user-item pair $(u,i)$. 130 | 131 | TripletLoss is the loss per $(u,i_+,i_-)$. 132 | Here, $(u,i_+)$ is a positive pair, and $(u,i_-)$ is a negative pair. 133 | 134 | In general, CML uses triplet loss, and MF uses pairwise loss. 135 | 136 | ## samplers 137 | 138 | The samplers is a module that handles the sampling of mini-batches during training. 139 | 140 | There are two types of sampling done by the sampler. 141 | 142 | - Sampling of positive user-item pairs $(u,i_+)$, 143 | - Sampling of negative items $i_-$. 144 | 145 | The default setting is to sample both with a uniform random probability. 146 | 147 | It is also possible to weigh both positively and negatively. 148 | 149 | For example, if you want to weigh the items by their popularity, you can follow. 150 | 151 | ```python 152 | item_ids, item_popularity = np.unique(train_set[:,1], return_counts=True) 153 | sampler = samplers.BaseSampler( 154 | train_set_torch, neg_weight = item_popularity, 155 | n_user, n_item, device=device 156 | ) 157 | ``` 158 | 159 | ## trainers 160 | 161 | The trainers is the module that handles training. 162 | 163 | You can train by setting up a model, optimizer, loss function, and sampler. 164 | 165 | ## evaluators 166 | 167 | The evaluators is a module for evaluating performance after learning. 168 | 169 | You can evaluate your model as follows. 170 | 171 | ```python 172 | from PyTorchCML import evaluators 173 | 174 | # test set (whose columns are [user_id, item_id, rating].) 175 | test_set = np.array([[0, 2, 3], [0, 3, 4], [1, 0, 2], [1, 1, 5]]) 176 | test_set_torch = torch.LongTensor(test_set).to(device) 177 | 178 | # define metrics and evaluator 179 | score_function_dict = { 180 | "nDCG" : evaluators.ndcg, 181 | "MAP" : evaluators.average_precision, 182 | "Recall": evaluators.recall 183 | } 184 | evaluator = evaluators.UserwiseEvaluator( 185 | test_set_torch, 186 | score_function_dict, 187 | ks=[3,5] 188 | ) 189 | 190 | # calc scores 191 | scores = evaluator.score(model) 192 | ``` 193 | 194 | The `test_set` is a three-column NumPy array with user ID, item ID, and rating records. 195 | 196 | The `score_function_dict` is a dictionary of evaluation metrics. Its key is a name, and its value is a function to compute the evaluation metric. The evaluators module implements nDCG@k, MAP@k, and Recall@k as its functions. In this example, those three are set, but you can set any number of evaluation indicators. 197 | 198 | The `evaluator` takes input test data, evaluation metrics, and a list with @k types. 199 | 200 | You can calculate the scores by running the method `.score()` with the model as input. Its output `scores` will be a single row pandas.DataFrame with each score. In this example, its columns are `["nDCG@3", "MAP@3", "Recall@3", "nDCG@5", "MAP@5", "Recall@5"]`. 201 | 202 | Also, inputting the evaluator to the `valid_evaluator` argument of the fit method of the trainer will allow you to evaluate the learning progress. 203 | This system is helpful for hyperparameter tuning. 204 | 205 | ```python 206 | valid_evaluator = evaluators.UserwiseEvaluator( 207 | test_set_torch, # eval set 208 | score_function_dict, 209 | ks=[3,5] 210 | ) 211 | trainer.fit(n_batch=50, n_epoch=15, valid_evaluator = valid_evaluator) 212 | ``` 213 | 214 | ## regularizers 215 | 216 | The regularizers is a module that handles the regularization terms of embedded vectors. 217 | 218 | You can implement the L2 norm, etc., by entering a list of regularizer instances as the argument of the loss function, as shown below. 219 | 220 | ```python 221 | from PyTorchCML import regularizers 222 | regs = [regularizers.L2Regularizer(weight=1e-2)] 223 | criterion = losses.MinTripletLoss(margin=1, regularizers=regs).to(device) 224 | ``` 225 | 226 | It is also possible to introduce multiple regularizations by increasing the length of the list. 227 | 228 | ## adaptors 229 | 230 | The adaptors is a module for realizing domain adaptation. 231 | 232 | Domain adaptation in CML is achieved by adding $L(v_i, \theta) = \|f(x_i;\theta)-v_i\|^2$ to the loss for feature $x_i$ of item $i$. The same is true for the user. This allows us to reflect attribute information in the embedding vector. 233 | 234 | MLPAdaptor is a class of adaptors that assumes a multilayer perceptron in function $f(x_i;\theta)$. 235 | 236 | You can set up the adaptor as shown in the code below 237 | 238 | ```python 239 | from PyTorchCML import adaptors 240 | 241 | # item_feature.shape = (n_item, n_feature) 242 | item_feature_torch = torch.Tensor(item_feature) 243 | adaptor = adaptors.MLPAdaptor( 244 | item_feature_torch, 245 | n_dim=10, 246 | n_hidden=[20], 247 | weight=1e-4 248 | ) 249 | 250 | model = models.CollaborativeMetricLearning( 251 | n_user, n_item, n_dim, 252 | item_adaptor=adaptor 253 | ).to(device) 254 | ``` 255 | 256 | # Development 257 | 258 | Build develop enviroment as below. 259 | 260 | ```bash 261 | pip install poetry 262 | pip install poetry-dynamic-versioning 263 | 264 | poetry install 265 | poetry build 266 | ``` 267 | 268 | Follow the gitflow procedure for development. 269 | 270 | Develop detailed features by deriving feature/xxx branches from the develop branch. 271 | 272 | Each time you push, the github workflow will run a unitest. 273 | 274 | Send a pull request to the develop branch when a series of feature development is finished. 275 | 276 | 277 | # Citation 278 | 279 | You may use PyTorchCML under MIT License. If you use this program in your research then please cite: 280 | 281 | ```jsx 282 | @misc{matsui2021pytorchcml, 283 | author = {Ryo, Matsui}, 284 | title = {PyTorchCML}, 285 | year = {2021}, 286 | publisher = {GitHub}, 287 | journal = {GitHub repository}, 288 | howpublished = {https://github.com/hand10ryo/PyTorchCML} 289 | } 290 | ``` 291 | 292 | # References 293 | 294 | [1] Cheng-Kang Hsieh, Longqi Yang, Yin Cui, Tsung-Yi Lin, Serge Belongie, and Deborah Estrin.Collaborative metric learning. InProceedings of the 26th International Conference on World WideWeb, pp. 193–201, 2017. -------------------------------------------------------------------------------- /README_ja.md: -------------------------------------------------------------------------------- 1 | # PyTorchCML 2 | 3 | ![https://github.com/hand10ryo/PyTorchCML/blob/image/images/icon.png](https://github.com/hand10ryo/PyTorchCML/blob/image/images/icon.png) 4 | 5 | PyTorchCMLは、推薦システム・データマイニングのアルゴリズムである 行列分解(matrix factorization, MF) および collaborative metric learning (CML)を PyTorch で実装したライブラリです。 6 | 7 | English version of README is [here](https://github.com/hand10ryo/PyTorchCML/blob/main/README.md) 8 | 9 | # CML とは 10 | 11 | CML は metric learning と MF を組み合わせたアルゴリズムで、ユーザー×アイテム、ドキュメント × 単語 など 2つの集合の要素をそれらの関係データを用いて同じ距離空間に埋め込むことを可能にします。 12 | 13 | 特に、CML は MF よりもユーザー間およびアイテム間の関係性を精緻に捉えられることがわかっています[1] 。そのため、推薦システムにおいて MF よりも高い精度が望まれる上、解釈性が高く定性的な評価が容易となると考えられています。また、SNS上の友達推薦やECサイト上の類似商品推薦など埋め込みベクトルの副次的な利用も想定できます。 14 | 15 | 詳しくはこちらの参考文献 [1] をご参照ください。 16 | 17 | # インストール 18 | 19 | PytorchCMLは python のパッケージマネージャー pip でインストール可能です。 20 | 21 | ```bash 22 | pip install PyTorchCML 23 | ``` 24 | 25 | また、ソースコード を直接 ダウンロードして poetry で環境を構築することもできます。 26 | 27 | ```bash 28 | git clone https://github.com/hand10ryo/PyTorchCML 29 | poetory install 30 | ``` 31 | 32 | ## 依存関係 33 | 34 | 依存ライブラリは以下の通りです。 35 | 36 | - python = ">=3.7.10,<3.9" 37 | - torch = "^1.8.1" 38 | - scikit-learn = "^0.22.2" 39 | - scipy = "^1.4.1" 40 | - numpy = "^1.19.5" 41 | - pandas = "^1.1.5" 42 | - tqdm = "^4.41.1" 43 | 44 | # 使い方 45 | 46 | ## 例 47 | 48 | Movielens 100k データセットを用いた jupyter notebook の例が[こちら](https://github.com/hand10ryo/PytorchCML/tree/main/examples/notebooks)にあります。 49 | 50 | ## 概観 51 | 52 | このライブラリは以下の6つのモジュールで構成されています。 53 | 54 | - trainers 55 | - models 56 | - samplers 57 | - losses 58 | - regularizers 59 | - evaluators 60 | 61 | これらを組み合わせることで様々なアルゴリズムを実装可能となります。 62 | 63 | これらのモジュールは以下の図のような関係があります。 64 | 65 | ![https://github.com/hand10ryo/PyTorchCML/blob/image/images/diagram.png](https://github.com/hand10ryo/PyTorchCML/blob/image/images/diagram.png) 66 | 67 | 最も単純化した実装は以下の通りです。 68 | 69 | ```python 70 | import torch 71 | from torch import optim 72 | import numpy as np 73 | from PyTorchCML import losses, models, samplers, trainers 74 | device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") 75 | 76 | # train dataset (whose columns are [user_id, item_id].) 77 | train_set = np.array([[0, 0], [0, 1], [1, 2], [1, 3]]) 78 | train_set_torch = torch.LongTensor(train_set).to(device) 79 | n_user = train_set[:,0].max() + 1 80 | n_item = train_set[:,1].max() + 1 81 | 82 | # model settings 83 | model = models.CollaborativeMetricLearning(n_user, n_item, n_dim=10).to(device) 84 | optimizer = optim.Adam(model.parameters(), lr=1e-3) 85 | criterion = losses.MinTripletLoss(margin=1).to(device) 86 | sampler = samplers.BaseSampler(train_set_torch, n_user, n_item, device=device) 87 | trainer = trainers.BaseTrainer(model, optimizer, criterion, sampler) 88 | 89 | # run 90 | trainer.fit(n_batch=256, n_epoch=3) 91 | ``` 92 | 93 | ただし `train_set`は、ポジティブなfeedback を受け取ったユーザーIDおよびアイテムIDをレコードにもつ2列の numpy 配列を表します。 94 | 95 | また`n_user`および`n_item`はユーザー数・アイテム数の取得をしています。ユーザーIDおよびアイテムIDは0から始まり、全てのユーザーおよびアイテムが`train_set`に含まれることを想定しています。 96 | 97 | その後、model, optimizer, criterion, sampler を定義し、trainer に入力し、trainer の fit メソッドを実行すると CML の学習が始まります。 98 | 99 | ## models 100 | 101 | models 埋め込みベクトルを司るモジュールです。 102 | 103 | モデルは現在、以下の二つが選べます。 104 | 105 | - models.CollaborativeMetricLearning 106 | - models.LogitMatrixFactorization 107 | 108 | `predict` メソッドで対象のユーザーとアイテムの関係性を予測できます。 109 | 110 | CMFはベクトル距離、MFの内積で関係性を表現します。 111 | 112 | また、ベクトルの最大ノルム、埋め込みベクトルの初期値を設定することもできます。 113 | 114 | 例えば LogitMatrixFactorizationではこのようになります。 115 | 116 | ```python 117 | model = models.LogitMatrixFactorization( 118 | n_user, n_item, n_dim, max_norm=5, 119 | user_embedding_init = torch.Tensor(U), # shape = (n_user, n_dim) 120 | item_embedding_init = torch.Tensor(V.T), # shape = (n_dim, n_item) 121 | ).to(device) 122 | ``` 123 | 124 | ## losses 125 | 126 | losses は埋め込みベクトル学習のための損失関数を司るモジュールです。 127 | 128 | 損失関数は主に、PairwiseLoss と TripletLoss に分けられます。 129 | 130 | PairwiseLoss は、ユーザーアイテムペア $(u, i)$ ごとの損失です。 131 | 132 | TripletLoss は、ポジティブなユーザーアイテムペア $(u,i_+)$ に対してネガティブなアイテム$i_-$ を加えた$(u,i_+,i_-)$ ごとの損失です。 133 | 134 | ## samplers 135 | 136 | samplers は学習中のミニバッチのサンプリングを司るモジュールです。 137 | 138 | sampler が行うサンプリングは2種類あります。 139 | 140 | - ポジティブなユーザーアイテムペア$(u,i_+)$の抽出 141 | - ネガティブなアイテム$i_-$ の抽出 142 | 143 | デフォルトでは両者のサンプリングを一様ランダムに行います。 144 | 145 | ポジティブおよびネガティブで共に重み付けすることも可能です。 146 | 147 | 例えば、アイテムの人気度で重み付けする場合は以下のように行います。 148 | 149 | ```python 150 | item_ids, item_popularity = np.unique(train_set[:,1], return_counts=True) 151 | sampler = samplers.BaseSampler( 152 | train_set_torch, neg_weight = item_popularity, 153 | n_user, n_item, device=device 154 | ) 155 | ``` 156 | 157 | ## trainers 158 | 159 | trainers は学習を司るモジュールです。 160 | 161 | モデル、オプティマイザ、損失関数、サンプラーを設定すると学習ができます。 162 | 163 | ## evaluators 164 | 165 | evaluators は学習後のパフォーマンス評価を行うためのモジュールです。 166 | 167 | 学習後の評価は以下のように行うことができます。 168 | 169 | ```python 170 | from PyTorchCML import evaluators 171 | 172 | # test set (whose columns are [user_id, item_id, rating].) 173 | test_set = np.array([[0, 2, 3], [0, 3, 4], [1, 0, 2], [1, 1, 5]]) 174 | test_set_torch = torch.LongTensor(test_set).to(device) 175 | 176 | # define metrics and evaluator 177 | score_function_dict = { 178 | "nDCG" : evaluators.ndcg, 179 | "MAP" : evaluators.average_precision, 180 | "Recall": evaluators.recall 181 | } 182 | evaluator = evaluators.UserwiseEvaluator( 183 | test_set_torch, 184 | score_function_dict, 185 | ks=[3,5] 186 | ) 187 | 188 | # calc scores 189 | scores = evaluator.score(model) 190 | ``` 191 | 192 | 1行目の入力 `test_set`は、評価対象ユーザーアイテムペアのID およびその評価値をレコードにもつ3列の numpy 配列を表します。 193 | 194 | `score_function_dict`は評価指標の定義です。 195 | 196 | key にその名前、value には評価指標を計算する関数を設定します。 197 | 198 | evaluators モジュールにはその関数として、nDCG@k, MAP@k, Recall@k が実装されています。 199 | 200 | ここではその3つを設定していますが、任意の数の評価指標を設定できます。 201 | 202 | `evaluator`は、テストデータ、評価指標、 @k の種類を持つリストを入力とします。model を入力とするメソッド `.score()`を実行すればそのスコアを計算できます。その出力 `scores`は、各スコアを持つ1行の pandas.DataFrame となります。この例ではそのカラムは `["nDCG@3", "MAP@3", "Recall@3", "nDCG@5", "MAP@5", "Recall@5"]`となります。 203 | 204 | また、trainer の fit メソッドの引数 `valid_evaluator` に evaluator を設定すれば学習経過にも評価することができ、ハイパーパラメータ調整にも役立ちます。 205 | 206 | ```python 207 | valid_evaluator = evaluators.UserwiseEvaluator( 208 | test_set_torch, # eval set 209 | score_function_dict, 210 | ks=[3,5] 211 | ) 212 | trainer.fit(n_batch=50, n_epoch=15, valid_evaluator = valid_evaluator) 213 | ``` 214 | 215 | ## regularizers 216 | 217 | regularizers は埋め込みベクトルの正則化項を司るモジュールです。 218 | 219 | 以下のように、損失関数の引数に regularizer のインスタンスを要素にもつリストを入力することでL2ノルムなどを実装することができます。 220 | 221 | ```python 222 | from PyTorchCML import regularizers 223 | regs = [regularizers.L2Regularizer(weight=1e-2)] 224 | criterion = losses.MinTripletLoss(margin=1, regularizers=regs).to(device) 225 | ``` 226 | 227 | リストの長さを増やせば複数の正則化を導入することも可能です。 228 | 229 | ## adaptors 230 | 231 | adaptors はドメイン適合を実現するためのモジュールです。 232 | 233 | CMLにおけるドメイン適合はアイテム $i$ の特徴量 $x_i$に対して、$L(v_i, \theta) = \|f(x_i;\theta)-v_i\|^2$ を損失に加えることで達成します。ユーザーについても同様です。これによって埋め込みベクトルに属性情報を反映することができます。 234 | 235 | MLPAdaptor は$f(x_i;\theta)$ に多層パーセプトロンを仮定した Adaptor クラスです。 236 | 237 | 以下のようにモデルに組み込むことができます。 238 | 239 | ```python 240 | from PyTorchCML import adaptors 241 | 242 | # item_feature.shape = (n_item, n_feature) 243 | item_feature_torch = torch.Tensor(item_feature) 244 | adaptor = adaptors.MLPAdaptor( 245 | item_feature_torch, 246 | n_dim=10, 247 | n_hidden=[20], 248 | weight=1e-4 249 | ) 250 | 251 | model = models.CollaborativeMetricLearning( 252 | n_user, n_item, n_dim, 253 | item_adaptor=adaptor 254 | ).to(device) 255 | ``` 256 | 257 | # 開発 258 | 259 | 以下のように開発環境を構築します。 260 | 261 | ```bash 262 | pip install poetry 263 | pip install poetry-dynamic-versioning 264 | 265 | poetry install 266 | poetry build 267 | ``` 268 | 269 | gitflowの手順に従って開発しています。 270 | 271 | まず、develop ブランチから featuer/xxxx といったブランチを切って機能開発をします。 272 | 273 | どのブランチでも、github workflowsによってpushをする度にunittestが走ります。 274 | 275 | 機能開発が終わったらdevelopにプルリクエストを送ってください。 276 | 277 | 278 | # 引用 279 | 280 | PyTorchCML はMIT Licenseとして使用できます。研究に用いた際は以下を引用していただけると幸いです。 281 | 282 | ```jsx 283 | @misc{matsui2021pytorchcml, 284 | author = {Ryo, Matsui}, 285 | title = {PyTorchCML}, 286 | year = {2021}, 287 | publisher = {GitHub}, 288 | journal = {GitHub repository}, 289 | howpublished = {https://github.com/hand10ryo/PyTorchCML} 290 | } 291 | ``` 292 | 293 | # 参考文献 294 | 295 | [1] Cheng-Kang Hsieh, Longqi Yang, Yin Cui, Tsung-Yi Lin, Serge Belongie, and Deborah Estrin.Collaborative metric learning. InProceedings of the 26th International Conference on World WideWeb, pp. 193–201, 2017. -------------------------------------------------------------------------------- /examples/notebooks/movielens_cml.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "source": [ 7 | "# !pip install PyTorchCML" 8 | ], 9 | "outputs": [], 10 | "metadata": {} 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": 1, 15 | "source": [ 16 | "import sys\n", 17 | "sys.path.append(\"../../\")\n", 18 | "\n", 19 | "from itertools import product\n", 20 | "\n", 21 | "from PyTorchCML import losses, models, samplers, regularizers, evaluators, trainers\n", 22 | "import torch\n", 23 | "from torch import nn, optim\n", 24 | "import pandas as pd\n", 25 | "import numpy as np\n", 26 | "from sklearn.model_selection import train_test_split\n", 27 | "from sklearn.decomposition import TruncatedSVD\n", 28 | "from scipy.sparse import csr_matrix" 29 | ], 30 | "outputs": [], 31 | "metadata": {} 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": 2, 36 | "source": [ 37 | "# download movielens dataset\n", 38 | "movielens = pd.read_csv(\n", 39 | " 'http://files.grouplens.org/datasets/movielens/ml-100k/u.data', \n", 40 | " sep='\\t', header=None, index_col=None,\n", 41 | " names = [\"user_id\", \"item_id\", \"rating\", \"timestamp\"]\n", 42 | ")\n", 43 | "# Set user/item id and number of users/items.\n", 44 | "movielens.user_id -= 1\n", 45 | "movielens.item_id -= 1\n", 46 | "n_user = movielens.user_id.nunique()\n", 47 | "n_item = movielens.item_id.nunique()\n", 48 | "\n", 49 | "# make implicit feedback\n", 50 | "movielens.rating = (movielens.rating >= 4).astype(int)\n", 51 | "\n", 52 | "\n", 53 | "# train test split\n", 54 | "train, test = train_test_split(movielens)\n", 55 | "\n", 56 | "\n", 57 | "# all user item pairs\n", 58 | "df_all = pd.DataFrame(\n", 59 | " [[u, i] for u,i in product(range(n_user), range(n_item))],\n", 60 | " columns=[\"user_id\", \"item_id\"]\n", 61 | ")\n", 62 | "\n", 63 | "# frag train pairs\n", 64 | "df_all = pd.merge(\n", 65 | " df_all, \n", 66 | " train[[\"user_id\", \"item_id\", \"rating\"]], \n", 67 | " on=[\"user_id\", \"item_id\"], \n", 68 | " how=\"left\"\n", 69 | ")\n", 70 | "\n", 71 | "# remove train pairs\n", 72 | "test = pd.merge(\n", 73 | " df_all[df_all.rating.isna()][[\"user_id\", \"item_id\"]], \n", 74 | " test[[\"user_id\", \"item_id\", \"rating\"]], \n", 75 | " on=[\"user_id\", \"item_id\"], \n", 76 | " how=\"left\"\n", 77 | ").fillna(0)\n", 78 | "\n", 79 | "# numpy array\n", 80 | "train_set = train[train.rating == 1][[\"user_id\", \"item_id\"]].values\n", 81 | "test_set = test[[\"user_id\", \"item_id\", \"rating\"]].values\n", 82 | "\n", 83 | "# to torch.Tensor\n", 84 | "device = torch.device(\"cuda:0\" if torch.cuda.is_available() else \"cpu\")\n", 85 | "train_set = torch.LongTensor(train_set).to(device)\n", 86 | "test_set = torch.LongTensor(test_set).to(device)\n" 87 | ], 88 | "outputs": [], 89 | "metadata": {} 90 | }, 91 | { 92 | "cell_type": "markdown", 93 | "source": [ 94 | "## Defalt" 95 | ], 96 | "metadata": {} 97 | }, 98 | { 99 | "cell_type": "code", 100 | "execution_count": 3, 101 | "source": [ 102 | "lr = 1e-3\n", 103 | "n_dim = 10\n", 104 | "model = models.CollaborativeMetricLearning(n_user, n_item, n_dim).to(device)\n", 105 | "optimizer = optim.Adam(model.parameters(), lr=lr)\n", 106 | "criterion = losses.SumTripletLoss(margin=1).to(device)\n", 107 | "sampler = samplers.BaseSampler(train_set, n_user, n_item, device=device, strict_negative=False)\n", 108 | "\n", 109 | "score_function_dict = {\n", 110 | " \"nDCG\" : evaluators.ndcg,\n", 111 | " \"MAP\" : evaluators.average_precision,\n", 112 | " \"Recall\": evaluators.recall\n", 113 | "}\n", 114 | "evaluator = evaluators.UserwiseEvaluator(test_set, score_function_dict, ks=[3,5])\n", 115 | "trainer = trainers.BaseTrainer(model, optimizer, criterion, sampler)\n" 116 | ], 117 | "outputs": [], 118 | "metadata": {} 119 | }, 120 | { 121 | "cell_type": "code", 122 | "execution_count": 4, 123 | "source": [ 124 | "trainer.fit(n_batch=256, n_epoch=20, valid_evaluator = evaluator, valid_per_epoch=10)" 125 | ], 126 | "outputs": [ 127 | { 128 | "output_type": "stream", 129 | "name": "stderr", 130 | "text": [ 131 | "100%|██████████| 943/943 [00:20<00:00, 46.66it/s]\n", 132 | "epoch1 avg_loss:0.931: 100%|██████████| 256/256 [00:06<00:00, 38.85it/s]\n", 133 | "epoch2 avg_loss:0.753: 100%|██████████| 256/256 [00:06<00:00, 40.74it/s]\n", 134 | "epoch3 avg_loss:0.658: 100%|██████████| 256/256 [00:06<00:00, 39.85it/s]\n", 135 | "epoch4 avg_loss:0.597: 100%|██████████| 256/256 [00:05<00:00, 46.31it/s]\n", 136 | "epoch5 avg_loss:0.558: 100%|██████████| 256/256 [00:07<00:00, 34.50it/s]\n", 137 | "epoch6 avg_loss:0.525: 100%|██████████| 256/256 [00:05<00:00, 44.82it/s]\n", 138 | "epoch7 avg_loss:0.500: 100%|██████████| 256/256 [00:06<00:00, 42.24it/s]\n", 139 | "epoch8 avg_loss:0.476: 100%|██████████| 256/256 [00:07<00:00, 35.35it/s]\n", 140 | "epoch9 avg_loss:0.455: 100%|██████████| 256/256 [00:07<00:00, 34.50it/s]\n", 141 | "epoch10 avg_loss:0.433: 100%|██████████| 256/256 [00:06<00:00, 41.74it/s]\n", 142 | "100%|██████████| 943/943 [00:21<00:00, 44.59it/s]\n", 143 | "epoch11 avg_loss:0.412: 100%|██████████| 256/256 [00:06<00:00, 41.59it/s]\n", 144 | "epoch12 avg_loss:0.387: 100%|██████████| 256/256 [00:06<00:00, 39.21it/s]\n", 145 | "epoch13 avg_loss:0.368: 100%|██████████| 256/256 [00:06<00:00, 42.29it/s]\n", 146 | "epoch14 avg_loss:0.350: 100%|██████████| 256/256 [00:06<00:00, 40.07it/s]\n", 147 | "epoch15 avg_loss:0.334: 100%|██████████| 256/256 [00:06<00:00, 42.41it/s]\n", 148 | "epoch16 avg_loss:0.319: 100%|██████████| 256/256 [00:06<00:00, 42.64it/s]\n", 149 | "epoch17 avg_loss:0.303: 100%|██████████| 256/256 [00:06<00:00, 41.52it/s]\n", 150 | "epoch18 avg_loss:0.294: 100%|██████████| 256/256 [00:06<00:00, 41.94it/s]\n", 151 | "epoch19 avg_loss:0.283: 100%|██████████| 256/256 [00:05<00:00, 44.28it/s]\n", 152 | "epoch20 avg_loss:0.274: 100%|██████████| 256/256 [00:06<00:00, 41.52it/s]\n", 153 | "100%|██████████| 943/943 [00:21<00:00, 43.05it/s]\n" 154 | ] 155 | } 156 | ], 157 | "metadata": {} 158 | }, 159 | { 160 | "cell_type": "code", 161 | "execution_count": 5, 162 | "source": [ 163 | "trainer.valid_scores" 164 | ], 165 | "outputs": [ 166 | { 167 | "output_type": "execute_result", 168 | "data": { 169 | "text/html": [ 170 | "
\n", 171 | "\n", 184 | "\n", 185 | " \n", 186 | " \n", 187 | " \n", 188 | " \n", 189 | " \n", 190 | " \n", 191 | " \n", 192 | " \n", 193 | " \n", 194 | " \n", 195 | " \n", 196 | " \n", 197 | " \n", 198 | " \n", 199 | " \n", 200 | " \n", 201 | " \n", 202 | " \n", 203 | " \n", 204 | " \n", 205 | " \n", 206 | " \n", 207 | " \n", 208 | " \n", 209 | " \n", 210 | " \n", 211 | " \n", 212 | " \n", 213 | " \n", 214 | " \n", 215 | " \n", 216 | " \n", 217 | " \n", 218 | " \n", 219 | " \n", 220 | " \n", 221 | " \n", 222 | " \n", 223 | " \n", 224 | " \n", 225 | " \n", 226 | " \n", 227 | " \n", 228 | " \n", 229 | " \n", 230 | " \n", 231 | " \n", 232 | " \n", 233 | "
nDCG@3MAP@3Recall@3nDCG@5MAP@5Recall@5epochloss
00.0074230.0129020.0015680.0086340.0179220.0029170NaN
00.0423870.0700780.0060080.0468970.0843530.011537100.432954
00.2020320.2918880.0448770.2021310.3111880.073077200.274121
\n", 234 | "
" 235 | ], 236 | "text/plain": [ 237 | " nDCG@3 MAP@3 Recall@3 nDCG@5 MAP@5 Recall@5 epoch loss\n", 238 | "0 0.007423 0.012902 0.001568 0.008634 0.017922 0.002917 0 NaN\n", 239 | "0 0.042387 0.070078 0.006008 0.046897 0.084353 0.011537 10 0.432954\n", 240 | "0 0.202032 0.291888 0.044877 0.202131 0.311188 0.073077 20 0.274121" 241 | ] 242 | }, 243 | "metadata": {}, 244 | "execution_count": 5 245 | } 246 | ], 247 | "metadata": {} 248 | }, 249 | { 250 | "cell_type": "markdown", 251 | "source": [ 252 | "## Strict Negative" 253 | ], 254 | "metadata": {} 255 | }, 256 | { 257 | "cell_type": "code", 258 | "execution_count": 6, 259 | "source": [ 260 | "lr = 1e-3\n", 261 | "n_dim = 10\n", 262 | "model = models.CollaborativeMetricLearning(n_user, n_item, n_dim).to(device)\n", 263 | "optimizer = optim.Adam(model.parameters(), lr=lr)\n", 264 | "criterion = losses.SumTripletLoss(margin=1).to(device)\n", 265 | "sampler = samplers.BaseSampler(train_set, n_user, n_item, device=device, strict_negative=True)\n", 266 | "\n", 267 | "score_function_dict = {\n", 268 | " \"nDCG\" : evaluators.ndcg,\n", 269 | " \"MAP\" : evaluators.average_precision,\n", 270 | " \"Recall\": evaluators.recall\n", 271 | "}\n", 272 | "evaluator = evaluators.UserwiseEvaluator(test_set, score_function_dict, ks=[3,5])\n", 273 | "trainer = trainers.BaseTrainer(model, optimizer, criterion, sampler)\n" 274 | ], 275 | "outputs": [], 276 | "metadata": {} 277 | }, 278 | { 279 | "cell_type": "code", 280 | "execution_count": 7, 281 | "source": [ 282 | "trainer.fit(n_batch=256, n_epoch=20, valid_evaluator = evaluator, valid_per_epoch=10)" 283 | ], 284 | "outputs": [ 285 | { 286 | "output_type": "stream", 287 | "name": "stderr", 288 | "text": [ 289 | "100%|██████████| 943/943 [00:18<00:00, 51.01it/s]\n", 290 | "epoch1 avg_loss:0.949: 100%|██████████| 256/256 [00:09<00:00, 26.99it/s]\n", 291 | "epoch2 avg_loss:0.792: 100%|██████████| 256/256 [00:09<00:00, 26.42it/s]\n", 292 | "epoch3 avg_loss:0.697: 100%|██████████| 256/256 [00:09<00:00, 25.81it/s]\n", 293 | "epoch4 avg_loss:0.636: 100%|██████████| 256/256 [00:09<00:00, 27.21it/s]\n", 294 | "epoch5 avg_loss:0.597: 100%|██████████| 256/256 [00:07<00:00, 34.30it/s]\n", 295 | "epoch6 avg_loss:0.564: 100%|██████████| 256/256 [00:07<00:00, 34.35it/s]\n", 296 | "epoch7 avg_loss:0.538: 100%|██████████| 256/256 [00:07<00:00, 34.95it/s]\n", 297 | "epoch8 avg_loss:0.517: 100%|██████████| 256/256 [00:08<00:00, 29.53it/s]\n", 298 | "epoch9 avg_loss:0.494: 100%|██████████| 256/256 [00:08<00:00, 28.63it/s]\n", 299 | "epoch10 avg_loss:0.471: 100%|██████████| 256/256 [00:07<00:00, 32.86it/s]\n", 300 | "100%|██████████| 943/943 [00:23<00:00, 40.65it/s]\n", 301 | "epoch11 avg_loss:0.450: 100%|██████████| 256/256 [00:07<00:00, 33.37it/s]\n", 302 | "epoch12 avg_loss:0.425: 100%|██████████| 256/256 [00:07<00:00, 33.03it/s]\n", 303 | "epoch13 avg_loss:0.405: 100%|██████████| 256/256 [00:09<00:00, 27.11it/s]\n", 304 | "epoch14 avg_loss:0.388: 100%|██████████| 256/256 [00:08<00:00, 29.81it/s]\n", 305 | "epoch15 avg_loss:0.369: 100%|██████████| 256/256 [00:07<00:00, 33.38it/s]\n", 306 | "epoch16 avg_loss:0.350: 100%|██████████| 256/256 [00:08<00:00, 29.23it/s]\n", 307 | "epoch17 avg_loss:0.333: 100%|██████████| 256/256 [00:08<00:00, 28.81it/s]\n", 308 | "epoch18 avg_loss:0.316: 100%|██████████| 256/256 [00:07<00:00, 33.58it/s]\n", 309 | "epoch19 avg_loss:0.301: 100%|██████████| 256/256 [00:07<00:00, 34.74it/s]\n", 310 | "epoch20 avg_loss:0.288: 100%|██████████| 256/256 [00:07<00:00, 33.92it/s]\n", 311 | "100%|██████████| 943/943 [00:24<00:00, 39.21it/s]\n" 312 | ] 313 | } 314 | ], 315 | "metadata": { 316 | "tags": [] 317 | } 318 | }, 319 | { 320 | "cell_type": "code", 321 | "execution_count": 8, 322 | "source": [ 323 | "trainer.valid_scores" 324 | ], 325 | "outputs": [ 326 | { 327 | "output_type": "execute_result", 328 | "data": { 329 | "text/html": [ 330 | "
\n", 331 | "\n", 344 | "\n", 345 | " \n", 346 | " \n", 347 | " \n", 348 | " \n", 349 | " \n", 350 | " \n", 351 | " \n", 352 | " \n", 353 | " \n", 354 | " \n", 355 | " \n", 356 | " \n", 357 | " \n", 358 | " \n", 359 | " \n", 360 | " \n", 361 | " \n", 362 | " \n", 363 | " \n", 364 | " \n", 365 | " \n", 366 | " \n", 367 | " \n", 368 | " \n", 369 | " \n", 370 | " \n", 371 | " \n", 372 | " \n", 373 | " \n", 374 | " \n", 375 | " \n", 376 | " \n", 377 | " \n", 378 | " \n", 379 | " \n", 380 | " \n", 381 | " \n", 382 | " \n", 383 | " \n", 384 | " \n", 385 | " \n", 386 | " \n", 387 | " \n", 388 | " \n", 389 | " \n", 390 | " \n", 391 | " \n", 392 | " \n", 393 | "
nDCG@3MAP@3Recall@3nDCG@5MAP@5Recall@5epochloss
00.0203850.0369390.0014190.0184770.0416400.0023210NaN
00.0599360.0915520.0045460.0686320.1103790.009836100.471340
00.2736290.3691230.0347140.2725010.3858170.059150200.287908
\n", 394 | "
" 395 | ], 396 | "text/plain": [ 397 | " nDCG@3 MAP@3 Recall@3 nDCG@5 MAP@5 Recall@5 epoch loss\n", 398 | "0 0.020385 0.036939 0.001419 0.018477 0.041640 0.002321 0 NaN\n", 399 | "0 0.059936 0.091552 0.004546 0.068632 0.110379 0.009836 10 0.471340\n", 400 | "0 0.273629 0.369123 0.034714 0.272501 0.385817 0.059150 20 0.287908" 401 | ] 402 | }, 403 | "metadata": {}, 404 | "execution_count": 8 405 | } 406 | ], 407 | "metadata": {} 408 | }, 409 | { 410 | "cell_type": "markdown", 411 | "source": [ 412 | "## Global Orthogonal Regularization" 413 | ], 414 | "metadata": {} 415 | }, 416 | { 417 | "cell_type": "code", 418 | "execution_count": 9, 419 | "source": [ 420 | "lr = 1e-3\n", 421 | "n_dim = 10\n", 422 | "model = models.CollaborativeMetricLearning(n_user, n_item, n_dim).to(device)\n", 423 | "optimizer = optim.Adam(model.parameters(), lr=lr)\n", 424 | "regs = [regularizers.GlobalOrthogonalRegularizer(weight=1e-2)]\n", 425 | "criterion = losses.SumTripletLoss(margin=1, regularizers=regs).to(device)\n", 426 | "sampler = samplers.BaseSampler(train_set, n_user, n_item, device=device, strict_negative=True)\n", 427 | "\n", 428 | "score_function_dict = {\n", 429 | " \"nDCG\" : evaluators.ndcg,\n", 430 | " \"MAP\" : evaluators.average_precision,\n", 431 | " \"Recall\": evaluators.recall\n", 432 | "}\n", 433 | "evaluator = evaluators.UserwiseEvaluator(test_set, score_function_dict, ks=[3,5])\n", 434 | "trainer = trainers.BaseTrainer(model, optimizer, criterion, sampler)" 435 | ], 436 | "outputs": [], 437 | "metadata": {} 438 | }, 439 | { 440 | "cell_type": "code", 441 | "execution_count": 10, 442 | "source": [ 443 | "trainer.fit(n_batch=256, n_epoch=20, valid_evaluator = evaluator, valid_per_epoch=10)" 444 | ], 445 | "outputs": [ 446 | { 447 | "output_type": "stream", 448 | "name": "stderr", 449 | "text": [ 450 | "100%|██████████| 943/943 [00:21<00:00, 44.39it/s]\n", 451 | "epoch1 avg_loss:0.948: 100%|██████████| 256/256 [00:11<00:00, 21.50it/s]\n", 452 | "epoch2 avg_loss:0.794: 100%|██████████| 256/256 [00:07<00:00, 32.35it/s]\n", 453 | "epoch3 avg_loss:0.700: 100%|██████████| 256/256 [00:07<00:00, 33.16it/s]\n", 454 | "epoch4 avg_loss:0.638: 100%|██████████| 256/256 [00:10<00:00, 23.36it/s]\n", 455 | "epoch5 avg_loss:0.598: 100%|██████████| 256/256 [00:07<00:00, 34.20it/s]\n", 456 | "epoch6 avg_loss:0.565: 100%|██████████| 256/256 [00:08<00:00, 31.81it/s]\n", 457 | "epoch7 avg_loss:0.540: 100%|██████████| 256/256 [00:07<00:00, 32.40it/s]\n", 458 | "epoch8 avg_loss:0.516: 100%|██████████| 256/256 [00:07<00:00, 33.46it/s]\n", 459 | "epoch9 avg_loss:0.493: 100%|██████████| 256/256 [00:13<00:00, 19.51it/s]\n", 460 | "epoch10 avg_loss:0.470: 100%|██████████| 256/256 [00:08<00:00, 31.15it/s]\n", 461 | "100%|██████████| 943/943 [00:24<00:00, 38.92it/s]\n", 462 | "epoch11 avg_loss:0.449: 100%|██████████| 256/256 [00:08<00:00, 28.90it/s]\n", 463 | "epoch12 avg_loss:0.422: 100%|██████████| 256/256 [00:08<00:00, 31.93it/s]\n", 464 | "epoch13 avg_loss:0.401: 100%|██████████| 256/256 [00:10<00:00, 23.42it/s]\n", 465 | "epoch14 avg_loss:0.377: 100%|██████████| 256/256 [00:08<00:00, 31.57it/s]\n", 466 | "epoch15 avg_loss:0.357: 100%|██████████| 256/256 [00:11<00:00, 22.92it/s]\n", 467 | "epoch16 avg_loss:0.338: 100%|██████████| 256/256 [00:07<00:00, 33.48it/s]\n", 468 | "epoch17 avg_loss:0.318: 100%|██████████| 256/256 [00:08<00:00, 30.68it/s]\n", 469 | "epoch18 avg_loss:0.304: 100%|██████████| 256/256 [00:08<00:00, 31.37it/s]\n", 470 | "epoch19 avg_loss:0.289: 100%|██████████| 256/256 [00:07<00:00, 33.49it/s]\n", 471 | "epoch20 avg_loss:0.281: 100%|██████████| 256/256 [00:13<00:00, 18.94it/s]\n", 472 | "100%|██████████| 943/943 [00:24<00:00, 38.13it/s]\n" 473 | ] 474 | } 475 | ], 476 | "metadata": {} 477 | }, 478 | { 479 | "cell_type": "code", 480 | "execution_count": 11, 481 | "source": [ 482 | "trainer.valid_scores" 483 | ], 484 | "outputs": [ 485 | { 486 | "output_type": "execute_result", 487 | "data": { 488 | "text/html": [ 489 | "
\n", 490 | "\n", 503 | "\n", 504 | " \n", 505 | " \n", 506 | " \n", 507 | " \n", 508 | " \n", 509 | " \n", 510 | " \n", 511 | " \n", 512 | " \n", 513 | " \n", 514 | " \n", 515 | " \n", 516 | " \n", 517 | " \n", 518 | " \n", 519 | " \n", 520 | " \n", 521 | " \n", 522 | " \n", 523 | " \n", 524 | " \n", 525 | " \n", 526 | " \n", 527 | " \n", 528 | " \n", 529 | " \n", 530 | " \n", 531 | " \n", 532 | " \n", 533 | " \n", 534 | " \n", 535 | " \n", 536 | " \n", 537 | " \n", 538 | " \n", 539 | " \n", 540 | " \n", 541 | " \n", 542 | " \n", 543 | " \n", 544 | " \n", 545 | " \n", 546 | " \n", 547 | " \n", 548 | " \n", 549 | " \n", 550 | " \n", 551 | " \n", 552 | "
nDCG@3MAP@3Recall@3nDCG@5MAP@5Recall@5epochloss
00.0170320.0296920.0021780.0171220.0364620.0033620NaN
00.0742960.1155000.0052930.0751280.1256720.008741100.469893
00.2760360.3808770.0354440.2768840.3954980.060833200.281246
\n", 553 | "
" 554 | ], 555 | "text/plain": [ 556 | " nDCG@3 MAP@3 Recall@3 nDCG@5 MAP@5 Recall@5 epoch loss\n", 557 | "0 0.017032 0.029692 0.002178 0.017122 0.036462 0.003362 0 NaN\n", 558 | "0 0.074296 0.115500 0.005293 0.075128 0.125672 0.008741 10 0.469893\n", 559 | "0 0.276036 0.380877 0.035444 0.276884 0.395498 0.060833 20 0.281246" 560 | ] 561 | }, 562 | "metadata": {}, 563 | "execution_count": 11 564 | } 565 | ], 566 | "metadata": {} 567 | }, 568 | { 569 | "cell_type": "markdown", 570 | "source": [ 571 | "## Two Stage" 572 | ], 573 | "metadata": {} 574 | }, 575 | { 576 | "cell_type": "code", 577 | "execution_count": 3, 578 | "source": [ 579 | "item_count = train.groupby(\"item_id\")[\"user_id\"].count()\n", 580 | "count_index = np.array(item_count.index)\n", 581 | "neg_weight = np.zeros(n_item)\n", 582 | "neg_weight[count_index] = item_count ** 0.1" 583 | ], 584 | "outputs": [], 585 | "metadata": {} 586 | }, 587 | { 588 | "cell_type": "code", 589 | "execution_count": 4, 590 | "source": [ 591 | "lr = 1e-3\n", 592 | "n_dim = 10\n", 593 | "model = models.CollaborativeMetricLearning(n_user, n_item, n_dim).to(device)\n", 594 | "optimizer = optim.Adam(model.parameters(), lr=lr)\n", 595 | "\n", 596 | "regs = [regularizers.GlobalOrthogonalRegularizer(weight=1e-3)]\n", 597 | "criterion = losses.MinTripletLoss(margin=1, regularizers=regs).to(device)\n", 598 | "sampler = samplers.TwoStageSampler(\n", 599 | " train_set, n_user, n_item, \n", 600 | " neg_weight=neg_weight, n_neg_samples=5,\n", 601 | " device=device, strict_negative=False\n", 602 | ")\n", 603 | "\n", 604 | "score_function_dict = {\n", 605 | " \"nDCG\" : evaluators.ndcg,\n", 606 | " \"MAP\" : evaluators.average_precision,\n", 607 | " \"Recall\": evaluators.recall\n", 608 | "}\n", 609 | "evaluator = evaluators.UserwiseEvaluator(test_set, score_function_dict, ks=[3,5])\n", 610 | "trainer = trainers.BaseTrainer(model, optimizer, criterion, sampler)" 611 | ], 612 | "outputs": [], 613 | "metadata": {} 614 | }, 615 | { 616 | "cell_type": "code", 617 | "execution_count": 5, 618 | "source": [ 619 | "trainer.fit(n_batch=256, n_epoch=20, valid_evaluator = evaluator, valid_per_epoch=10)" 620 | ], 621 | "outputs": [ 622 | { 623 | "output_type": "stream", 624 | "name": "stderr", 625 | "text": [ 626 | "100%|██████████| 943/943 [00:27<00:00, 34.76it/s]\n", 627 | "epoch1 avg_loss:1.495: 100%|██████████| 256/256 [00:08<00:00, 31.49it/s]\n", 628 | "epoch2 avg_loss:1.321: 100%|██████████| 256/256 [00:07<00:00, 36.29it/s]\n", 629 | "epoch3 avg_loss:1.207: 100%|██████████| 256/256 [00:06<00:00, 37.32it/s]\n", 630 | "epoch4 avg_loss:1.144: 100%|██████████| 256/256 [00:16<00:00, 15.77it/s]\n", 631 | "epoch5 avg_loss:1.108: 100%|██████████| 256/256 [00:11<00:00, 22.04it/s]\n", 632 | "epoch6 avg_loss:1.084: 100%|██████████| 256/256 [00:12<00:00, 20.87it/s]\n", 633 | "epoch7 avg_loss:1.074: 100%|██████████| 256/256 [00:08<00:00, 28.55it/s]\n", 634 | "epoch8 avg_loss:1.060: 100%|██████████| 256/256 [00:06<00:00, 38.29it/s]\n", 635 | "epoch9 avg_loss:1.050: 100%|██████████| 256/256 [00:06<00:00, 37.60it/s]\n", 636 | "epoch10 avg_loss:1.044: 100%|██████████| 256/256 [00:05<00:00, 43.88it/s]\n", 637 | "100%|██████████| 943/943 [00:22<00:00, 42.64it/s]\n", 638 | "epoch11 avg_loss:1.036: 100%|██████████| 256/256 [00:06<00:00, 40.12it/s]\n", 639 | "epoch12 avg_loss:1.030: 100%|██████████| 256/256 [00:06<00:00, 39.84it/s]\n", 640 | "epoch13 avg_loss:1.028: 100%|██████████| 256/256 [00:08<00:00, 30.95it/s]\n", 641 | "epoch14 avg_loss:1.024: 100%|██████████| 256/256 [00:06<00:00, 41.29it/s]\n", 642 | "epoch15 avg_loss:1.020: 100%|██████████| 256/256 [00:06<00:00, 36.79it/s]\n", 643 | "epoch16 avg_loss:1.018: 100%|██████████| 256/256 [00:06<00:00, 38.61it/s]\n", 644 | "epoch17 avg_loss:1.014: 100%|██████████| 256/256 [00:06<00:00, 38.05it/s]\n", 645 | "epoch18 avg_loss:1.011: 100%|██████████| 256/256 [00:08<00:00, 30.10it/s]\n", 646 | "epoch19 avg_loss:1.008: 100%|██████████| 256/256 [00:06<00:00, 39.79it/s]\n", 647 | "epoch20 avg_loss:1.001: 100%|██████████| 256/256 [00:06<00:00, 39.21it/s]\n", 648 | "100%|██████████| 943/943 [00:22<00:00, 41.77it/s]\n" 649 | ] 650 | } 651 | ], 652 | "metadata": {} 653 | }, 654 | { 655 | "cell_type": "code", 656 | "execution_count": 6, 657 | "source": [ 658 | "trainer.valid_scores" 659 | ], 660 | "outputs": [ 661 | { 662 | "output_type": "execute_result", 663 | "data": { 664 | "text/html": [ 665 | "
\n", 666 | "\n", 679 | "\n", 680 | " \n", 681 | " \n", 682 | " \n", 683 | " \n", 684 | " \n", 685 | " \n", 686 | " \n", 687 | " \n", 688 | " \n", 689 | " \n", 690 | " \n", 691 | " \n", 692 | " \n", 693 | " \n", 694 | " \n", 695 | " \n", 696 | " \n", 697 | " \n", 698 | " \n", 699 | " \n", 700 | " \n", 701 | " \n", 702 | " \n", 703 | " \n", 704 | " \n", 705 | " \n", 706 | " \n", 707 | " \n", 708 | " \n", 709 | " \n", 710 | " \n", 711 | " \n", 712 | " \n", 713 | " \n", 714 | " \n", 715 | " \n", 716 | " \n", 717 | " \n", 718 | " \n", 719 | " \n", 720 | " \n", 721 | " \n", 722 | " \n", 723 | " \n", 724 | " \n", 725 | " \n", 726 | " \n", 727 | " \n", 728 | "
nDCG@3MAP@3Recall@3nDCG@5MAP@5Recall@5epochloss
00.0129860.0220040.0011670.0132780.0281900.0025820NaN
00.2071660.2936550.0198970.2000640.3057680.033076101.043901
00.3565460.4840930.0525730.3260330.4844090.074481201.001474
\n", 729 | "
" 730 | ], 731 | "text/plain": [ 732 | " nDCG@3 MAP@3 Recall@3 nDCG@5 MAP@5 Recall@5 epoch loss\n", 733 | "0 0.012986 0.022004 0.001167 0.013278 0.028190 0.002582 0 NaN\n", 734 | "0 0.207166 0.293655 0.019897 0.200064 0.305768 0.033076 10 1.043901\n", 735 | "0 0.356546 0.484093 0.052573 0.326033 0.484409 0.074481 20 1.001474" 736 | ] 737 | }, 738 | "metadata": {}, 739 | "execution_count": 6 740 | } 741 | ], 742 | "metadata": {} 743 | }, 744 | { 745 | "cell_type": "markdown", 746 | "source": [ 747 | "## model weighted negative sampler" 748 | ], 749 | "metadata": {} 750 | }, 751 | { 752 | "cell_type": "code", 753 | "execution_count": 4, 754 | "source": [ 755 | "def svd_init(X, dim):\n", 756 | " \"\"\"\n", 757 | " Args :\n", 758 | " X : csr_matrix which element is 0 or 1.\n", 759 | " dim : number of dimention\n", 760 | " \"\"\"\n", 761 | " svd = TruncatedSVD(n_components=10)\n", 762 | " U_ = svd.fit_transform(X)\n", 763 | " V_ = svd.components_\n", 764 | "\n", 765 | " s = (U_.sum(axis=1).mean() + V_.sum(axis=0).mean()) / 2\n", 766 | " U = 2 ** 0.5 * U_ - (1 / n_dim) ** 0.5 * s * np.ones_like(U_)\n", 767 | " V = 2 ** 0.5 * V_ + (1 / n_dim) ** 0.5 / s * np.ones_like(V_)\n", 768 | " ub = -(2 / n_dim) ** 0.5 * U_.sum(axis=1) / s\n", 769 | " vb = (2 / n_dim) ** 0.5 * V_.sum(axis=0) * s\n", 770 | "\n", 771 | " return U, V, ub, vb" 772 | ], 773 | "outputs": [], 774 | "metadata": {} 775 | }, 776 | { 777 | "cell_type": "code", 778 | "execution_count": 5, 779 | "source": [ 780 | "n_dim = 10\n", 781 | "X = csr_matrix(\n", 782 | " (np.ones(train_set.shape[0]), (train_set[:,0], train_set[:,1])),\n", 783 | " shape=[n_user, n_item]\n", 784 | ")\n", 785 | "U, V, ub, vb = svd_init(X, n_dim)\n", 786 | "neg_weight_model = models.LogitMatrixFactorization(\n", 787 | " n_user, n_item, n_dim, max_norm=None,\n", 788 | " user_embedding_init = torch.Tensor(U), \n", 789 | " item_embedding_init = torch.Tensor(V.T),\n", 790 | " user_bias_init = torch.Tensor(ub), \n", 791 | " item_bias_init = torch.Tensor(vb)\n", 792 | ").to(device)\n", 793 | "neg_weight_model.link_weight = lambda x : 1 - torch.sigmoid(x)" 794 | ], 795 | "outputs": [], 796 | "metadata": {} 797 | }, 798 | { 799 | "cell_type": "code", 800 | "execution_count": 6, 801 | "source": [ 802 | "lr = 1e-3\n", 803 | "model = models.CollaborativeMetricLearning(n_user, n_item, n_dim).to(device)\n", 804 | "optimizer = optim.Adam(model.parameters(), lr=lr)\n", 805 | "criterion = losses.SumTripletLoss(margin=1).to(device)\n", 806 | "sampler = samplers.BaseSampler(\n", 807 | " train_set, n_user, n_item, \n", 808 | " neg_weight=neg_weight_model,\n", 809 | " device=device, strict_negative=False\n", 810 | ")\n", 811 | "\n", 812 | "score_function_dict = {\n", 813 | " \"nDCG\" : evaluators.ndcg,\n", 814 | " \"MAP\" : evaluators.average_precision,\n", 815 | " \"Recall\": evaluators.recall\n", 816 | "}\n", 817 | "evaluator = evaluators.UserwiseEvaluator(test_set, score_function_dict, ks=[3,5])\n", 818 | "trainer = trainers.BaseTrainer(model, optimizer, criterion, sampler)" 819 | ], 820 | "outputs": [], 821 | "metadata": {} 822 | }, 823 | { 824 | "cell_type": "code", 825 | "execution_count": 7, 826 | "source": [ 827 | "trainer.fit(n_batch=256, n_epoch=20, valid_evaluator = evaluator, valid_per_epoch=10)" 828 | ], 829 | "outputs": [ 830 | { 831 | "output_type": "stream", 832 | "name": "stderr", 833 | "text": [ 834 | "100%|██████████| 943/943 [00:16<00:00, 55.70it/s]\n", 835 | "epoch1 avg_loss:0.968: 100%|██████████| 256/256 [00:05<00:00, 44.73it/s]\n", 836 | "epoch2 avg_loss:0.846: 100%|██████████| 256/256 [00:05<00:00, 44.17it/s]\n", 837 | "epoch3 avg_loss:0.766: 100%|██████████| 256/256 [00:06<00:00, 36.72it/s]\n", 838 | "epoch4 avg_loss:0.718: 100%|██████████| 256/256 [00:06<00:00, 38.69it/s]\n", 839 | "epoch5 avg_loss:0.677: 100%|██████████| 256/256 [00:07<00:00, 34.02it/s]\n", 840 | "epoch6 avg_loss:0.650: 100%|██████████| 256/256 [00:06<00:00, 41.09it/s]\n", 841 | "epoch7 avg_loss:0.629: 100%|██████████| 256/256 [00:05<00:00, 46.11it/s]\n", 842 | "epoch8 avg_loss:0.610: 100%|██████████| 256/256 [00:05<00:00, 45.69it/s]\n", 843 | "epoch9 avg_loss:0.589: 100%|██████████| 256/256 [00:07<00:00, 34.75it/s]\n", 844 | "epoch10 avg_loss:0.572: 100%|██████████| 256/256 [00:07<00:00, 33.05it/s]\n", 845 | "100%|██████████| 943/943 [00:19<00:00, 47.84it/s]\n", 846 | "epoch11 avg_loss:0.555: 100%|██████████| 256/256 [00:07<00:00, 33.20it/s]\n", 847 | "epoch12 avg_loss:0.539: 100%|██████████| 256/256 [00:06<00:00, 39.77it/s]\n", 848 | "epoch13 avg_loss:0.521: 100%|██████████| 256/256 [00:06<00:00, 40.92it/s]\n", 849 | "epoch14 avg_loss:0.507: 100%|██████████| 256/256 [00:06<00:00, 41.77it/s]\n", 850 | "epoch15 avg_loss:0.489: 100%|██████████| 256/256 [00:06<00:00, 41.66it/s]\n", 851 | "epoch16 avg_loss:0.479: 100%|██████████| 256/256 [00:06<00:00, 40.17it/s]\n", 852 | "epoch17 avg_loss:0.466: 100%|██████████| 256/256 [00:05<00:00, 43.27it/s]\n", 853 | "epoch18 avg_loss:0.452: 100%|██████████| 256/256 [00:06<00:00, 38.08it/s]\n", 854 | "epoch19 avg_loss:0.440: 100%|██████████| 256/256 [00:07<00:00, 36.39it/s]\n", 855 | "epoch20 avg_loss:0.430: 100%|██████████| 256/256 [00:06<00:00, 38.09it/s]\n", 856 | "100%|██████████| 943/943 [00:20<00:00, 45.11it/s]\n" 857 | ] 858 | } 859 | ], 860 | "metadata": {} 861 | }, 862 | { 863 | "cell_type": "code", 864 | "execution_count": 8, 865 | "source": [ 866 | "trainer.valid_scores" 867 | ], 868 | "outputs": [ 869 | { 870 | "output_type": "execute_result", 871 | "data": { 872 | "text/html": [ 873 | "
\n", 874 | "\n", 887 | "\n", 888 | " \n", 889 | " \n", 890 | " \n", 891 | " \n", 892 | " \n", 893 | " \n", 894 | " \n", 895 | " \n", 896 | " \n", 897 | " \n", 898 | " \n", 899 | " \n", 900 | " \n", 901 | " \n", 902 | " \n", 903 | " \n", 904 | " \n", 905 | " \n", 906 | " \n", 907 | " \n", 908 | " \n", 909 | " \n", 910 | " \n", 911 | " \n", 912 | " \n", 913 | " \n", 914 | " \n", 915 | " \n", 916 | " \n", 917 | " \n", 918 | " \n", 919 | " \n", 920 | " \n", 921 | " \n", 922 | " \n", 923 | " \n", 924 | " \n", 925 | " \n", 926 | " \n", 927 | " \n", 928 | " \n", 929 | " \n", 930 | " \n", 931 | " \n", 932 | " \n", 933 | " \n", 934 | " \n", 935 | " \n", 936 | "
nDCG@3MAP@3Recall@3nDCG@5MAP@5Recall@5epochloss
00.0162090.0303990.0019550.016900.0382640.0034770NaN
00.0512920.0782080.0042850.055360.0948360.007510100.572125
00.2332680.3225520.0302320.234010.3362760.049536200.430135
\n", 937 | "
" 938 | ], 939 | "text/plain": [ 940 | " nDCG@3 MAP@3 Recall@3 nDCG@5 MAP@5 Recall@5 epoch loss\n", 941 | "0 0.016209 0.030399 0.001955 0.01690 0.038264 0.003477 0 NaN\n", 942 | "0 0.051292 0.078208 0.004285 0.05536 0.094836 0.007510 10 0.572125\n", 943 | "0 0.233268 0.322552 0.030232 0.23401 0.336276 0.049536 20 0.430135" 944 | ] 945 | }, 946 | "metadata": {}, 947 | "execution_count": 8 948 | } 949 | ], 950 | "metadata": {} 951 | }, 952 | { 953 | "cell_type": "code", 954 | "execution_count": null, 955 | "source": [], 956 | "outputs": [], 957 | "metadata": {} 958 | }, 959 | { 960 | "cell_type": "code", 961 | "execution_count": 14, 962 | "source": [], 963 | "outputs": [], 964 | "metadata": {} 965 | }, 966 | { 967 | "cell_type": "markdown", 968 | "source": [ 969 | "# Domain Adaptation" 970 | ], 971 | "metadata": {} 972 | }, 973 | { 974 | "cell_type": "code", 975 | "execution_count": 3, 976 | "source": [ 977 | "from PyTorchCML import adaptors" 978 | ], 979 | "outputs": [], 980 | "metadata": {} 981 | }, 982 | { 983 | "cell_type": "code", 984 | "execution_count": 4, 985 | "source": [ 986 | "df_item = pd.read_csv('http://files.grouplens.org/datasets/movielens/ml-100k/u.item' , sep=\"|\", header=None, encoding='latin-1')\n", 987 | "item_feature = df_item.iloc[:, -19:]\n", 988 | "item_feature_torch = torch.Tensor(item_feature.values)" 989 | ], 990 | "outputs": [], 991 | "metadata": {} 992 | }, 993 | { 994 | "cell_type": "code", 995 | "execution_count": 6, 996 | "source": [ 997 | "item_feature" 998 | ], 999 | "outputs": [ 1000 | { 1001 | "output_type": "execute_result", 1002 | "data": { 1003 | "text/html": [ 1004 | "
\n", 1005 | "\n", 1018 | "\n", 1019 | " \n", 1020 | " \n", 1021 | " \n", 1022 | " \n", 1023 | " \n", 1024 | " \n", 1025 | " \n", 1026 | " \n", 1027 | " \n", 1028 | " \n", 1029 | " \n", 1030 | " \n", 1031 | " \n", 1032 | " \n", 1033 | " \n", 1034 | " \n", 1035 | " \n", 1036 | " \n", 1037 | " \n", 1038 | " \n", 1039 | " \n", 1040 | " \n", 1041 | " \n", 1042 | " \n", 1043 | " \n", 1044 | " \n", 1045 | " \n", 1046 | " \n", 1047 | " \n", 1048 | " \n", 1049 | " \n", 1050 | " \n", 1051 | " \n", 1052 | " \n", 1053 | " \n", 1054 | " \n", 1055 | " \n", 1056 | " \n", 1057 | " \n", 1058 | " \n", 1059 | " \n", 1060 | " \n", 1061 | " \n", 1062 | " \n", 1063 | " \n", 1064 | " \n", 1065 | " \n", 1066 | " \n", 1067 | " \n", 1068 | " \n", 1069 | " \n", 1070 | " \n", 1071 | " \n", 1072 | " \n", 1073 | " \n", 1074 | " \n", 1075 | " \n", 1076 | " \n", 1077 | " \n", 1078 | " \n", 1079 | " \n", 1080 | " \n", 1081 | " \n", 1082 | " \n", 1083 | " \n", 1084 | " \n", 1085 | " \n", 1086 | " \n", 1087 | " \n", 1088 | " \n", 1089 | " \n", 1090 | " \n", 1091 | " \n", 1092 | " \n", 1093 | " \n", 1094 | " \n", 1095 | " \n", 1096 | " \n", 1097 | " \n", 1098 | " \n", 1099 | " \n", 1100 | " \n", 1101 | " \n", 1102 | " \n", 1103 | " \n", 1104 | " \n", 1105 | " \n", 1106 | " \n", 1107 | " \n", 1108 | " \n", 1109 | " \n", 1110 | " \n", 1111 | " \n", 1112 | " \n", 1113 | " \n", 1114 | " \n", 1115 | " \n", 1116 | " \n", 1117 | " \n", 1118 | " \n", 1119 | " \n", 1120 | " \n", 1121 | " \n", 1122 | " \n", 1123 | " \n", 1124 | " \n", 1125 | " \n", 1126 | " \n", 1127 | " \n", 1128 | " \n", 1129 | " \n", 1130 | " \n", 1131 | " \n", 1132 | " \n", 1133 | " \n", 1134 | " \n", 1135 | " \n", 1136 | " \n", 1137 | " \n", 1138 | " \n", 1139 | " \n", 1140 | " \n", 1141 | " \n", 1142 | " \n", 1143 | " \n", 1144 | " \n", 1145 | " \n", 1146 | " \n", 1147 | " \n", 1148 | " \n", 1149 | " \n", 1150 | " \n", 1151 | " \n", 1152 | " \n", 1153 | " \n", 1154 | " \n", 1155 | " \n", 1156 | " \n", 1157 | " \n", 1158 | " \n", 1159 | " \n", 1160 | " \n", 1161 | " \n", 1162 | " \n", 1163 | " \n", 1164 | " \n", 1165 | " \n", 1166 | " \n", 1167 | " \n", 1168 | " \n", 1169 | " \n", 1170 | " \n", 1171 | " \n", 1172 | " \n", 1173 | " \n", 1174 | " \n", 1175 | " \n", 1176 | " \n", 1177 | " \n", 1178 | " \n", 1179 | " \n", 1180 | " \n", 1181 | " \n", 1182 | " \n", 1183 | " \n", 1184 | " \n", 1185 | " \n", 1186 | " \n", 1187 | " \n", 1188 | " \n", 1189 | " \n", 1190 | " \n", 1191 | " \n", 1192 | " \n", 1193 | " \n", 1194 | " \n", 1195 | " \n", 1196 | " \n", 1197 | " \n", 1198 | " \n", 1199 | " \n", 1200 | " \n", 1201 | " \n", 1202 | " \n", 1203 | " \n", 1204 | " \n", 1205 | " \n", 1206 | " \n", 1207 | " \n", 1208 | " \n", 1209 | " \n", 1210 | " \n", 1211 | " \n", 1212 | " \n", 1213 | " \n", 1214 | " \n", 1215 | " \n", 1216 | " \n", 1217 | " \n", 1218 | " \n", 1219 | " \n", 1220 | " \n", 1221 | " \n", 1222 | " \n", 1223 | " \n", 1224 | " \n", 1225 | " \n", 1226 | " \n", 1227 | " \n", 1228 | " \n", 1229 | " \n", 1230 | " \n", 1231 | " \n", 1232 | " \n", 1233 | " \n", 1234 | " \n", 1235 | " \n", 1236 | " \n", 1237 | " \n", 1238 | " \n", 1239 | " \n", 1240 | " \n", 1241 | " \n", 1242 | " \n", 1243 | " \n", 1244 | " \n", 1245 | " \n", 1246 | " \n", 1247 | " \n", 1248 | " \n", 1249 | " \n", 1250 | " \n", 1251 | " \n", 1252 | " \n", 1253 | " \n", 1254 | " \n", 1255 | " \n", 1256 | " \n", 1257 | " \n", 1258 | " \n", 1259 | " \n", 1260 | " \n", 1261 | " \n", 1262 | " \n", 1263 | " \n", 1264 | " \n", 1265 | " \n", 1266 | " \n", 1267 | " \n", 1268 | " \n", 1269 | " \n", 1270 | " \n", 1271 | " \n", 1272 | " \n", 1273 | " \n", 1274 | " \n", 1275 | " \n", 1276 | " \n", 1277 | " \n", 1278 | " \n", 1279 | " \n", 1280 | " \n", 1281 | " \n", 1282 | " \n", 1283 | " \n", 1284 | " \n", 1285 | " \n", 1286 | " \n", 1287 | "
567891011121314151617181920212223
00001110000000000000
10110000000000000100
20000000000000000100
30100010010000000000
40000001010000000100
............................................................
16770000000010000000000
16780000000000000010100
16790000000010000010000
16800000010000000000000
16810000000010000000000
\n", 1288 | "

1682 rows × 19 columns

\n", 1289 | "
" 1290 | ], 1291 | "text/plain": [ 1292 | " 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 \\\n", 1293 | "0 0 0 0 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 \n", 1294 | "1 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 \n", 1295 | "2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 \n", 1296 | "3 0 1 0 0 0 1 0 0 1 0 0 0 0 0 0 0 0 0 \n", 1297 | "4 0 0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 1 0 \n", 1298 | "... .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. \n", 1299 | "1677 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 \n", 1300 | "1678 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 0 \n", 1301 | "1679 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 \n", 1302 | "1680 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 \n", 1303 | "1681 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 \n", 1304 | "\n", 1305 | " 23 \n", 1306 | "0 0 \n", 1307 | "1 0 \n", 1308 | "2 0 \n", 1309 | "3 0 \n", 1310 | "4 0 \n", 1311 | "... .. \n", 1312 | "1677 0 \n", 1313 | "1678 0 \n", 1314 | "1679 0 \n", 1315 | "1680 0 \n", 1316 | "1681 0 \n", 1317 | "\n", 1318 | "[1682 rows x 19 columns]" 1319 | ] 1320 | }, 1321 | "metadata": {}, 1322 | "execution_count": 6 1323 | } 1324 | ], 1325 | "metadata": {} 1326 | }, 1327 | { 1328 | "cell_type": "code", 1329 | "execution_count": 6, 1330 | "source": [ 1331 | "lr = 1e-3\n", 1332 | "n_dim = 10\n", 1333 | "adaptor = adaptors.MLPAdaptor(item_feature_torch, n_dim, [20], 1e-4)\n", 1334 | "model = models.CollaborativeMetricLearning(n_user, n_item, n_dim, item_adaptor=adaptor).to(device)\n", 1335 | "optimizer = optim.Adam(model.parameters(), lr=lr)\n", 1336 | "criterion = losses.SumTripletLoss(margin=1).to(device)\n", 1337 | "sampler = samplers.BaseSampler(train_set, n_user, n_item, device=device, strict_negative=False)\n", 1338 | "\n", 1339 | "score_function_dict = {\n", 1340 | " \"nDCG\" : evaluators.ndcg,\n", 1341 | " \"MAP\" : evaluators.average_precision,\n", 1342 | " \"Recall\": evaluators.recall\n", 1343 | "}\n", 1344 | "evaluator = evaluators.UserwiseEvaluator(test_set, score_function_dict, ks=[3,5])\n", 1345 | "trainer = trainers.BaseTrainer(model, optimizer, criterion, sampler)\n" 1346 | ], 1347 | "outputs": [], 1348 | "metadata": {} 1349 | }, 1350 | { 1351 | "cell_type": "code", 1352 | "execution_count": 7, 1353 | "source": [ 1354 | "trainer.fit(n_batch=256, n_epoch=20, valid_evaluator = evaluator, valid_per_epoch=10)" 1355 | ], 1356 | "outputs": [ 1357 | { 1358 | "output_type": "stream", 1359 | "name": "stderr", 1360 | "text": [ 1361 | "100%|██████████| 943/943 [00:18<00:00, 51.76it/s]\n", 1362 | "epoch1 avg_loss:1.192: 100%|██████████| 256/256 [00:05<00:00, 50.56it/s]\n", 1363 | "epoch2 avg_loss:0.992: 100%|██████████| 256/256 [00:05<00:00, 50.57it/s]\n", 1364 | "epoch3 avg_loss:0.877: 100%|██████████| 256/256 [00:05<00:00, 50.00it/s]\n", 1365 | "epoch4 avg_loss:0.804: 100%|██████████| 256/256 [00:04<00:00, 51.48it/s]\n", 1366 | "epoch5 avg_loss:0.750: 100%|██████████| 256/256 [00:05<00:00, 48.38it/s]\n", 1367 | "epoch6 avg_loss:0.707: 100%|██████████| 256/256 [00:05<00:00, 49.85it/s]\n", 1368 | "epoch7 avg_loss:0.669: 100%|██████████| 256/256 [00:04<00:00, 52.49it/s]\n", 1369 | "epoch8 avg_loss:0.633: 100%|██████████| 256/256 [00:05<00:00, 48.15it/s]\n", 1370 | "epoch9 avg_loss:0.604: 100%|██████████| 256/256 [00:07<00:00, 32.41it/s]\n", 1371 | "epoch10 avg_loss:0.575: 100%|██████████| 256/256 [00:07<00:00, 32.39it/s]\n", 1372 | "100%|██████████| 943/943 [00:21<00:00, 44.52it/s]\n", 1373 | "epoch11 avg_loss:0.552: 100%|██████████| 256/256 [00:05<00:00, 47.47it/s]\n", 1374 | "epoch12 avg_loss:0.525: 100%|██████████| 256/256 [00:05<00:00, 46.07it/s]\n", 1375 | "epoch13 avg_loss:0.506: 100%|██████████| 256/256 [00:05<00:00, 45.56it/s]\n", 1376 | "epoch14 avg_loss:0.482: 100%|██████████| 256/256 [00:05<00:00, 46.49it/s]\n", 1377 | "epoch15 avg_loss:0.462: 100%|██████████| 256/256 [00:05<00:00, 44.14it/s]\n", 1378 | "epoch16 avg_loss:0.448: 100%|██████████| 256/256 [00:06<00:00, 37.59it/s]\n", 1379 | "epoch17 avg_loss:0.433: 100%|██████████| 256/256 [00:07<00:00, 35.14it/s]\n", 1380 | "epoch18 avg_loss:0.421: 100%|██████████| 256/256 [00:06<00:00, 37.13it/s]\n", 1381 | "epoch19 avg_loss:0.412: 100%|██████████| 256/256 [00:06<00:00, 39.32it/s]\n", 1382 | "epoch20 avg_loss:0.401: 100%|██████████| 256/256 [00:06<00:00, 39.88it/s]\n", 1383 | "100%|██████████| 943/943 [00:21<00:00, 43.02it/s]\n" 1384 | ] 1385 | } 1386 | ], 1387 | "metadata": {} 1388 | }, 1389 | { 1390 | "cell_type": "code", 1391 | "execution_count": 8, 1392 | "source": [ 1393 | "trainer.valid_scores" 1394 | ], 1395 | "outputs": [ 1396 | { 1397 | "output_type": "execute_result", 1398 | "data": { 1399 | "text/html": [ 1400 | "
\n", 1401 | "\n", 1414 | "\n", 1415 | " \n", 1416 | " \n", 1417 | " \n", 1418 | " \n", 1419 | " \n", 1420 | " \n", 1421 | " \n", 1422 | " \n", 1423 | " \n", 1424 | " \n", 1425 | " \n", 1426 | " \n", 1427 | " \n", 1428 | " \n", 1429 | " \n", 1430 | " \n", 1431 | " \n", 1432 | " \n", 1433 | " \n", 1434 | " \n", 1435 | " \n", 1436 | " \n", 1437 | " \n", 1438 | " \n", 1439 | " \n", 1440 | " \n", 1441 | " \n", 1442 | " \n", 1443 | " \n", 1444 | " \n", 1445 | " \n", 1446 | " \n", 1447 | " \n", 1448 | " \n", 1449 | " \n", 1450 | " \n", 1451 | " \n", 1452 | " \n", 1453 | " \n", 1454 | " \n", 1455 | " \n", 1456 | " \n", 1457 | " \n", 1458 | " \n", 1459 | " \n", 1460 | " \n", 1461 | " \n", 1462 | " \n", 1463 | "
nDCG@3MAP@3Recall@3nDCG@5MAP@5Recall@5epochloss
00.0101190.0178510.0015630.0099280.0219070.0024350NaN
00.1021170.1451040.0131610.1050930.1601520.023478100.574888
00.2433470.3351010.0545780.2340800.3443720.080134200.400818
\n", 1464 | "
" 1465 | ], 1466 | "text/plain": [ 1467 | " nDCG@3 MAP@3 Recall@3 nDCG@5 MAP@5 Recall@5 epoch loss\n", 1468 | "0 0.010119 0.017851 0.001563 0.009928 0.021907 0.002435 0 NaN\n", 1469 | "0 0.102117 0.145104 0.013161 0.105093 0.160152 0.023478 10 0.574888\n", 1470 | "0 0.243347 0.335101 0.054578 0.234080 0.344372 0.080134 20 0.400818" 1471 | ] 1472 | }, 1473 | "metadata": {}, 1474 | "execution_count": 8 1475 | } 1476 | ], 1477 | "metadata": {} 1478 | }, 1479 | { 1480 | "cell_type": "code", 1481 | "execution_count": null, 1482 | "source": [], 1483 | "outputs": [], 1484 | "metadata": {} 1485 | } 1486 | ], 1487 | "metadata": { 1488 | "kernelspec": { 1489 | "name": "python3", 1490 | "display_name": "Python 3.8.6 64-bit ('pytorchcml-MJCCLiEQ-py3.8': poetry)" 1491 | }, 1492 | "language_info": { 1493 | "codemirror_mode": { 1494 | "name": "ipython", 1495 | "version": 3 1496 | }, 1497 | "file_extension": ".py", 1498 | "mimetype": "text/x-python", 1499 | "name": "python", 1500 | "nbconvert_exporter": "python", 1501 | "pygments_lexer": "ipython3", 1502 | "version": "3.8.6" 1503 | }, 1504 | "interpreter": { 1505 | "hash": "1a6e8c4c71356cfd7f7f45384d81183fdca12e98ad893ee020bd76249bbd6be9" 1506 | } 1507 | }, 1508 | "nbformat": 4, 1509 | "nbformat_minor": 4 1510 | } -------------------------------------------------------------------------------- /examples/notebooks/movielens_mf.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "# !pip install PyTorchCML" 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": 1, 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import sys\n", 19 | "sys.path.append(\"../../\")\n", 20 | "\n", 21 | "from itertools import product\n", 22 | "\n", 23 | "from PyTorchCML import losses, models, samplers, evaluators, trainers\n", 24 | "import torch\n", 25 | "from torch import nn, optim\n", 26 | "import numpy as np\n", 27 | "import pandas as pd\n", 28 | "from sklearn.model_selection import train_test_split\n", 29 | "from sklearn.decomposition import TruncatedSVD\n", 30 | "from scipy.sparse import csr_matrix" 31 | ] 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": 2, 36 | "metadata": {}, 37 | "outputs": [], 38 | "source": [ 39 | "def svd_init(X, dim):\n", 40 | " \"\"\"\n", 41 | " Args :\n", 42 | " X : csr_matrix which element is 0 or 1.\n", 43 | " dim : number of dimention\n", 44 | " \"\"\"\n", 45 | " svd = TruncatedSVD(n_components=10)\n", 46 | " U_ = svd.fit_transform(X)\n", 47 | " V_ = svd.components_\n", 48 | "\n", 49 | " s = (U_.sum(axis=1).mean() + V_.sum(axis=0).mean()) / 2\n", 50 | " U = 2 ** 0.5 * U_ - (1 / n_dim) ** 0.5 * s * np.ones_like(U_)\n", 51 | " V = 2 ** 0.5 * V_ + (1 / n_dim) ** 0.5 / s * np.ones_like(V_)\n", 52 | " ub = -(2 / n_dim) ** 0.5 * U_.sum(axis=1) / s\n", 53 | " vb = (2 / n_dim) ** 0.5 * V_.sum(axis=0) * s\n", 54 | "\n", 55 | " return U, V, ub, vb" 56 | ] 57 | }, 58 | { 59 | "cell_type": "code", 60 | "execution_count": 3, 61 | "metadata": {}, 62 | "outputs": [], 63 | "source": [ 64 | "# download movielens dataset\n", 65 | "movielens = pd.read_csv(\n", 66 | " 'http://files.grouplens.org/datasets/movielens/ml-100k/u.data', \n", 67 | " sep='\\t', header=None, index_col=None,\n", 68 | " names = [\"user_id\", \"item_id\", \"rating\", \"timestamp\"]\n", 69 | ")\n", 70 | "# Set user/item id and number of users/items.\n", 71 | "movielens.user_id -= 1\n", 72 | "movielens.item_id -= 1\n", 73 | "n_user = movielens.user_id.nunique()\n", 74 | "n_item = movielens.item_id.nunique()\n", 75 | "\n", 76 | "# make implicit feedback\n", 77 | "movielens.rating = (movielens.rating >= 4).astype(int)\n", 78 | "\n", 79 | "\n", 80 | "# train test split\n", 81 | "train, test = train_test_split(movielens)\n", 82 | "\n", 83 | "# all user item pairs\n", 84 | "df_all = pd.DataFrame(\n", 85 | " [[u, i] for u,i in product(range(n_user), range(n_item))],\n", 86 | " columns=[\"user_id\", \"item_id\"]\n", 87 | ")\n", 88 | "\n", 89 | "# frag train pairs\n", 90 | "df_all = pd.merge(\n", 91 | " df_all, \n", 92 | " train[[\"user_id\", \"item_id\", \"rating\"]], \n", 93 | " on=[\"user_id\", \"item_id\"], \n", 94 | " how=\"left\"\n", 95 | ")\n", 96 | "\n", 97 | "# remove train pairs\n", 98 | "test = pd.merge(\n", 99 | " df_all[df_all.rating.isna()][[\"user_id\", \"item_id\"]], \n", 100 | " test[[\"user_id\", \"item_id\", \"rating\"]], \n", 101 | " on=[\"user_id\", \"item_id\"], \n", 102 | " how=\"left\"\n", 103 | ").fillna(0)\n", 104 | "\n", 105 | "# numpy array\n", 106 | "train_set = train[train.rating == 1][[\"user_id\", \"item_id\"]].values\n", 107 | "test_set = test[[\"user_id\", \"item_id\", \"rating\"]].values\n", 108 | "\n", 109 | "# to torch.Tensor\n", 110 | "device = torch.device(\"cuda:0\" if torch.cuda.is_available() else \"cpu\")\n", 111 | "train_set = torch.LongTensor(train_set).to(device)\n", 112 | "test_set = torch.LongTensor(test_set).to(device)\n" 113 | ] 114 | }, 115 | { 116 | "cell_type": "code", 117 | "execution_count": 4, 118 | "metadata": {}, 119 | "outputs": [], 120 | "source": [ 121 | "n_dim = 10\n", 122 | "X = csr_matrix(\n", 123 | " (np.ones(train_set.shape[0]), (train_set[:,0], train_set[:,1])),\n", 124 | " shape=[n_user, n_item]\n", 125 | ")\n", 126 | "U, V, ub, vb = svd_init(X, n_dim)" 127 | ] 128 | }, 129 | { 130 | "source": [ 131 | "# Naive MF" 132 | ], 133 | "cell_type": "markdown", 134 | "metadata": {} 135 | }, 136 | { 137 | "cell_type": "code", 138 | "execution_count": 5, 139 | "metadata": {}, 140 | "outputs": [], 141 | "source": [ 142 | "device = torch.device(\"cuda:0\" if torch.cuda.is_available() else \"cpu\")\n", 143 | "lr = 1e-3\n", 144 | "n_dim = 10\n", 145 | "model = models.LogitMatrixFactorization(\n", 146 | " n_user, n_item, n_dim, max_norm=5,max_bias=3,\n", 147 | " user_embedding_init = torch.Tensor(U), \n", 148 | " item_embedding_init = torch.Tensor(V.T),\n", 149 | " user_bias_init = torch.Tensor(ub), \n", 150 | " item_bias_init = torch.Tensor(vb)\n", 151 | ").to(device)\n", 152 | "\n", 153 | "optimizer = optim.Adam(model.parameters(), lr=lr)\n", 154 | "criterion = losses.LogitPairwiseLoss().to(device)\n", 155 | "sampler = samplers.BaseSampler(train_set, n_user, n_item, device=device,n_neg_samples=5, batch_size=1024)\n", 156 | "\n", 157 | "score_function_dict = {\n", 158 | " \"nDCG\" : evaluators.ndcg,\n", 159 | " \"MAP\" : evaluators.average_precision,\n", 160 | " \"Recall\": evaluators.recall\n", 161 | "}\n", 162 | "evaluator = evaluators.UserwiseEvaluator(torch.LongTensor(test_set).to(device), score_function_dict, ks=[3])\n", 163 | "trainer = trainers.BaseTrainer(model, optimizer, criterion, sampler)\n" 164 | ] 165 | }, 166 | { 167 | "cell_type": "code", 168 | "execution_count": 6, 169 | "metadata": {}, 170 | "outputs": [ 171 | { 172 | "output_type": "stream", 173 | "name": "stderr", 174 | "text": [ 175 | "100%|██████████| 943/943 [00:20<00:00, 46.16it/s]\n", 176 | "epoch1 avg_loss:451.035: 100%|██████████| 50/50 [00:03<00:00, 14.38it/s]\n", 177 | "epoch2 avg_loss:351.008: 100%|██████████| 50/50 [00:04<00:00, 12.41it/s]\n", 178 | "epoch3 avg_loss:277.473: 100%|██████████| 50/50 [00:02<00:00, 18.52it/s]\n", 179 | "epoch4 avg_loss:223.029: 100%|██████████| 50/50 [00:03<00:00, 15.06it/s]\n", 180 | "epoch5 avg_loss:180.195: 100%|██████████| 50/50 [00:03<00:00, 12.74it/s]\n", 181 | "100%|██████████| 943/943 [00:28<00:00, 33.23it/s]\n", 182 | "epoch6 avg_loss:147.858: 100%|██████████| 50/50 [00:05<00:00, 9.89it/s]\n", 183 | "epoch7 avg_loss:124.041: 100%|██████████| 50/50 [00:04<00:00, 12.10it/s]\n", 184 | "epoch8 avg_loss:105.032: 100%|██████████| 50/50 [00:04<00:00, 11.43it/s]\n", 185 | "epoch9 avg_loss:90.234: 100%|██████████| 50/50 [00:04<00:00, 12.21it/s]\n", 186 | "epoch10 avg_loss:77.848: 100%|██████████| 50/50 [00:05<00:00, 8.84it/s]\n", 187 | "100%|██████████| 943/943 [00:27<00:00, 34.08it/s]\n", 188 | "epoch11 avg_loss:67.927: 100%|██████████| 50/50 [00:05<00:00, 9.05it/s]\n", 189 | "epoch12 avg_loss:60.175: 100%|██████████| 50/50 [00:05<00:00, 8.78it/s]\n", 190 | "epoch13 avg_loss:52.815: 100%|██████████| 50/50 [00:07<00:00, 6.81it/s]\n", 191 | "epoch14 avg_loss:47.541: 100%|██████████| 50/50 [00:04<00:00, 10.87it/s]\n", 192 | "epoch15 avg_loss:42.113: 100%|██████████| 50/50 [00:05<00:00, 9.65it/s]\n", 193 | "100%|██████████| 943/943 [00:24<00:00, 38.80it/s]\n" 194 | ] 195 | } 196 | ], 197 | "source": [ 198 | "trainer.fit(n_batch=50, n_epoch=15, valid_evaluator = evaluator, valid_per_epoch=5)" 199 | ] 200 | }, 201 | { 202 | "cell_type": "code", 203 | "execution_count": 7, 204 | "metadata": {}, 205 | "outputs": [ 206 | { 207 | "output_type": "execute_result", 208 | "data": { 209 | "text/plain": [ 210 | " nDCG@3 MAP@3 Recall@3 epoch loss\n", 211 | "0 0.395128 0.529074 0.109667 0 NaN\n", 212 | "0 0.385569 0.518293 0.106805 5 180.194864\n", 213 | "0 0.370207 0.504065 0.102159 10 77.848219\n", 214 | "0 0.356891 0.487186 0.097600 15 42.113500" 215 | ], 216 | "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
nDCG@3MAP@3Recall@3epochloss
00.3951280.5290740.1096670NaN
00.3855690.5182930.1068055180.194864
00.3702070.5040650.1021591077.848219
00.3568910.4871860.0976001542.113500
\n
" 217 | }, 218 | "metadata": {}, 219 | "execution_count": 7 220 | } 221 | ], 222 | "source": [ 223 | "trainer.valid_scores" 224 | ] 225 | }, 226 | { 227 | "source": [ 228 | "# RelMF" 229 | ], 230 | "cell_type": "markdown", 231 | "metadata": {} 232 | }, 233 | { 234 | "cell_type": "code", 235 | "execution_count": 8, 236 | "metadata": {}, 237 | "outputs": [], 238 | "source": [ 239 | "train[\"popularity\"] = train.groupby(\"item_id\").rating.transform(sum)\n", 240 | "train[\"pscore\"] = 1 / (train.popularity / train.popularity.max()) ** 0.5" 241 | ] 242 | }, 243 | { 244 | "cell_type": "code", 245 | "execution_count": 12, 246 | "metadata": {}, 247 | "outputs": [], 248 | "source": [ 249 | "device = torch.device(\"cuda:0\" if torch.cuda.is_available() else \"cpu\")\n", 250 | "lr = 1e-3\n", 251 | "n_dim = 10\n", 252 | "\n", 253 | "train_set = train[train.rating == 1][[\"user_id\", \"item_id\", \"pscore\"]].values\n", 254 | "train_set = torch.LongTensor(train_set).to(device)\n", 255 | "\n", 256 | "model = models.LogitMatrixFactorization(\n", 257 | " n_user, n_item, n_dim, max_norm=5,max_bias=3,\n", 258 | " user_embedding_init = torch.Tensor(U), \n", 259 | " item_embedding_init = torch.Tensor(V.T),\n", 260 | " user_bias_init = torch.Tensor(ub), \n", 261 | " item_bias_init = torch.Tensor(vb)\n", 262 | ").to(device)\n", 263 | "\n", 264 | "optimizer = optim.Adam(model.parameters(), lr=lr)\n", 265 | "criterion = losses.RelevancePairwiseLoss(delta=\"rmse\").to(device)\n", 266 | "sampler = samplers.BaseSampler(train_set, n_user, n_item, device=device,n_neg_samples=5, batch_size=1024)\n", 267 | "\n", 268 | "score_function_dict = {\n", 269 | " \"nDCG\" : evaluators.ndcg,\n", 270 | " \"MAP\" : evaluators.average_precision,\n", 271 | " \"Recall\": evaluators.recall\n", 272 | "}\n", 273 | "evaluator = evaluators.UserwiseEvaluator(torch.LongTensor(test_set).to(device), score_function_dict, ks=[3])\n", 274 | "trainer = trainers.BaseTrainer(\n", 275 | " model, optimizer, criterion, sampler, \n", 276 | " column_names={\"user_id\":0, \"item_id\":1, \"pscore\":2}\n", 277 | ")\n" 278 | ] 279 | }, 280 | { 281 | "cell_type": "code", 282 | "execution_count": 13, 283 | "metadata": {}, 284 | "outputs": [ 285 | { 286 | "output_type": "stream", 287 | "name": "stderr", 288 | "text": [ 289 | "100%|██████████| 943/943 [00:15<00:00, 60.70it/s]\n", 290 | "epoch1 avg_loss:0.873: 100%|██████████| 50/50 [00:02<00:00, 17.79it/s]\n", 291 | "epoch2 avg_loss:0.575: 100%|██████████| 50/50 [00:01<00:00, 26.68it/s]\n", 292 | "epoch3 avg_loss:0.441: 100%|██████████| 50/50 [00:01<00:00, 28.20it/s]\n", 293 | "epoch4 avg_loss:0.358: 100%|██████████| 50/50 [00:01<00:00, 26.18it/s]\n", 294 | "epoch5 avg_loss:0.308: 100%|██████████| 50/50 [00:01<00:00, 26.51it/s]\n", 295 | "100%|██████████| 943/943 [00:19<00:00, 49.52it/s]\n", 296 | "epoch6 avg_loss:0.267: 100%|██████████| 50/50 [00:01<00:00, 27.11it/s]\n", 297 | "epoch7 avg_loss:0.240: 100%|██████████| 50/50 [00:01<00:00, 30.29it/s]\n", 298 | "epoch8 avg_loss:0.222: 100%|██████████| 50/50 [00:01<00:00, 26.68it/s]\n", 299 | "epoch9 avg_loss:0.206: 100%|██████████| 50/50 [00:01<00:00, 28.12it/s]\n", 300 | "epoch10 avg_loss:0.187: 100%|██████████| 50/50 [00:01<00:00, 29.63it/s]\n", 301 | "100%|██████████| 943/943 [00:22<00:00, 42.35it/s]\n", 302 | "epoch11 avg_loss:0.175: 100%|██████████| 50/50 [00:02<00:00, 19.32it/s]\n", 303 | "epoch12 avg_loss:0.169: 100%|██████████| 50/50 [00:02<00:00, 21.09it/s]\n", 304 | "epoch13 avg_loss:0.160: 100%|██████████| 50/50 [00:02<00:00, 21.26it/s]\n", 305 | "epoch14 avg_loss:0.150: 100%|██████████| 50/50 [00:02<00:00, 24.38it/s]\n", 306 | "epoch15 avg_loss:0.146: 100%|██████████| 50/50 [00:02<00:00, 23.80it/s]\n", 307 | "100%|██████████| 943/943 [00:18<00:00, 50.99it/s]\n" 308 | ] 309 | } 310 | ], 311 | "source": [ 312 | "trainer.fit(n_batch=50, n_epoch=15, valid_evaluator = evaluator, valid_per_epoch=5)" 313 | ] 314 | }, 315 | { 316 | "cell_type": "code", 317 | "execution_count": 14, 318 | "metadata": {}, 319 | "outputs": [ 320 | { 321 | "output_type": "execute_result", 322 | "data": { 323 | "text/plain": [ 324 | " nDCG@3 MAP@3 Recall@3 epoch loss\n", 325 | "0 0.404477 0.545157 0.111933 0 NaN\n", 326 | "0 0.398088 0.544185 0.112874 5 0.308484\n", 327 | "0 0.392882 0.537027 0.112438 10 0.186894\n", 328 | "0 0.393925 0.533581 0.112751 15 0.145944" 329 | ], 330 | "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
nDCG@3MAP@3Recall@3epochloss
00.4044770.5451570.1119330NaN
00.3980880.5441850.11287450.308484
00.3928820.5370270.112438100.186894
00.3939250.5335810.112751150.145944
\n
" 331 | }, 332 | "metadata": {}, 333 | "execution_count": 14 334 | } 335 | ], 336 | "source": [ 337 | "trainer.valid_scores" 338 | ] 339 | }, 340 | { 341 | "cell_type": "code", 342 | "execution_count": null, 343 | "metadata": {}, 344 | "outputs": [], 345 | "source": [] 346 | } 347 | ], 348 | "metadata": { 349 | "kernelspec": { 350 | "name": "python3", 351 | "display_name": "Python 3.8.6 64-bit ('pytorchcml-MJCCLiEQ-py3.8': poetry)" 352 | }, 353 | "language_info": { 354 | "codemirror_mode": { 355 | "name": "ipython", 356 | "version": 3 357 | }, 358 | "file_extension": ".py", 359 | "mimetype": "text/x-python", 360 | "name": "python", 361 | "nbconvert_exporter": "python", 362 | "pygments_lexer": "ipython3", 363 | "version": "3.8.6" 364 | }, 365 | "interpreter": { 366 | "hash": "1a6e8c4c71356cfd7f7f45384d81183fdca12e98ad893ee020bd76249bbd6be9" 367 | } 368 | }, 369 | "nbformat": 4, 370 | "nbformat_minor": 4 371 | } -------------------------------------------------------------------------------- /images/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hand10ryo/PyTorchCML/59e2808d42557d69a167a1864a07872c6ad89ddc/images/diagram.png -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hand10ryo/PyTorchCML/59e2808d42557d69a167a1864a07872c6ad89ddc/images/icon.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "PyTorchCML" 3 | version = "0.0.0" 4 | description = "Collaborative Metric Learning implemented by PyTorch" 5 | authors = ["hand10ryo "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/hand10ryo/PyTorchCML" 9 | repository = "https://github.com/hand10ryo/PyTorchCML" 10 | packages = [ 11 | { include = "PyTorchCML" } 12 | ] 13 | 14 | [tool.poetry-dynamic-versioning] 15 | enable = true 16 | style = "pep440" 17 | 18 | [tool.poetry.dependencies] 19 | python = ">=3.7.10,<3.10" 20 | torch = "^1.8.1" 21 | scikit-learn = "^0.22.2" 22 | scipy = "^1.4.1" 23 | numpy = "^1.19.5" 24 | pandas = "^1.1.5" 25 | tqdm = "^4.41.1" 26 | 27 | [tool.poetry.dev-dependencies] 28 | flake8 = "^3.9.1" 29 | autopep8 = "^1.5.6" 30 | black = "^21.4b2" 31 | isort = "^5.8.0" 32 | mypy = "^0.812" 33 | ipykernel = "^6.0.0" 34 | 35 | [build-system] 36 | requires = ["poetry-core>=1.0.0"] 37 | build-backend = "poetry.core.masonry.api" 38 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hand10ryo/PyTorchCML/59e2808d42557d69a167a1864a07872c6ad89ddc/tests/__init__.py -------------------------------------------------------------------------------- /tests/adaptors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hand10ryo/PyTorchCML/59e2808d42557d69a167a1864a07872c6ad89ddc/tests/adaptors/__init__.py -------------------------------------------------------------------------------- /tests/adaptors/test_MLPAdaptor.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | from torch import nn 5 | 6 | from PyTorchCML.adaptors import MLPAdaptor 7 | 8 | 9 | class TestMLPAdaptor(unittest.TestCase): 10 | """Test MLPAdaptor""" 11 | 12 | def test_forward(self): 13 | user_features = torch.ones(3, 4) / 10 14 | item_features = torch.ones(5, 6) / 10 15 | 16 | user_adaptor = MLPAdaptor(user_features, n_hidden=[10, 10], n_dim=4, weight=1) 17 | item_adaptor = MLPAdaptor(item_features, n_hidden=[10, 10], n_dim=4, weight=1) 18 | 19 | for param in user_adaptor.parameters(): 20 | nn.init.constant_(param, 0.1) 21 | 22 | for param in item_adaptor.parameters(): 23 | nn.init.constant_(param, 0.1) 24 | 25 | users = torch.LongTensor([[0], [1]]) 26 | pos_items = torch.LongTensor([[2], [3]]) 27 | neg_items = torch.LongTensor([[0, 1, 3, 4], [0, 2, 3, 4]]) 28 | 29 | embeddings_dict = { 30 | "user_embedding": torch.ones(2, 1, 4) / 10, 31 | "pos_item_embedding": torch.ones(2, 1, 4) / 10, 32 | "neg_item_embedding": torch.ones(2, 4, 4) * 2 / 10, 33 | } 34 | 35 | user_loss = user_adaptor(users, embeddings_dict["user_embedding"]) 36 | pos_item_loss = item_adaptor(pos_items, embeddings_dict["pos_item_embedding"]) 37 | neg_item_loss = item_adaptor(neg_items, embeddings_dict["neg_item_embedding"]) 38 | 39 | self.assertAlmostEqual(user_loss.item(), 0.96, places=5) 40 | self.assertAlmostEqual(pos_item_loss.item(), 1.04, places=5) 41 | self.assertAlmostEqual(neg_item_loss.item(), 2.56, places=5) 42 | -------------------------------------------------------------------------------- /tests/evaluators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hand10ryo/PyTorchCML/59e2808d42557d69a167a1864a07872c6ad89ddc/tests/evaluators/__init__.py -------------------------------------------------------------------------------- /tests/evaluators/test_UserwiseEvaluator.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | import numpy as np 5 | 6 | from PyTorchCML.evaluators import UserwiseEvaluator, ndcg, average_precision, recall 7 | from PyTorchCML.models import CollaborativeMetricLearning 8 | 9 | 10 | class TestScoreFunctions(unittest.TestCase): 11 | """Test Score Functions""" 12 | 13 | def test_ndcg(self): 14 | """Test ndcg 15 | 16 | true relevance : [0, 1, 1, 0, 1] 17 | predict relevance : [4, 6, 2, 0, 1] 18 | 19 | discount : [1/log_2(2), 1/log_2(3), 1/log_2(4), 1/log_2(5), 1/log_2(6)] 20 | = [1, 0.6309, 0.5, 0.4306, 0.3868] 21 | true relevance ranked by predict relevance : [1, 0, 1, 1, 0] 22 | 23 | ndcg@2 = ( 1 + 0 ) / ( 1 + 0.6309 ) = 0.6131 24 | ndcg@3 = ( 1 + 0 + 0.5 ) / ( 1 + 0.631 + 0.5 ) = 0.7038 25 | ndcg@4 = ( 1 + 0 + 0.5 + 0.4306 ) / ( 1 + 0.6309 + 0.5 + 0) = 0.9060 26 | """ 27 | y_test_user = np.array([0, 1, 1, 0, 1]) 28 | y_hat_user = np.array([4, 6, 2, 0, 1]) 29 | 30 | ndcg_at_2 = ndcg(y_test_user, y_hat_user, 2) 31 | ndcg_at_3 = ndcg(y_test_user, y_hat_user, 3) 32 | ndcg_at_4 = ndcg(y_test_user, y_hat_user, 4) 33 | 34 | self.assertAlmostEqual(ndcg_at_2, 0.6131, places=3) 35 | self.assertAlmostEqual(ndcg_at_3, 0.7038, places=3) 36 | self.assertAlmostEqual(ndcg_at_4, 0.9060, places=3) 37 | 38 | def test_average_precision(self): 39 | """Test average_precision 40 | 41 | true relevance : [0, 1, 1, 0, 1] 42 | predict relevance : [4, 6, 2, 0, 1] 43 | true relevance ranked by predict relevance : [1, 0, 1, 1, 0] 44 | 45 | AP@2 = ( 1 + 0 ) / 1 = 1 46 | AP@3 = ( 1 + 2/3 ) / 2 = 0.8333 47 | AP@4 = ( 1 + 2/3 + 3/4 ) / 3 = 0.8055 48 | """ 49 | 50 | y_test_user = np.array([0, 1, 1, 0, 1]) 51 | y_hat_user = np.array([4, 6, 2, 0, 1]) 52 | 53 | ap_at_2 = average_precision(y_test_user, y_hat_user, 2) 54 | ap_at_3 = average_precision(y_test_user, y_hat_user, 3) 55 | ap_at_4 = average_precision(y_test_user, y_hat_user, 4) 56 | 57 | self.assertEqual(ap_at_2, 1) 58 | self.assertAlmostEqual(ap_at_3, 0.8333, places=3) 59 | self.assertAlmostEqual(ap_at_4, 0.8055, places=3) 60 | 61 | def test_recall(self): 62 | """Test recall 63 | 64 | true relevance : [0, 1, 1, 0, 1] 65 | predict relevance : [4, 6, 2, 0, 1] 66 | true relevance ranked by predict relevance : [1, 0, 1, 1, 0] 67 | 68 | recall@2 = 1 / 3 = 0.3333 69 | recall@3 = 2 / 3 = 0.6666 70 | recall@4 = 3 / 3 = 1 71 | """ 72 | 73 | y_test_user = np.array([0, 1, 1, 0, 1]) 74 | y_hat_user = np.array([4, 6, 2, 0, 1]) 75 | 76 | recall_at_2 = recall(y_test_user, y_hat_user, 2) 77 | recall_at_3 = recall(y_test_user, y_hat_user, 3) 78 | recall_at_4 = recall(y_test_user, y_hat_user, 4) 79 | 80 | self.assertAlmostEqual(recall_at_2, 0.3333, places=3) 81 | self.assertAlmostEqual(recall_at_3, 0.6666, places=3) 82 | self.assertEqual(recall_at_4, 1) 83 | 84 | 85 | class TestUserwiseEvaluator(unittest.TestCase): 86 | """Test UserwiseEvaluator""" 87 | 88 | def test_eval_user(self): 89 | score_function_dict = {"nDCG": ndcg, "MAP": average_precision, "Recall": recall} 90 | test_set = torch.LongTensor( 91 | [ 92 | [0, 1, 1], 93 | [0, 3, 1], 94 | [0, 4, 0], 95 | [1, 0, 0], 96 | [1, 2, 1], 97 | [1, 4, 1], 98 | [2, 0, 1], 99 | [2, 1, 0], 100 | [2, 2, 1], 101 | ] 102 | ) 103 | 104 | evaluator = UserwiseEvaluator(test_set, score_function_dict, ks=[2, 3]) 105 | model = CollaborativeMetricLearning(n_user=3, n_item=5, n_dim=10) 106 | df_eval_sub = evaluator.eval_user(model, 0) 107 | 108 | # shape 109 | n, m = df_eval_sub.shape 110 | self.assertEqual(n, 1) 111 | self.assertEqual(m, 6) 112 | 113 | # columns 114 | columns = list(df_eval_sub.columns) 115 | self.assertEqual( 116 | columns, ["nDCG@2", "MAP@2", "Recall@2", "nDCG@3", "MAP@3", "Recall@3"] 117 | ) 118 | 119 | def test_score(self): 120 | score_function_dict = {"nDCG": ndcg, "MAP": average_precision, "Recall": recall} 121 | test_set = torch.LongTensor( 122 | [ 123 | [0, 1, 0], 124 | [0, 3, 1], 125 | [0, 4, 0], 126 | [1, 0, 0], 127 | [1, 2, 0], 128 | [1, 4, 1], 129 | [2, 0, 1], 130 | [2, 1, 0], 131 | [2, 2, 0], 132 | ] 133 | ) 134 | 135 | evaluator = UserwiseEvaluator(test_set, score_function_dict, ks=[2, 3]) 136 | model = CollaborativeMetricLearning(n_user=3, n_item=5, n_dim=10) 137 | 138 | df_eval = evaluator.score(model) 139 | 140 | # shape 141 | n, m = df_eval.shape 142 | self.assertEqual(n, 1) 143 | self.assertEqual(m, 6) 144 | 145 | # columns 146 | columns = list(df_eval.columns) 147 | self.assertEqual( 148 | columns, ["nDCG@2", "MAP@2", "Recall@2", "nDCG@3", "MAP@3", "Recall@3"] 149 | ) 150 | 151 | # is not null 152 | for col in columns: 153 | self.assertIsNotNone(df_eval[col].values[0]) 154 | 155 | # reduction 156 | df_eval = evaluator.score(model, reduction="sum") 157 | 158 | # shape 159 | n, m = df_eval.shape 160 | self.assertEqual(n, 3) 161 | self.assertEqual(m, 6) 162 | 163 | 164 | if __name__ == "__main__": 165 | unittest.main() 166 | -------------------------------------------------------------------------------- /tests/losses/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hand10ryo/PyTorchCML/59e2808d42557d69a167a1864a07872c6ad89ddc/tests/losses/__init__.py -------------------------------------------------------------------------------- /tests/losses/test_LogitPairwiseLoss.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | from torch import nn 5 | import numpy as np 6 | 7 | from PyTorchCML.losses import LogitPairwiseLoss 8 | 9 | 10 | def sigmoid(x: np.ndarray) -> np.ndarray: 11 | return 1 / (1 + np.exp(-x)) 12 | 13 | 14 | class SampleRegularizer(nn.Module): 15 | def forward(self, embeding_dict: dict) -> torch.Tensor: 16 | return torch.ones(3).sum() 17 | 18 | 19 | class TestLogitPairwiseLoss(unittest.TestCase): 20 | """Test LogitPairwiseLoss""" 21 | 22 | def test_forward(self): 23 | """ 24 | test forward 25 | 26 | pos_dist = [[3], [3]] 27 | neg_dist = [[0,1,2], [3,4,5]] 28 | loss = [[10], [1]] 29 | avg_loss = 5.5 30 | """ 31 | embeddings_dict = { 32 | "user_embedding": torch.ones(3, 1, 5), 33 | "pos_item_embedding": torch.ones(3, 1, 5) * 2, 34 | "neg_item_embedding": torch.ones(3, 2, 5), 35 | "user_bias": torch.zeros(3, 1), 36 | "pos_item_bias": torch.zeros(3, 1) * 2, 37 | "neg_item_bias": torch.zeros(3, 2), 38 | } 39 | batch = torch.ones([3, 2]) 40 | column_names = {"user_id": 0, "item_id": 1} 41 | 42 | # without regularizer 43 | criterion = LogitPairwiseLoss() 44 | loss = criterion(embeddings_dict, batch, column_names).item() 45 | 46 | self.assertGreater(loss, 0) 47 | self.assertAlmostEqual(loss, 3.3378, places=3) 48 | 49 | # with regularizer 50 | regs = [SampleRegularizer()] 51 | criterion = LogitPairwiseLoss(regularizers=regs) 52 | loss = criterion(embeddings_dict, batch, column_names).item() 53 | self.assertGreater(loss, 0) 54 | self.assertAlmostEqual(loss, 6.3378, places=3) 55 | 56 | 57 | if __name__ == "__main__": 58 | unittest.main() 59 | -------------------------------------------------------------------------------- /tests/losses/test_MSEPairwiseLoss.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | from torch import nn 5 | 6 | from PyTorchCML.losses import MSEPairwiseLoss 7 | 8 | 9 | class SampleRegularizer(nn.Module): 10 | def forward(self, embeding_dict: dict) -> torch.Tensor: 11 | return torch.ones(3).sum() 12 | 13 | 14 | class TestMSEPairwiseLoss(unittest.TestCase): 15 | """Test LogitPairwiseLoss""" 16 | 17 | def test_forward(self): 18 | """ 19 | test forward 20 | 21 | pos_inner = [[10], [10], [10]] 22 | neg_inner = [[-5, -5], [-5, -5], [-5, -5]] 23 | pos_bias = [3, 3, 3] 24 | neg_bias = [0, 0, 0] 25 | 26 | pos_r_hat = [13, 13, 13] 27 | neg_r_hat = [[-5, -5], [-5, -5], [-5, -5]] 28 | pos_loss = [-24, -24, -24] 29 | neg_loss = [[25, 25], [25, 25], [25, 25]] 30 | avg_loss = 26 / 3 31 | """ 32 | embeddings_dict = { 33 | "user_embedding": torch.zeros(3, 1, 5), 34 | "pos_item_embedding": torch.zeros(3, 1, 5) * 2, 35 | "neg_item_embedding": -torch.zeros(3, 2, 5), 36 | "user_bias": torch.zeros(3, 1), 37 | "pos_item_bias": torch.zeros(3, 1) * 2, 38 | "neg_item_bias": -torch.zeros(3, 2), 39 | } 40 | batch = torch.ones([3, 2]) 41 | column_names = {"user_id": 0, "item_id": 1} 42 | 43 | # without regularizer 44 | criterion = MSEPairwiseLoss() 45 | loss = criterion(embeddings_dict, batch, column_names).item() 46 | 47 | self.assertGreater(loss, 0) 48 | self.assertAlmostEqual(loss, 0.25, places=3) 49 | 50 | # with regularizer 51 | regs = [SampleRegularizer()] 52 | criterion = MSEPairwiseLoss(regularizers=regs) 53 | loss = criterion(embeddings_dict, batch, column_names).item() 54 | self.assertGreater(loss, 0) 55 | self.assertAlmostEqual(loss, 3.25, places=3) 56 | 57 | 58 | if __name__ == "__main__": 59 | unittest.main() 60 | -------------------------------------------------------------------------------- /tests/losses/test_MinTripletLoss.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | from torch import nn 5 | 6 | from PyTorchCML.losses import MinTripletLoss 7 | 8 | 9 | class SampleRegularizer(nn.Module): 10 | def forward(self, embeding_dict: dict) -> torch.Tensor: 11 | return torch.ones(3).sum() 12 | 13 | 14 | class TestMinTripletLoss(unittest.TestCase): 15 | """Test MnTripletLoss""" 16 | 17 | def test_forward(self): 18 | """ 19 | test forward 20 | """ 21 | embeddings_dict = { 22 | "user_embedding": torch.ones(3, 1, 5), 23 | "pos_item_embedding": torch.ones(3, 1, 5) * 2, 24 | "neg_item_embedding": torch.ones(3, 1, 5), 25 | } 26 | 27 | batch = torch.ones([3, 2]) 28 | column_names = {"user_id": 0, "item_id": 1} 29 | 30 | # without regularizer 31 | criterion = MinTripletLoss(margin=1) 32 | loss = criterion(embeddings_dict, batch, column_names).item() 33 | self.assertGreater(loss, 0) 34 | self.assertEqual(loss, 6) 35 | 36 | # with regularizer 37 | regs = [SampleRegularizer()] 38 | criterion = MinTripletLoss(margin=1, regularizers=regs) 39 | loss = criterion(embeddings_dict, batch, column_names).item() 40 | self.assertGreater(loss, 0) 41 | self.assertEqual(loss, 9) 42 | 43 | 44 | if __name__ == "__main__": 45 | unittest.main() 46 | -------------------------------------------------------------------------------- /tests/losses/test_RelevancePairwiseLoss.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | from torch import nn 5 | 6 | from PyTorchCML.losses import RelevancePairwiseLoss 7 | 8 | 9 | class SampleRegularizer(nn.Module): 10 | def forward(self, embeding_dict: dict) -> torch.Tensor: 11 | return torch.ones(3).sum() 12 | 13 | 14 | class TestRelevancePairwiseLoss(unittest.TestCase): 15 | """Test LogitPairwiseLoss""" 16 | 17 | def test_forward(self): 18 | """ 19 | test forward 20 | 21 | pos_inner = [[10], [10], [10]] 22 | neg_inner = [[-5, -5], [-5, -5], [-5, -5]] 23 | pos_bias = [3, 3, 3] 24 | neg_bias = [0, 0, 0] 25 | 26 | pos_r_hat = [13, 13, 13] 27 | neg_r_hat = [[-5, -5], [-5, -5], [-5, -5]] 28 | pos_loss = [-24, -24, -24] 29 | neg_loss = [[25, 25], [25, 25], [25, 25]] 30 | avg_loss = 26 / 3 31 | """ 32 | embeddings_dict = { 33 | "user_embedding": torch.zeros(3, 1, 5), 34 | "pos_item_embedding": torch.zeros(3, 1, 5) * 2, 35 | "neg_item_embedding": -torch.zeros(3, 2, 5), 36 | "user_bias": torch.zeros(3, 1), 37 | "pos_item_bias": torch.zeros(3, 1) * 2, 38 | "neg_item_bias": -torch.zeros(3, 2), 39 | } 40 | batch = torch.ones([3, 3]) 41 | column_names = {"user_id": 0, "item_id": 1, "pscore": 2} 42 | 43 | # without regularizer 44 | criterion = RelevancePairwiseLoss(delta="mse") 45 | loss = criterion(embeddings_dict, batch, column_names).item() 46 | 47 | self.assertGreater(loss, 0) 48 | self.assertAlmostEqual(loss, 0.5, places=3) 49 | 50 | # with regularizer 51 | regs = [SampleRegularizer()] 52 | criterion = RelevancePairwiseLoss(regularizers=regs, delta="mse") 53 | loss = criterion(embeddings_dict, batch, column_names).item() 54 | self.assertGreater(loss, 0) 55 | self.assertAlmostEqual(loss, 3.5, places=3) 56 | 57 | 58 | if __name__ == "__main__": 59 | unittest.main() 60 | -------------------------------------------------------------------------------- /tests/losses/test_SumTripletLoss.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | from torch import nn 5 | 6 | from PyTorchCML.losses import SumTripletLoss 7 | 8 | 9 | class SampleRegularizer(nn.Module): 10 | def forward(self, embeding_dict: dict) -> torch.Tensor: 11 | return torch.ones(3).sum() 12 | 13 | 14 | class TestSumTripletLoss(unittest.TestCase): 15 | """Test SumTripletLoss""" 16 | 17 | def test_forward(self): 18 | """ 19 | test forward 20 | """ 21 | embeddings_dict = { 22 | "user_embedding": torch.ones(3, 1, 5), 23 | "pos_item_embedding": torch.ones(3, 1, 5) * 2, 24 | "neg_item_embedding": torch.ones(3, 1, 5), 25 | } 26 | batch = torch.ones([3, 2]) 27 | column_names = {"user_id": 0, "item_id": 1} 28 | 29 | # without regularizer 30 | criterion = SumTripletLoss(margin=1) 31 | loss = criterion(embeddings_dict, batch, column_names).item() 32 | self.assertGreater(loss, 0) 33 | self.assertEqual(loss, 6) 34 | 35 | # with regularizer 36 | regs = [SampleRegularizer()] 37 | criterion = SumTripletLoss(margin=1, regularizers=regs) 38 | loss = criterion(embeddings_dict, batch, column_names).item() 39 | self.assertGreater(loss, 0) 40 | self.assertEqual(loss, 9) 41 | 42 | 43 | if __name__ == "__main__": 44 | unittest.main() 45 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hand10ryo/PyTorchCML/59e2808d42557d69a167a1864a07872c6ad89ddc/tests/models/__init__.py -------------------------------------------------------------------------------- /tests/models/test_CollaborativeMetricLearning.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | 5 | from PyTorchCML.models import CollaborativeMetricLearning 6 | 7 | 8 | class TestCollaborativeMetricLearning(unittest.TestCase): 9 | def test_forward(self): 10 | users = torch.LongTensor([[0], [1]]) 11 | pos_items = torch.LongTensor([[2], [3]]) 12 | neg_items = torch.LongTensor([[1, 3, 4], [0, 1, 2]]) 13 | 14 | model = CollaborativeMetricLearning( 15 | n_user=3, 16 | n_item=5, 17 | n_dim=10, 18 | ) 19 | 20 | embedding_dict = model(users, pos_items, neg_items) 21 | 22 | user_embedding = embedding_dict["user_embedding"] 23 | pos_item_embedding = embedding_dict["pos_item_embedding"] 24 | neg_item_embedding = embedding_dict["neg_item_embedding"] 25 | 26 | # user_emb shape 27 | shape = user_embedding.shape 28 | self.assertEqual(shape, (2, 1, 10)) 29 | 30 | # pos_item_emb shape 31 | shape = pos_item_embedding.shape 32 | self.assertEqual(shape, (2, 1, 10)) 33 | 34 | # item_emb shape 35 | shape = neg_item_embedding.shape 36 | self.assertEqual(shape, (2, 3, 10)) 37 | 38 | def test_predict(self): 39 | user_item_pairs = torch.LongTensor([[0, 0], [1, 1], [2, 2]]) 40 | model = CollaborativeMetricLearning( 41 | n_user=3, 42 | n_item=5, 43 | n_dim=10, 44 | ) 45 | 46 | y_hat = model.predict(user_item_pairs) 47 | 48 | # y_hat shape 49 | shape = y_hat.shape 50 | self.assertEqual(shape, torch.Size([3])) 51 | 52 | def test_spreadout_distance(self): 53 | pos_items = torch.LongTensor([[2], [3]]) 54 | neg_items = torch.LongTensor([0, 1, 4]) 55 | 56 | model = CollaborativeMetricLearning( 57 | n_user=3, 58 | n_item=5, 59 | n_dim=10, 60 | ) 61 | 62 | so_dist = model.spreadout_distance(pos_items, neg_items) 63 | 64 | # y_hat shape 65 | shape = so_dist.shape 66 | self.assertEqual(shape, torch.Size([2, 3])) 67 | -------------------------------------------------------------------------------- /tests/models/test_MatrixFactorization.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | 5 | from PyTorchCML.models import LogitMatrixFactorization 6 | 7 | 8 | class TestLogitMatrixFactorization(unittest.TestCase): 9 | def test_forward(self): 10 | users = torch.LongTensor([0, 1]) 11 | pos_items = torch.LongTensor([2, 3]) 12 | neg_items = torch.LongTensor([[1, 3, 4], [0, 1, 2]]) 13 | 14 | model = LogitMatrixFactorization( 15 | n_user=3, 16 | n_item=5, 17 | n_dim=10, 18 | ) 19 | 20 | embeddings_dict = model(users, pos_items, neg_items) 21 | 22 | user_embedding = embeddings_dict["user_embedding"] 23 | pos_item_embedding = embeddings_dict["pos_item_embedding"] 24 | neg_item_embedding = embeddings_dict["neg_item_embedding"] 25 | user_bias = embeddings_dict["user_bias"] 26 | pos_item_bias = embeddings_dict["pos_item_bias"] 27 | neg_item_bias = embeddings_dict["neg_item_bias"] 28 | 29 | # user_emb shape 30 | shape = user_embedding.shape 31 | self.assertEqual(shape, (2, 10)) 32 | 33 | # pos_item_emb shape 34 | shape = pos_item_embedding.shape 35 | self.assertEqual(shape, (2, 10)) 36 | 37 | # neg_item_emb shape 38 | shape = neg_item_embedding.shape 39 | self.assertEqual(shape, (2, 3, 10)) 40 | 41 | # user_bias shape 42 | shape = user_bias.shape 43 | self.assertEqual(shape, (2, 1)) 44 | 45 | # pos_item_bias shape 46 | shape = pos_item_bias.shape 47 | self.assertEqual(shape, (2, 1)) 48 | 49 | # neg_item_bias shape 50 | shape = neg_item_bias.shape 51 | self.assertEqual(shape, (2, 3)) 52 | 53 | def test_predict(self): 54 | user_item_pairs = torch.LongTensor([[0, 0], [1, 1], [2, 2]]) 55 | model = LogitMatrixFactorization( 56 | n_user=3, 57 | n_item=5, 58 | n_dim=10, 59 | ) 60 | 61 | y_hat = model.predict(user_item_pairs) 62 | 63 | # y_hat shape 64 | shape = y_hat.shape 65 | self.assertEqual(shape, torch.Size([3])) 66 | 67 | def test_predict_proba(self): 68 | user_item_pairs = torch.LongTensor([[0, 0], [1, 1], [2, 2]]) 69 | model = LogitMatrixFactorization( 70 | n_user=3, 71 | n_item=5, 72 | n_dim=10, 73 | ) 74 | 75 | y_hat = model.predict_proba(user_item_pairs) 76 | 77 | # y_hat shape 78 | shape = y_hat.shape 79 | self.assertEqual(shape, torch.Size([3])) 80 | 81 | # range 82 | min_y_hat = y_hat.min().item() 83 | max_y_hat = y_hat.max().item() 84 | self.assertGreater(min_y_hat, 0) 85 | self.assertGreater(1, max_y_hat) 86 | 87 | def test_get_item_weight(self): 88 | users = torch.LongTensor([0, 1]) 89 | model = LogitMatrixFactorization( 90 | n_user=3, 91 | n_item=5, 92 | n_dim=10, 93 | ) 94 | item_weight = model.get_item_weight(users) 95 | 96 | # y_hat shape 97 | shape = item_weight.shape 98 | self.assertEqual(shape, (2, 5)) 99 | 100 | # range 101 | min_weight = item_weight.min().item() 102 | self.assertGreater(min_weight, 0) 103 | 104 | # value test 105 | model = LogitMatrixFactorization( 106 | n_user=3, 107 | n_item=5, 108 | n_dim=10, 109 | user_embedding_init=torch.ones(3, 10), 110 | item_embedding_init=torch.ones(5, 10), 111 | user_bias_init=torch.ones(3), 112 | item_bias_init=torch.ones(5), 113 | ) 114 | model.weight_link = lambda x: x 115 | item_weight = model.get_item_weight(users) 116 | 117 | min_weight = item_weight.min().item() 118 | max_weight = item_weight.max().item() 119 | self.assertEqual(min_weight, 12) 120 | self.assertEqual(max_weight, 12) 121 | -------------------------------------------------------------------------------- /tests/regularizers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hand10ryo/PyTorchCML/59e2808d42557d69a167a1864a07872c6ad89ddc/tests/regularizers/__init__.py -------------------------------------------------------------------------------- /tests/regularizers/test_GlobalOrthogonalRegularizer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | 5 | from PyTorchCML.regularizers import GlobalOrthogonalRegularizer 6 | 7 | 8 | class TestGlobalOrthogonalRegularizer(unittest.TestCase): 9 | """Test for Global Orthogonal Regularizer""" 10 | 11 | def test_forward(self): 12 | """ 13 | test forward 14 | """ 15 | embeddings_dict = { 16 | "pos_item_embedding": torch.ones(2, 1, 5), 17 | "neg_item_embedding": torch.ones(2, 3, 5), 18 | } 19 | 20 | regularizer = GlobalOrthogonalRegularizer(weight=1) 21 | 22 | reg = regularizer(embeddings_dict).item() 23 | 24 | self.assertAlmostEqual(reg, 49.8, places=2) 25 | 26 | 27 | if __name__ == "__main__": 28 | unittest.main() 29 | -------------------------------------------------------------------------------- /tests/regularizers/test_L2Regularizer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | 5 | from PyTorchCML.regularizers import L2Regularizer 6 | 7 | 8 | class TestL2Regularizer(unittest.TestCase): 9 | """Test for Global Orthogonal Regularizer""" 10 | 11 | def test_forward(self): 12 | """ 13 | test forward 14 | """ 15 | embeddings_dict = { 16 | "user_embedding": torch.ones(2, 1, 5), 17 | "pos_item_embedding": torch.ones(2, 1, 5), 18 | "neg_item_embedding": torch.ones(2, 3, 5), 19 | } 20 | 21 | regularizer = L2Regularizer(weight=1) 22 | 23 | reg = regularizer(embeddings_dict).item() 24 | 25 | self.assertEqual(reg, 50) 26 | 27 | 28 | if __name__ == "__main__": 29 | unittest.main() 30 | -------------------------------------------------------------------------------- /tests/samplers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hand10ryo/PyTorchCML/59e2808d42557d69a167a1864a07872c6ad89ddc/tests/samplers/__init__.py -------------------------------------------------------------------------------- /tests/samplers/test_BaseSampler.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | import numpy as np 5 | 6 | from PyTorchCML.samplers import BaseSampler 7 | from PyTorchCML.models import LogitMatrixFactorization 8 | 9 | 10 | class TestBaseSampler(unittest.TestCase): 11 | """Test BaseSampler""" 12 | 13 | def test_get_pos_batch(self): 14 | """ 15 | test get_pos_batch 16 | """ 17 | train_set = torch.LongTensor([[0, 0], [0, 2], [1, 1], [1, 3], [2, 3], [2, 4]]) 18 | 19 | sampler = BaseSampler( 20 | train_set, 21 | n_user=3, 22 | n_item=5, 23 | batch_size=3, 24 | n_neg_samples=2, 25 | strict_negative=True, 26 | ) 27 | pos_batch = sampler.get_pos_batch() 28 | 29 | # shape 30 | n, m = pos_batch.shape 31 | self.assertEqual(n, 3) 32 | self.assertEqual(m, 2) 33 | 34 | # range of user id and item id 35 | user_id_min = pos_batch[:, 0].min().item() 36 | item_id_min = pos_batch[:, 1].min().item() 37 | user_id_max = pos_batch[:, 0].max().item() 38 | item_id_max = pos_batch[:, 1].max().item() 39 | self.assertGreaterEqual(user_id_min, 0) 40 | self.assertGreaterEqual(item_id_min, 0) 41 | self.assertGreaterEqual(3, user_id_max) 42 | self.assertGreaterEqual(5, item_id_max) 43 | 44 | # pairwise weighted sampler 45 | pos_weight_pair = np.array([1, 1, 1, 1, 1, 100]) 46 | sampler = BaseSampler( 47 | train_set, 48 | n_user=3, 49 | n_item=5, 50 | pos_weight=pos_weight_pair, 51 | batch_size=100, 52 | n_neg_samples=2, 53 | strict_negative=True, 54 | ) 55 | pos_batch = sampler.get_pos_batch() 56 | cnt_heavy = (pos_batch[:, 1] == 4).sum().item() 57 | cnt_lignt = (pos_batch[:, 1] == 0).sum().item() 58 | self.assertGreaterEqual(cnt_heavy, cnt_lignt) 59 | 60 | # item wise weighted sampler 61 | pos_weight_item = np.array([1, 1, 1, 1, 100]) 62 | sampler = BaseSampler( 63 | train_set, 64 | n_user=3, 65 | n_item=5, 66 | pos_weight=pos_weight_item, 67 | batch_size=100, 68 | n_neg_samples=2, 69 | strict_negative=True, 70 | ) 71 | pos_batch = sampler.get_pos_batch() 72 | cnt_heavy = (pos_batch[:, 1] == 4).sum().item() 73 | cnt_lignt = (pos_batch[:, 1] == 0).sum().item() 74 | self.assertGreaterEqual(cnt_heavy, cnt_lignt) 75 | 76 | def test_get_neg_batch(self): 77 | """ 78 | test get_neg_batch 79 | """ 80 | train_set = torch.LongTensor([[0, 0], [0, 2], [1, 1], [1, 3], [2, 3], [2, 4]]) 81 | interactions = { 82 | 0: [0, 2], 83 | 1: [1, 3], 84 | 2: [3, 4], 85 | } 86 | sampler = BaseSampler( 87 | train_set, 88 | n_user=3, 89 | n_item=5, 90 | batch_size=3, 91 | n_neg_samples=2, 92 | strict_negative=True, 93 | ) 94 | pos_batch = sampler.get_pos_batch() 95 | users = pos_batch[:, 0] 96 | neg_batch = sampler.get_neg_batch(users) 97 | 98 | # shape 99 | n, m = neg_batch.shape 100 | self.assertEqual(n, 3) 101 | self.assertEqual(m, 2) 102 | 103 | # range of item id 104 | item_id_min = neg_batch.min().item() 105 | item_id_max = neg_batch.max().item() 106 | self.assertGreaterEqual(item_id_min, 0) 107 | self.assertGreaterEqual(5, item_id_max) 108 | 109 | # strict negative 110 | for k, u in enumerate(users): 111 | for i in interactions[u.item()]: 112 | self.assertNotIn(i, neg_batch[k]) 113 | 114 | # weighted sampling 115 | neg_weight = np.array([1, 1, 1, 1, 100]) 116 | sampler = BaseSampler( 117 | train_set, 118 | n_user=3, 119 | n_item=5, 120 | neg_weight=neg_weight, 121 | batch_size=100, 122 | n_neg_samples=2, 123 | strict_negative=True, 124 | ) 125 | pos_batch = sampler.get_pos_batch() 126 | users = pos_batch[:, 0] 127 | neg_batch = sampler.get_neg_batch(users) 128 | 129 | cnt_heavy = (neg_batch == 4).sum().item() 130 | cnt_lignt = (neg_batch == 0).sum().item() 131 | self.assertGreaterEqual(cnt_heavy, cnt_lignt) 132 | 133 | # model weighted sampling 134 | neg_weight_model = LogitMatrixFactorization( 135 | n_user=3, 136 | n_item=5, 137 | n_dim=10, 138 | ) 139 | 140 | sampler = BaseSampler( 141 | train_set, 142 | n_user=3, 143 | n_item=5, 144 | neg_weight=neg_weight_model, 145 | batch_size=100, 146 | n_neg_samples=2, 147 | strict_negative=True, 148 | ) 149 | pos_batch = sampler.get_pos_batch() 150 | users = pos_batch[:, 0] 151 | neg_batch = sampler.get_neg_batch(users) 152 | 153 | n, m = neg_batch.shape 154 | self.assertEqual(n, 100) 155 | self.assertEqual(m, 2) 156 | 157 | 158 | if __name__ == "__main__": 159 | unittest.main() 160 | -------------------------------------------------------------------------------- /tests/samplers/test_TwoStageSampler.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import torch 4 | import numpy as np 5 | 6 | from PyTorchCML.samplers import TwoStageSampler 7 | 8 | 9 | class TestTwoStageSampler(unittest.TestCase): 10 | """Test BaseSampler""" 11 | 12 | def test_get_pos_batch(self): 13 | """ 14 | test get_pos_batch 15 | """ 16 | train_set = torch.LongTensor([[0, 0], [0, 2], [1, 1], [1, 3], [2, 3], [2, 4]]) 17 | 18 | sampler = TwoStageSampler( 19 | train_set, 20 | n_user=3, 21 | n_item=5, 22 | batch_size=3, 23 | n_neg_samples=2, 24 | # strict_negative=True, 25 | ) 26 | pos_batch = sampler.get_pos_batch() 27 | 28 | # shape 29 | n, m = pos_batch.shape 30 | self.assertEqual(n, 3) 31 | self.assertEqual(m, 2) 32 | 33 | # range of user id and item id 34 | user_id_min = pos_batch[:, 0].min().item() 35 | item_id_min = pos_batch[:, 1].min().item() 36 | user_id_max = pos_batch[:, 0].max().item() 37 | item_id_max = pos_batch[:, 1].max().item() 38 | self.assertGreaterEqual(user_id_min, 0) 39 | self.assertGreaterEqual(item_id_min, 0) 40 | self.assertGreaterEqual(3, user_id_max) 41 | self.assertGreaterEqual(5, item_id_max) 42 | 43 | # pairwise weighted sampler 44 | pos_weight_pair = np.array([1, 1, 1, 1, 1, 1000]) 45 | sampler = TwoStageSampler( 46 | train_set, 47 | n_user=3, 48 | n_item=5, 49 | pos_weight=pos_weight_pair, 50 | batch_size=100, 51 | n_neg_samples=2, 52 | strict_negative=True, 53 | ) 54 | pos_batch = sampler.get_pos_batch() 55 | cnt_heavy = (pos_batch[:, 1] == 4).sum().item() 56 | cnt_lignt = (pos_batch[:, 1] == 0).sum().item() 57 | self.assertGreaterEqual(cnt_heavy, cnt_lignt) 58 | 59 | # item wise weighted sampler 60 | pos_weight_item = np.array([1, 1, 1, 1, 1000]) 61 | sampler = TwoStageSampler( 62 | train_set, 63 | n_user=3, 64 | n_item=5, 65 | pos_weight=pos_weight_item, 66 | batch_size=100, 67 | n_neg_samples=2, 68 | strict_negative=True, 69 | ) 70 | pos_batch = sampler.get_pos_batch() 71 | cnt_heavy = (pos_batch[:, 1] == 4).sum().item() 72 | cnt_lignt = (pos_batch[:, 1] == 0).sum().item() 73 | self.assertGreaterEqual(cnt_heavy, cnt_lignt) 74 | 75 | def test_get_neg_batch(self): 76 | """ 77 | test get_neg_batch 78 | """ 79 | train_set = torch.LongTensor([[0, 0], [0, 2], [1, 1], [1, 3], [2, 3], [2, 4]]) 80 | interactions = { 81 | 0: [0, 2], 82 | 1: [1, 3], 83 | 2: [3, 4], 84 | } 85 | sampler = TwoStageSampler( 86 | train_set, 87 | n_user=3, 88 | n_item=15, 89 | batch_size=3, 90 | n_neg_samples=2, 91 | n_neg_candidates=5, 92 | strict_negative=True, 93 | ) 94 | pos_batch = sampler.get_pos_batch() 95 | users = pos_batch[:, 0:1] 96 | 97 | # two stage 98 | sampler.get_and_set_candidates() 99 | dist = torch.ones(3, 5) 100 | sampler.set_candidates_weight(dist, 3) 101 | neg_batch = sampler.get_neg_batch(users.reshape(-1)) 102 | 103 | # shape 104 | n, m = neg_batch.shape 105 | self.assertEqual(n, 3) 106 | self.assertEqual(m, 2) 107 | 108 | # range of item id 109 | item_id_min = neg_batch.min().item() 110 | item_id_max = neg_batch.max().item() 111 | self.assertGreaterEqual(item_id_min, 0) 112 | self.assertGreaterEqual(15, item_id_max) 113 | 114 | # strict negative 115 | for k, u in enumerate(users): 116 | for i in interactions[u.item()]: 117 | self.assertNotIn(i, neg_batch[k]) 118 | 119 | # weighted sampling 120 | neg_weight = np.array([1, 1, 1, 1, 100]) 121 | sampler = TwoStageSampler( 122 | train_set, 123 | n_user=3, 124 | n_item=5, 125 | neg_weight=neg_weight, 126 | batch_size=100, 127 | n_neg_samples=2, 128 | n_neg_candidates=3, 129 | # strict_negative=True, 130 | ) 131 | pos_batch = sampler.get_pos_batch() 132 | users = pos_batch[:, 0:1] 133 | 134 | # two stage 135 | sampler.get_and_set_candidates() 136 | dist = torch.ones(100, 3) 137 | sampler.set_candidates_weight(dist, 3) 138 | neg_batch = sampler.get_neg_batch(users.reshape(-1)) 139 | 140 | cnt_heavy = (neg_batch == 4).sum().item() 141 | cnt_lignt = (neg_batch == 0).sum().item() 142 | self.assertGreaterEqual(cnt_heavy, cnt_lignt) 143 | 144 | 145 | if __name__ == "__main__": 146 | unittest.main() 147 | -------------------------------------------------------------------------------- /tests/trainers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hand10ryo/PyTorchCML/59e2808d42557d69a167a1864a07872c6ad89ddc/tests/trainers/__init__.py -------------------------------------------------------------------------------- /tests/trainers/test_BaseTrainer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import torch 3 | from torch import optim 4 | 5 | from PyTorchCML import evaluators, losses, models, samplers, trainers 6 | 7 | 8 | class TestBaseTrainer(unittest.TestCase): 9 | def test_fit_cml(self): 10 | train_set = torch.LongTensor([[0, 0], [0, 2], [1, 1], [1, 3], [2, 3], [2, 4]]) 11 | test_set = torch.LongTensor( 12 | [ 13 | [0, 1, 1], 14 | [0, 3, 1], 15 | [0, 4, 0], 16 | [1, 0, 0], 17 | [1, 2, 1], 18 | [1, 4, 1], 19 | [2, 0, 1], 20 | [2, 1, 0], 21 | [2, 2, 1], 22 | ] 23 | ) 24 | 25 | lr = 1e-3 26 | n_user, n_item, n_dim = 3, 5, 10 27 | model = models.CollaborativeMetricLearning(n_user, n_item, n_dim) 28 | optimizer = optim.Adam(model.parameters(), lr=lr) 29 | criterion = losses.SumTripletLoss(margin=1) 30 | sampler = samplers.BaseSampler(train_set, n_user, n_item, n_neg_samples=2) 31 | 32 | score_function_dict = { 33 | "nDCG": evaluators.ndcg, 34 | "MAP": evaluators.average_precision, 35 | "Recall": evaluators.recall, 36 | } 37 | 38 | evaluator = evaluators.UserwiseEvaluator( 39 | test_set, score_function_dict, ks=[2, 3] 40 | ) 41 | 42 | trainer = trainers.BaseTrainer(model, optimizer, criterion, sampler) 43 | 44 | trainer.fit(n_batch=3, n_epoch=3, valid_evaluator=evaluator, valid_per_epoch=1) 45 | 46 | df_eval = trainer.valid_scores 47 | 48 | self.assertEqual(df_eval.shape, (4, 8)) 49 | 50 | def test_fit_mf(self): 51 | train_set = torch.LongTensor([[0, 0], [0, 2], [1, 1], [1, 3], [2, 3], [2, 4]]) 52 | test_set = torch.LongTensor( 53 | [ 54 | [0, 1, 1], 55 | [0, 3, 1], 56 | [0, 4, 0], 57 | [1, 0, 0], 58 | [1, 2, 1], 59 | [1, 4, 1], 60 | [2, 0, 1], 61 | [2, 1, 0], 62 | [2, 2, 1], 63 | ] 64 | ) 65 | 66 | lr = 1e-3 67 | n_user, n_item, n_dim = 3, 5, 10 68 | model = models.LogitMatrixFactorization(n_user, n_item, n_dim) 69 | optimizer = optim.Adam(model.parameters(), lr=lr) 70 | criterion = losses.LogitPairwiseLoss() 71 | sampler = samplers.BaseSampler(train_set, n_user, n_item, n_neg_samples=2) 72 | 73 | score_function_dict = { 74 | "nDCG": evaluators.ndcg, 75 | "MAP": evaluators.average_precision, 76 | "Recall": evaluators.recall, 77 | } 78 | 79 | evaluator = evaluators.UserwiseEvaluator( 80 | test_set, score_function_dict, ks=[2, 3] 81 | ) 82 | 83 | trainer = trainers.BaseTrainer(model, optimizer, criterion, sampler) 84 | 85 | trainer.fit(n_batch=3, n_epoch=3, valid_evaluator=evaluator, valid_per_epoch=1) 86 | 87 | df_eval = trainer.valid_scores 88 | 89 | self.assertEqual(df_eval.shape, (4, 8)) 90 | 91 | 92 | if __name__ == "__main__": 93 | unittest.main() 94 | --------------------------------------------------------------------------------