├── .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 | 
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 | 
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 | 
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 | 
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 | " nDCG@3 | \n",
189 | " MAP@3 | \n",
190 | " Recall@3 | \n",
191 | " nDCG@5 | \n",
192 | " MAP@5 | \n",
193 | " Recall@5 | \n",
194 | " epoch | \n",
195 | " loss | \n",
196 | "
\n",
197 | " \n",
198 | " \n",
199 | " \n",
200 | " 0 | \n",
201 | " 0.007423 | \n",
202 | " 0.012902 | \n",
203 | " 0.001568 | \n",
204 | " 0.008634 | \n",
205 | " 0.017922 | \n",
206 | " 0.002917 | \n",
207 | " 0 | \n",
208 | " NaN | \n",
209 | "
\n",
210 | " \n",
211 | " 0 | \n",
212 | " 0.042387 | \n",
213 | " 0.070078 | \n",
214 | " 0.006008 | \n",
215 | " 0.046897 | \n",
216 | " 0.084353 | \n",
217 | " 0.011537 | \n",
218 | " 10 | \n",
219 | " 0.432954 | \n",
220 | "
\n",
221 | " \n",
222 | " 0 | \n",
223 | " 0.202032 | \n",
224 | " 0.291888 | \n",
225 | " 0.044877 | \n",
226 | " 0.202131 | \n",
227 | " 0.311188 | \n",
228 | " 0.073077 | \n",
229 | " 20 | \n",
230 | " 0.274121 | \n",
231 | "
\n",
232 | " \n",
233 | "
\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 | " nDCG@3 | \n",
349 | " MAP@3 | \n",
350 | " Recall@3 | \n",
351 | " nDCG@5 | \n",
352 | " MAP@5 | \n",
353 | " Recall@5 | \n",
354 | " epoch | \n",
355 | " loss | \n",
356 | "
\n",
357 | " \n",
358 | " \n",
359 | " \n",
360 | " 0 | \n",
361 | " 0.020385 | \n",
362 | " 0.036939 | \n",
363 | " 0.001419 | \n",
364 | " 0.018477 | \n",
365 | " 0.041640 | \n",
366 | " 0.002321 | \n",
367 | " 0 | \n",
368 | " NaN | \n",
369 | "
\n",
370 | " \n",
371 | " 0 | \n",
372 | " 0.059936 | \n",
373 | " 0.091552 | \n",
374 | " 0.004546 | \n",
375 | " 0.068632 | \n",
376 | " 0.110379 | \n",
377 | " 0.009836 | \n",
378 | " 10 | \n",
379 | " 0.471340 | \n",
380 | "
\n",
381 | " \n",
382 | " 0 | \n",
383 | " 0.273629 | \n",
384 | " 0.369123 | \n",
385 | " 0.034714 | \n",
386 | " 0.272501 | \n",
387 | " 0.385817 | \n",
388 | " 0.059150 | \n",
389 | " 20 | \n",
390 | " 0.287908 | \n",
391 | "
\n",
392 | " \n",
393 | "
\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 | " nDCG@3 | \n",
508 | " MAP@3 | \n",
509 | " Recall@3 | \n",
510 | " nDCG@5 | \n",
511 | " MAP@5 | \n",
512 | " Recall@5 | \n",
513 | " epoch | \n",
514 | " loss | \n",
515 | "
\n",
516 | " \n",
517 | " \n",
518 | " \n",
519 | " 0 | \n",
520 | " 0.017032 | \n",
521 | " 0.029692 | \n",
522 | " 0.002178 | \n",
523 | " 0.017122 | \n",
524 | " 0.036462 | \n",
525 | " 0.003362 | \n",
526 | " 0 | \n",
527 | " NaN | \n",
528 | "
\n",
529 | " \n",
530 | " 0 | \n",
531 | " 0.074296 | \n",
532 | " 0.115500 | \n",
533 | " 0.005293 | \n",
534 | " 0.075128 | \n",
535 | " 0.125672 | \n",
536 | " 0.008741 | \n",
537 | " 10 | \n",
538 | " 0.469893 | \n",
539 | "
\n",
540 | " \n",
541 | " 0 | \n",
542 | " 0.276036 | \n",
543 | " 0.380877 | \n",
544 | " 0.035444 | \n",
545 | " 0.276884 | \n",
546 | " 0.395498 | \n",
547 | " 0.060833 | \n",
548 | " 20 | \n",
549 | " 0.281246 | \n",
550 | "
\n",
551 | " \n",
552 | "
\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 | " nDCG@3 | \n",
684 | " MAP@3 | \n",
685 | " Recall@3 | \n",
686 | " nDCG@5 | \n",
687 | " MAP@5 | \n",
688 | " Recall@5 | \n",
689 | " epoch | \n",
690 | " loss | \n",
691 | "
\n",
692 | " \n",
693 | " \n",
694 | " \n",
695 | " 0 | \n",
696 | " 0.012986 | \n",
697 | " 0.022004 | \n",
698 | " 0.001167 | \n",
699 | " 0.013278 | \n",
700 | " 0.028190 | \n",
701 | " 0.002582 | \n",
702 | " 0 | \n",
703 | " NaN | \n",
704 | "
\n",
705 | " \n",
706 | " 0 | \n",
707 | " 0.207166 | \n",
708 | " 0.293655 | \n",
709 | " 0.019897 | \n",
710 | " 0.200064 | \n",
711 | " 0.305768 | \n",
712 | " 0.033076 | \n",
713 | " 10 | \n",
714 | " 1.043901 | \n",
715 | "
\n",
716 | " \n",
717 | " 0 | \n",
718 | " 0.356546 | \n",
719 | " 0.484093 | \n",
720 | " 0.052573 | \n",
721 | " 0.326033 | \n",
722 | " 0.484409 | \n",
723 | " 0.074481 | \n",
724 | " 20 | \n",
725 | " 1.001474 | \n",
726 | "
\n",
727 | " \n",
728 | "
\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 | " nDCG@3 | \n",
892 | " MAP@3 | \n",
893 | " Recall@3 | \n",
894 | " nDCG@5 | \n",
895 | " MAP@5 | \n",
896 | " Recall@5 | \n",
897 | " epoch | \n",
898 | " loss | \n",
899 | "
\n",
900 | " \n",
901 | " \n",
902 | " \n",
903 | " 0 | \n",
904 | " 0.016209 | \n",
905 | " 0.030399 | \n",
906 | " 0.001955 | \n",
907 | " 0.01690 | \n",
908 | " 0.038264 | \n",
909 | " 0.003477 | \n",
910 | " 0 | \n",
911 | " NaN | \n",
912 | "
\n",
913 | " \n",
914 | " 0 | \n",
915 | " 0.051292 | \n",
916 | " 0.078208 | \n",
917 | " 0.004285 | \n",
918 | " 0.05536 | \n",
919 | " 0.094836 | \n",
920 | " 0.007510 | \n",
921 | " 10 | \n",
922 | " 0.572125 | \n",
923 | "
\n",
924 | " \n",
925 | " 0 | \n",
926 | " 0.233268 | \n",
927 | " 0.322552 | \n",
928 | " 0.030232 | \n",
929 | " 0.23401 | \n",
930 | " 0.336276 | \n",
931 | " 0.049536 | \n",
932 | " 20 | \n",
933 | " 0.430135 | \n",
934 | "
\n",
935 | " \n",
936 | "
\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 | " 5 | \n",
1023 | " 6 | \n",
1024 | " 7 | \n",
1025 | " 8 | \n",
1026 | " 9 | \n",
1027 | " 10 | \n",
1028 | " 11 | \n",
1029 | " 12 | \n",
1030 | " 13 | \n",
1031 | " 14 | \n",
1032 | " 15 | \n",
1033 | " 16 | \n",
1034 | " 17 | \n",
1035 | " 18 | \n",
1036 | " 19 | \n",
1037 | " 20 | \n",
1038 | " 21 | \n",
1039 | " 22 | \n",
1040 | " 23 | \n",
1041 | "
\n",
1042 | " \n",
1043 | " \n",
1044 | " \n",
1045 | " 0 | \n",
1046 | " 0 | \n",
1047 | " 0 | \n",
1048 | " 0 | \n",
1049 | " 1 | \n",
1050 | " 1 | \n",
1051 | " 1 | \n",
1052 | " 0 | \n",
1053 | " 0 | \n",
1054 | " 0 | \n",
1055 | " 0 | \n",
1056 | " 0 | \n",
1057 | " 0 | \n",
1058 | " 0 | \n",
1059 | " 0 | \n",
1060 | " 0 | \n",
1061 | " 0 | \n",
1062 | " 0 | \n",
1063 | " 0 | \n",
1064 | " 0 | \n",
1065 | "
\n",
1066 | " \n",
1067 | " 1 | \n",
1068 | " 0 | \n",
1069 | " 1 | \n",
1070 | " 1 | \n",
1071 | " 0 | \n",
1072 | " 0 | \n",
1073 | " 0 | \n",
1074 | " 0 | \n",
1075 | " 0 | \n",
1076 | " 0 | \n",
1077 | " 0 | \n",
1078 | " 0 | \n",
1079 | " 0 | \n",
1080 | " 0 | \n",
1081 | " 0 | \n",
1082 | " 0 | \n",
1083 | " 0 | \n",
1084 | " 1 | \n",
1085 | " 0 | \n",
1086 | " 0 | \n",
1087 | "
\n",
1088 | " \n",
1089 | " 2 | \n",
1090 | " 0 | \n",
1091 | " 0 | \n",
1092 | " 0 | \n",
1093 | " 0 | \n",
1094 | " 0 | \n",
1095 | " 0 | \n",
1096 | " 0 | \n",
1097 | " 0 | \n",
1098 | " 0 | \n",
1099 | " 0 | \n",
1100 | " 0 | \n",
1101 | " 0 | \n",
1102 | " 0 | \n",
1103 | " 0 | \n",
1104 | " 0 | \n",
1105 | " 0 | \n",
1106 | " 1 | \n",
1107 | " 0 | \n",
1108 | " 0 | \n",
1109 | "
\n",
1110 | " \n",
1111 | " 3 | \n",
1112 | " 0 | \n",
1113 | " 1 | \n",
1114 | " 0 | \n",
1115 | " 0 | \n",
1116 | " 0 | \n",
1117 | " 1 | \n",
1118 | " 0 | \n",
1119 | " 0 | \n",
1120 | " 1 | \n",
1121 | " 0 | \n",
1122 | " 0 | \n",
1123 | " 0 | \n",
1124 | " 0 | \n",
1125 | " 0 | \n",
1126 | " 0 | \n",
1127 | " 0 | \n",
1128 | " 0 | \n",
1129 | " 0 | \n",
1130 | " 0 | \n",
1131 | "
\n",
1132 | " \n",
1133 | " 4 | \n",
1134 | " 0 | \n",
1135 | " 0 | \n",
1136 | " 0 | \n",
1137 | " 0 | \n",
1138 | " 0 | \n",
1139 | " 0 | \n",
1140 | " 1 | \n",
1141 | " 0 | \n",
1142 | " 1 | \n",
1143 | " 0 | \n",
1144 | " 0 | \n",
1145 | " 0 | \n",
1146 | " 0 | \n",
1147 | " 0 | \n",
1148 | " 0 | \n",
1149 | " 0 | \n",
1150 | " 1 | \n",
1151 | " 0 | \n",
1152 | " 0 | \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 | " 1677 | \n",
1178 | " 0 | \n",
1179 | " 0 | \n",
1180 | " 0 | \n",
1181 | " 0 | \n",
1182 | " 0 | \n",
1183 | " 0 | \n",
1184 | " 0 | \n",
1185 | " 0 | \n",
1186 | " 1 | \n",
1187 | " 0 | \n",
1188 | " 0 | \n",
1189 | " 0 | \n",
1190 | " 0 | \n",
1191 | " 0 | \n",
1192 | " 0 | \n",
1193 | " 0 | \n",
1194 | " 0 | \n",
1195 | " 0 | \n",
1196 | " 0 | \n",
1197 | "
\n",
1198 | " \n",
1199 | " 1678 | \n",
1200 | " 0 | \n",
1201 | " 0 | \n",
1202 | " 0 | \n",
1203 | " 0 | \n",
1204 | " 0 | \n",
1205 | " 0 | \n",
1206 | " 0 | \n",
1207 | " 0 | \n",
1208 | " 0 | \n",
1209 | " 0 | \n",
1210 | " 0 | \n",
1211 | " 0 | \n",
1212 | " 0 | \n",
1213 | " 0 | \n",
1214 | " 1 | \n",
1215 | " 0 | \n",
1216 | " 1 | \n",
1217 | " 0 | \n",
1218 | " 0 | \n",
1219 | "
\n",
1220 | " \n",
1221 | " 1679 | \n",
1222 | " 0 | \n",
1223 | " 0 | \n",
1224 | " 0 | \n",
1225 | " 0 | \n",
1226 | " 0 | \n",
1227 | " 0 | \n",
1228 | " 0 | \n",
1229 | " 0 | \n",
1230 | " 1 | \n",
1231 | " 0 | \n",
1232 | " 0 | \n",
1233 | " 0 | \n",
1234 | " 0 | \n",
1235 | " 0 | \n",
1236 | " 1 | \n",
1237 | " 0 | \n",
1238 | " 0 | \n",
1239 | " 0 | \n",
1240 | " 0 | \n",
1241 | "
\n",
1242 | " \n",
1243 | " 1680 | \n",
1244 | " 0 | \n",
1245 | " 0 | \n",
1246 | " 0 | \n",
1247 | " 0 | \n",
1248 | " 0 | \n",
1249 | " 1 | \n",
1250 | " 0 | \n",
1251 | " 0 | \n",
1252 | " 0 | \n",
1253 | " 0 | \n",
1254 | " 0 | \n",
1255 | " 0 | \n",
1256 | " 0 | \n",
1257 | " 0 | \n",
1258 | " 0 | \n",
1259 | " 0 | \n",
1260 | " 0 | \n",
1261 | " 0 | \n",
1262 | " 0 | \n",
1263 | "
\n",
1264 | " \n",
1265 | " 1681 | \n",
1266 | " 0 | \n",
1267 | " 0 | \n",
1268 | " 0 | \n",
1269 | " 0 | \n",
1270 | " 0 | \n",
1271 | " 0 | \n",
1272 | " 0 | \n",
1273 | " 0 | \n",
1274 | " 1 | \n",
1275 | " 0 | \n",
1276 | " 0 | \n",
1277 | " 0 | \n",
1278 | " 0 | \n",
1279 | " 0 | \n",
1280 | " 0 | \n",
1281 | " 0 | \n",
1282 | " 0 | \n",
1283 | " 0 | \n",
1284 | " 0 | \n",
1285 | "
\n",
1286 | " \n",
1287 | "
\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 | " nDCG@3 | \n",
1419 | " MAP@3 | \n",
1420 | " Recall@3 | \n",
1421 | " nDCG@5 | \n",
1422 | " MAP@5 | \n",
1423 | " Recall@5 | \n",
1424 | " epoch | \n",
1425 | " loss | \n",
1426 | "
\n",
1427 | " \n",
1428 | " \n",
1429 | " \n",
1430 | " 0 | \n",
1431 | " 0.010119 | \n",
1432 | " 0.017851 | \n",
1433 | " 0.001563 | \n",
1434 | " 0.009928 | \n",
1435 | " 0.021907 | \n",
1436 | " 0.002435 | \n",
1437 | " 0 | \n",
1438 | " NaN | \n",
1439 | "
\n",
1440 | " \n",
1441 | " 0 | \n",
1442 | " 0.102117 | \n",
1443 | " 0.145104 | \n",
1444 | " 0.013161 | \n",
1445 | " 0.105093 | \n",
1446 | " 0.160152 | \n",
1447 | " 0.023478 | \n",
1448 | " 10 | \n",
1449 | " 0.574888 | \n",
1450 | "
\n",
1451 | " \n",
1452 | " 0 | \n",
1453 | " 0.243347 | \n",
1454 | " 0.335101 | \n",
1455 | " 0.054578 | \n",
1456 | " 0.234080 | \n",
1457 | " 0.344372 | \n",
1458 | " 0.080134 | \n",
1459 | " 20 | \n",
1460 | " 0.400818 | \n",
1461 | "
\n",
1462 | " \n",
1463 | "
\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 nDCG@3 | \n MAP@3 | \n Recall@3 | \n epoch | \n loss | \n
\n \n \n \n 0 | \n 0.395128 | \n 0.529074 | \n 0.109667 | \n 0 | \n NaN | \n
\n \n 0 | \n 0.385569 | \n 0.518293 | \n 0.106805 | \n 5 | \n 180.194864 | \n
\n \n 0 | \n 0.370207 | \n 0.504065 | \n 0.102159 | \n 10 | \n 77.848219 | \n
\n \n 0 | \n 0.356891 | \n 0.487186 | \n 0.097600 | \n 15 | \n 42.113500 | \n
\n \n
\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 nDCG@3 | \n MAP@3 | \n Recall@3 | \n epoch | \n loss | \n
\n \n \n \n 0 | \n 0.404477 | \n 0.545157 | \n 0.111933 | \n 0 | \n NaN | \n
\n \n 0 | \n 0.398088 | \n 0.544185 | \n 0.112874 | \n 5 | \n 0.308484 | \n
\n \n 0 | \n 0.392882 | \n 0.537027 | \n 0.112438 | \n 10 | \n 0.186894 | \n
\n \n 0 | \n 0.393925 | \n 0.533581 | \n 0.112751 | \n 15 | \n 0.145944 | \n
\n \n
\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 |
--------------------------------------------------------------------------------