Repository: catalyst-team/catalyst (language: python)

Issue:
[WIP] RecSys Loss
The very first draft of Loss functions  for RecSys

## Before submitting (checklist)

- [ ] Was this discussed/approved via a Github issue? (no need for typos and docs improvements)
- [ ] Did you read the [contribution guide](https://github.com/catalyst-team/catalyst/blob/master/CONTRIBUTING.md)?
- [ ] Did you check the code style? `catalyst-make-codestyle && catalyst-check-codestyle` (`pip install -U catalyst-codestyle`).
- [ ] Did you make sure to update the docs? We use Google format for all the methods and classes.
- [ ] Did you check the docs with `make check-docs`?
- [ ] Did you write any new necessary tests?
- [ ] Did you check that your code passes the unit tests `pytest .` ? 
- [ ] Did you add your new functionality to the docs?
- [ ] Did you update the [CHANGELOG](https://github.com/catalyst-team/catalyst/blob/master/CHANGELOG.md)?
- [ ] Did you run [colab minimal CI/CD](https://colab.research.google.com/drive/1JCGTVvWlrIsLXMPRRRSWiAstSLic4nbA) with `latest` and `minimal` requirements?

<!-- For CHANGELOG separate each item in unreleased section by blank line to reduce collisions -->


## Description

<!-- Add a more detailed description of the changes if needed. -->


## Related Issue

<!-- If your PR refers to a related issue, link it here. -->


## Type of Change

<!-- Mark with an `x` all the checkboxes that apply (like `[x]`) -->

- [ ] Examples / docs / tutorials / contributors update
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Improvement (non-breaking change which improves an existing feature)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)


## PR review

Anyone in the community is free to review the PR once the tests have passed.
If we didn't discuss your PR in Github issues there's a high chance it will not be merged.

<!-- Thank you for your contribution! -->


### FAQ
Please review the FAQ before submitting an issue:
- [ ] I have read the [documentation and FAQ](https://catalyst-team.github.io/catalyst/)
- [ ] I have reviewed the [minimal examples section](https://github.com/catalyst-team/catalyst#minimal-examples)
- [ ] I have checked the [changelog](https://github.com/catalyst-team/catalyst/blob/master/CHANGELOG.md) for main framework updates
- [ ] I have read the [contribution guide](https://github.com/catalyst-team/catalyst/blob/master/CONTRIBUTING.md)
- [ ] I have joined [Catalyst slack (#__questions channel)](https://join.slack.com/t/catalyst-team-core/shared_invite/zt-d9miirnn-z86oKDzFMKlMG4fgFdZafw) for issue discussion



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93572b7a49..8455ea7b63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 
 ### Added
 
--
+- RecSys loss functions: `AdaptiveHingeLoss`, `BPRLoss`, `HingeLoss`, `LogisticLoss`, `WARPLoss` ([#1269](https://github.com/catalyst-team/catalyst/pull/1269))
 
 ### Changed
 
diff --git a/catalyst/contrib/nn/criterion/__init__.py b/catalyst/contrib/nn/criterion/__init__.py
index 7e4346020a..e8f1a6e826 100644
--- a/catalyst/contrib/nn/criterion/__init__.py
+++ b/catalyst/contrib/nn/criterion/__init__.py
@@ -37,3 +37,10 @@
     TripletPairwiseEmbeddingLoss,
 )
 from catalyst.contrib.nn.criterion.wing import WingLoss
+from catalyst.contrib.nn.criterion.recsys import (
+    AdaptiveHingeLoss,
+    BPRLoss,
+    HingeLoss,
+    LogisticLoss,
+    WARPLoss,
+)
diff --git a/catalyst/contrib/nn/criterion/recsys.py b/catalyst/contrib/nn/criterion/recsys.py
new file mode 100644
index 0000000000..ebba22dcb0
--- /dev/null
+++ b/catalyst/contrib/nn/criterion/recsys.py
@@ -0,0 +1,372 @@
+# flake8: noqa
+from typing import Optional
+
+import numpy as np
+import torch
+from torch import nn
+from torch.autograd import Function, Variable
+
+
+class Pointwise(nn.Module):
+    """Base class for pointwise loss functions.
+
+    Pointwise approaches look at a single document at a time in the loss function.
+    For a single documents predict it relevance to the query in time.
+    The score is independent for the order of the documents that are in the query's results.
+
+    Input space: single document d1
+    Output space: scores or relevant classes
+    """
+
+    def forward(self, score: torch.Tensor):
+        raise NotImplementedError()
+
+
+class PairwiseLoss(nn.Module):
+    """Base class for pairwise loss functions.
+
+    Pairwise approached looks at a pair of documents at a time in the loss function.
+    Given a pair of documents the algorithm try to come up with the optimal ordering
+    For that pair and compare it with the ground truth. The goal for the ranker is to
+    minimize the number of inversions in ranker.
+
+    Input space: pairs of documents (d1, d2)
+    Output space: preferences (yes/no) for a given doc. pair
+    """
+
+    @staticmethod
+    def _assert_equal_size(positive_score: torch.Tensor, negative_score: torch.Tensor) -> None:
+        if positive_score.size() != negative_score.size():
+            raise ValueError(f"Shape mismatch: {positive_score.size()}, {negative_score.size()}")
+
+    def forward(self, positive_score: torch.Tensor, negative_score: torch.Tensor):
+        raise NotImplementedError()
+
+
+class ListwiseLoss(nn.Module):
+    """Base class for listwise loss functions.
+
+    Listwise approach directly looks at the entire list of documents and comes up with
+    an optimal ordering for it.
+
+    Input space: document set
+    Output space: permutations - ranking of documents
+    """
+
+    @staticmethod
+    def _assert_equal_size(input_: torch.Tensor, target: torch.Tensor) -> None:
+        if input_.size() != target.size():
+            raise ValueError(f"Shape mismatch: {input_.size()}, {target.size()}")
+
+    def forward(self, input_: torch.Tensor, target: torch.Tensor):
+        raise NotImplementedError()
+
+
+class BPRLoss(PairwiseLoss):
+    """Bayesian Personalised Ranking loss function.
+
+    It has been proposed in `BPRLoss\: Bayesian Personalized Ranking from Implicit Feedback`_.
+
+    .. _BPRLoss\: Bayesian Personalized Ranking from Implicit Feedback:
+        https://arxiv.org/pdf/1205.2618.pdf
+
+    Args:
+        gamma (float): Small value to avoid division by zero. Default: ``1e-10``.
+
+    Example:
+
+    .. code-block:: python
+
+        import torch
+        from catalyst.contrib.nn.criterion import recsys
+
+        pos_score = torch.randn(3, requires_grad=True)
+        neg_score = torch.randn(3, requires_grad=True)
+
+        output = recsys.BPRLoss()(pos_score, neg_score)
+        output.backward()
+    """
+
+    def __init__(self, gamma=1e-10) -> None:
+        super().__init__()
+        self.gamma = gamma
+
+    def forward(self, positive_score: torch.Tensor, negative_score: torch.Tensor) -> torch.Tensor:
+        """Forward propagation method for the BPR loss.
+
+        Args:
+            positive_score: Tensor containing predictions for known positive items.
+            negative_score: Tensor containing predictions for sampled negative items.
+
+        Returns:
+            computed loss
+        """
+        self._assert_equal_size(positive_score, negative_score)
+
+        loss = -torch.log(self.gamma + torch.sigmoid(positive_score - negative_score))
+        return loss.mean()
+
+
+class LogisticLoss(PairwiseLoss):
+    """Logistic loss function.
+
+    Example:
+
+    .. code-block:: python
+
+        import torch
+        from catalyst.contrib.nn.criterion import recsys
+
+        pos_score = torch.randn(3, requires_grad=True)
+        neg_score = torch.randn(3, requires_grad=True)
+
+        output = recsys.LogisticLoss()(pos_score, neg_score)
+        output.backward()
+    """
+
+    def __init__(self,) -> None:
+        super().__init__()
+
+    def forward(self, positive_score: torch.Tensor, negative_score: torch.Tensor) -> torch.Tensor:
+        """Forward propagation method for the logistic loss.
+
+        Args:
+            positive_score: Tensor containing predictions for known positive items.
+            negative_score: Tensor containing predictions for sampled negative items.
+
+        Returns:
+            computed loss
+        """
+        self._assert_equal_size(positive_score, negative_score)
+
+        positives_loss = 1.0 - torch.sigmoid(positive_score)
+        negatives_loss = torch.sigmoid(negative_score)
+
+        loss = positives_loss + negatives_loss
+
+        return loss.mean()
+
+
+class HingeLoss(PairwiseLoss):
+    """Hinge loss function.
+
+    Example:
+
+    .. code-block:: python
+
+        import torch
+        from catalyst.contrib.nn.criterion import recsys
+
+        pos_score = torch.randn(3, requires_grad=True)
+        neg_score = torch.randn(3, requires_grad=True)
+
+        output = recsys.HingeLoss()(pos_score, neg_score)
+        output.backward()
+    """
+
+    def __init__(self,) -> None:
+        super().__init__()
+
+    def forward(self, positive_score: torch.Tensor, negative_score: torch.Tensor) -> torch.Tensor:
+        """Forward propagation method for the hinge loss.
+
+        Args:
+            positive_score: Tensor containing predictions for known positive items.
+            negative_score: Tensor containing predictions for sampled negative items.
+
+        Returns:
+            computed loss
+        """
+        self._assert_equal_size(positive_score, negative_score)
+
+        loss = torch.clamp(1.0 - (positive_score - negative_score), min=0.0)
+        return loss.mean()
+
+
+class AdaptiveHingeLoss(PairwiseLoss):
+    """Adaptive hinge loss function.
+
+    Takes a set of predictions for implicitly negative items, and selects those
+    that are highest, thus sampling those negatives that are closes to violating
+    the ranking implicit in the pattern of user interactions.
+
+    Example:
+
+    .. code-block:: python
+
+        import torch
+        from catalyst.contrib.nn.criterion import recsys
+
+        pos_score = torch.randn(3, requires_grad=True)
+        neg_scores = torch.randn(5, 3, requires_grad=True)
+
+        output = recsys.AdaptiveHingeLoss()(pos_score, neg_scores)
+        output.backward()
+    """
+
+    def __init__(self) -> None:
+        super().__init__()
+        self._hingeloss = HingeLoss()
+
+    def forward(self, positive_score: torch.Tensor, negative_scores: torch.Tensor) -> torch.Tensor:
+        """Forward propagation method for the adaptive hinge loss.
+
+        Args:
+            positive_score: Tensor containing predictions for known positive items.
+            negative_scores: Iterable of tensors containing predictions for sampled negative items.
+                More tensors increase the likelihood of finding ranking-violating pairs,
+                but risk overfitting.
+
+        Returns:
+            computed loss
+        """
+        self._assert_equal_size(positive_score, negative_scores[0])
+
+        highest_negative_score, _ = torch.max(negative_scores, 0)
+
+        return self._hingeloss.forward(positive_score, highest_negative_score.squeeze())
+
+
+class WARP(Function):
+    """Autograd function of WARP loss."""
+
+    @staticmethod
+    def forward(
+        ctx: nn.Module,
+        input_: torch.Tensor,
+        target: torch.Tensor,
+        max_num_trials: Optional[int] = None,
+    ):
+
+        batch_size = target.size()[0]
+        if max_num_trials is None:
+            max_num_trials = target.size()[1] - 1
+
+        positive_indices = torch.zeros(input_.size())
+        negative_indices = torch.zeros(input_.size())
+        L = torch.zeros(input_.size()[0])
+
+        all_labels_idx = torch.arange(target.size()[1])
+
+        Y = float(target.size()[1])
+        J = torch.nonzero(target)
+
+        for i in range(batch_size):
+
+            msk = torch.ones(target.size()[1], dtype=bool)
+
+            # Find the positive label for this example
+            j = J[i, 1]
+            positive_indices[i, j] = 1
+            msk[j] = False
+
+            # initialize the sample_score_margin
+            sample_score_margin = -1
+            num_trials = 0
+
+            neg_labels_idx = all_labels_idx[msk]
+
+            while (sample_score_margin < 0) and (num_trials < max_num_trials):  # type: ignore
+
+                # randomly sample a negative label, example from here:
+                # https://github.com/pytorch/pytorch/issues/16897
+                neg_idx = neg_labels_idx[torch.randint(0, neg_labels_idx.size(0), (1,))]
+                msk[neg_idx] = False
+                neg_labels_idx = all_labels_idx[msk]
+
+                num_trials += 1
+                # calculate the score margin
+                sample_score_margin = 1 + input_[i, neg_idx] - input_[i, j]
+
+            if sample_score_margin < 0:
+                # checks if no violating examples have been found
+                continue
+            else:
+                loss_weight = np.log(np.floor((Y - 1) / (num_trials)))
+                L[i] = loss_weight
+                negative_indices[i, neg_idx] = 1  # type: ignore
+
+        loss = L * (
+            1
+            - torch.sum(positive_indices * input_, dim=1)
+            + torch.sum(negative_indices * input_, dim=1)
+        )
+
+        ctx.save_for_backward(input_, target)
+        ctx.L = L
+        ctx.positive_indices = positive_indices
+        ctx.negative_indices = negative_indices
+
+        return torch.sum(loss, dim=0, keepdim=True)
+
+    # This function has only a single output, so it gets only one gradient
+    @staticmethod
+    def backward(ctx, grad_output):
+        input_, target = ctx.saved_variables
+        L = Variable(torch.unsqueeze(ctx.L, 1), requires_grad=False)
+
+        positive_indices = Variable(ctx.positive_indices, requires_grad=False)
+        negative_indices = Variable(ctx.negative_indices, requires_grad=False)
+        grad_input = grad_output * L * (negative_indices - positive_indices)
+
+        return grad_input, None, None
+
+
+class WARPLoss(ListwiseLoss):
+    """Weighted Approximate-Rank Pairwise (WARP) loss function.
+
+    It has been proposed in `WSABIE\: Scaling Up To Large Vocabulary Image Annotation`_ paper.
+
+    .. _WSABIE\: Scaling Up To Large Vocabulary Image Annotation:
+        https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/37180.pdf
+
+    WARP loss randomly sample output labels of a model, until it finds a pair
+    which it knows are wrongly labelled and will then only apply an update to
+    these two incorrectly labelled examples.
+
+    Adapted from:
+    https://github.com/gabrieltseng/datascience-projects/blob/master/misc/warp.py
+
+    Args:
+        max_num_trials: Number of attempts allowed to find a violating negative example.
+            In practice it means that we optimize for ranks 1 to max_num_trials-1.
+
+    Example:
+
+    .. code-block:: python
+
+        import torch
+        from catalyst.contrib.nn.criterion import recsys
+
+        input_ = torch.randn(5, 3, requires_grad=True)
+        target = torch.randn(5, 3, requires_grad=True)
+
+        output = recsys.WARPLoss()(input_, target)
+        output.backward()
+    """
+
+    def __init__(self, max_num_trials: Optional[int] = None):
+        super().__init__()
+        self.max_num_trials = max_num_trials
+
+    def forward(self, input_: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
+        """Forward propagation method for the WARP loss.
+
+        Args:
+            input_: Iterable of tensors containing predictions for all items.
+            target: Iterable of tensors containing true labels for all items.
+
+        Returns:
+            computed loss
+        """
+        self._assert_equal_size(input_, target)
+        return WARP.apply(input_, target, self.max_num_trials)
+
+
+__all__ = [
+    "AdaptiveHingeLoss",
+    "BPRLoss",
+    "HingeLoss",
+    "LogisticLoss",
+    "WARPLoss",
+]
diff --git a/docs/api/contrib.rst b/docs/api/contrib.rst
index a78b43bdb0..a4c4e1d05c 100644
--- a/docs/api/contrib.rst
+++ b/docs/api/contrib.rst
@@ -181,6 +181,44 @@ WingLoss
     :undoc-members:
     :show-inheritance:
 
+RecSys
+""""""
+
+AdaptiveHingeLoss
+*****************
+.. autoclass:: catalyst.contrib.nn.criterion.recsys.AdaptiveHingeLoss
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+BPRLoss
+*******
+.. autoclass:: catalyst.contrib.nn.criterion.recsys.BPRLoss
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+HingeLoss
+*********
+.. autoclass:: catalyst.contrib.nn.criterion.recsys.HingeLoss
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+LogisticLoss
+************
+.. autoclass:: catalyst.contrib.nn.criterion.recsys.LogisticLoss
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+WARPLoss
+********
+.. autoclass:: catalyst.contrib.nn.criterion.recsys.WARPLoss
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
 
 Modules
 ~~~~~~~~~~~~~~~~


Test patch:
diff --git a/tests/catalyst/contrib/nn/test_criterion.py b/tests/catalyst/contrib/nn/test_criterion.py
index 69cc21e2cc..f0dbde21af 100644
--- a/tests/catalyst/contrib/nn/test_criterion.py
+++ b/tests/catalyst/contrib/nn/test_criterion.py
@@ -1,4 +1,5 @@
 # flake8: noqa
+
 import numpy as np
 import pytest
 import torch
@@ -29,6 +30,108 @@ def test_criterion_init():
             assert instance is not None
 
 
+def test_bpr_loss():
+    """Testing for Bayesian Personalized Ranking"""
+    from catalyst.contrib.nn.criterion.recsys import BPRLoss
+
+    loss = BPRLoss()
+
+    rand = torch.rand(1000, dtype=torch.float)
+    assert float(loss.forward(rand, rand)) == pytest.approx(0.6931, 0.001)  # log of 0.5
+
+    pos, neg = torch.Tensor([1000, 1000, 1000, 1000]), torch.Tensor([0, 0, 0, 0])
+    assert float(loss.forward(pos, neg)) == pytest.approx(0, 0.001)  # log of 1
+
+    neg, pos = torch.Tensor([1000, 1000, 1000, 1000]), torch.Tensor([0, 0, 0, 0])
+    log_gamma = float(torch.log(torch.Tensor([loss.gamma])))
+    assert float(loss.forward(pos, neg)) == pytest.approx(-log_gamma, 0.001)  # log of 0 with gamma
+
+
+def test_warp_loss():
+    from catalyst.contrib.nn.criterion.recsys import WARPLoss
+
+    loss = WARPLoss(max_num_trials=1)
+
+    input_ = torch.Tensor([[0, 0, 0, 1]])
+    target = torch.Tensor([[0, 0, 0, 1]])
+    assert float(loss.forward(input_, target)) == pytest.approx(0, 0.001)
+
+    x2_input_ = torch.stack((input_.squeeze(0), input_.squeeze(0)))
+    x2_target = torch.stack((target.squeeze(0), target.squeeze(0)))
+    assert float(loss.forward(x2_input_, x2_target)) == pytest.approx(0, 0.001)
+
+    input_ = torch.Tensor([[0, 0, 0, 0]])
+    target = torch.Tensor([[0, 0, 0, 1]])
+    assert float(loss.forward(input_, target)) == pytest.approx(1.0986, 0.001)
+
+    x2_input_ = torch.stack((input_.squeeze(0), input_.squeeze(0)))
+    x2_target = torch.stack((target.squeeze(0), target.squeeze(0)))
+    assert float(loss.forward(x2_input_, x2_target)) == pytest.approx(2 * 1.0986, 0.001)
+
+    input_ = torch.Tensor([[0.5, 0.5, 0.5, 1]])
+    target = torch.Tensor([[0, 0, 0, 1]])
+    assert float(loss.forward(input_, target)) == pytest.approx(0.5493, 0.001)
+
+    input_ = torch.Tensor([[0.5, 0, 0.5, 1]])
+    target = torch.Tensor([[0, 0, 0, 1]])
+    loss_value = float(loss.forward(input_, target))
+    assert loss_value == pytest.approx(0.5493, 0.001) or loss_value == pytest.approx(0, 0.001)
+
+
+def test_logistic_loss():
+    from catalyst.contrib.nn.criterion.recsys import LogisticLoss
+
+    loss = LogisticLoss()
+
+    rand = torch.rand(1000)
+    assert float(loss.forward(rand, rand)) == pytest.approx(1, 0.001)  # neg relu of 1
+
+    pos, neg = torch.Tensor([1000, 1000, 1000, 1000]), torch.Tensor([0, 0, 0, 0])
+    assert float(loss.forward(pos, neg)) == pytest.approx(0.5, 0.001)  # relu of large negative
+
+    neg, pos = torch.Tensor([1000, 1000, 1000, 1000]), torch.Tensor([0, 0, 0, 0])
+    assert float(loss.forward(pos, neg)) == pytest.approx(1.5, 0.001)  # nerelu of large positive
+
+
+def test_hinge_loss():
+    from catalyst.contrib.nn.criterion.recsys import HingeLoss
+
+    loss = HingeLoss()
+
+    rand = torch.rand(1000)
+    assert float(loss.forward(rand, rand)) == pytest.approx(1, 0.001)  # relu of 0
+
+    pos, neg = torch.Tensor([1, 1, 1, 1]), torch.Tensor([0, 0, 0, 0])
+    assert float(loss.forward(pos, neg)) == pytest.approx(0, 0.001)  # relu of 1
+
+    pos, neg = torch.Tensor([1000, 1000, 1000, 1000]), torch.Tensor([0, 0, 0, 0])
+    assert float(loss.forward(pos, neg)) == pytest.approx(0, 0.001)  # relu of large negative
+
+    neg, pos = torch.Tensor([1000, 1000, 1000, 1000]), torch.Tensor([0, 0, 0, 0])
+    assert float(loss.forward(pos, neg)) == pytest.approx(1001, 0.001)  # nerelu of large positive
+
+
+def test_adaptive_hinge_loss():
+    from catalyst.contrib.nn.criterion.recsys import AdaptiveHingeLoss
+
+    loss = AdaptiveHingeLoss()
+
+    rand = torch.rand(1000)
+    ones = torch.ones(1000)
+    assert float(loss.forward(rand, rand.unsqueeze(0))) == pytest.approx(1, 0.001)  # relu of 0
+    assert float(loss.forward(rand, torch.stack((rand, rand)))) == pytest.approx(1, 0.001)
+    assert float(loss.forward(ones, torch.stack((rand, ones)))) == pytest.approx(1, 0.001)
+
+    pos, neg = torch.Tensor([1, 1, 1, 1]), torch.Tensor([0, 0, 0, 0]).unsqueeze(0)
+    assert float(loss.forward(pos, neg)) == pytest.approx(0, 0.001)  # relu of 1
+
+    pos, neg = torch.Tensor([1000, 1000, 1000, 1000]), torch.Tensor([0, 0, 0, 0]).unsqueeze(0)
+    assert float(loss.forward(pos, neg)) == pytest.approx(0, 0.001)  # relu of large negative
+
+    pos, neg = torch.Tensor([0, 0, 0, 0]), torch.Tensor([1000, 1000, 1000, 1000]).unsqueeze(0)
+    assert float(loss.forward(pos, neg)) == pytest.approx(1001, 0.001)  # nerelu of large positive
+
+
 @pytest.mark.parametrize(
     "embeddings_left,embeddings_right,offdiag_lambda,eps,true_value",
     (