Repository: Lightning-AI/torchmetrics

Issue:
Functional nDCG can not be called with negative relevance targets
## 🐛 Bug

<!-- A clear and concise description of what the bug is. -->
NDCG does not work with non-binary values because ```_check_retrieval_functional_inputs``` has a wrong check for binary array contents.

### To Reproduce
Steps to reproduce the behavior:

Call ```torchmetrics.functional.retrieval_normalized_dcg``` with negative relevance targets.

<!-- If you have a code sample, error messages, stack traces, please provide it here as well -->
```
torchmetrics_bugreport_ndcg.py", line 6, in <module>
    ndcg_torchmetrics = retrieval_normalized_dcg(score_array, qrel_array, k=5)
torchmetrics/functional/retrieval/ndcg.py", line 49, in retrieval_normalized_dcg
    preds, target = _check_retrieval_functional_inputs(preds, target, allow_non_binary_target=True)
torchmetrics/utilities/checks.py", line 544, in _check_retrieval_functional_inputs
    raise ValueError("`target` must contain `binary` values")
ValueError: `target` must contain `binary` values

```
#### Code sample
```
import torch
from torchmetrics.functional import retrieval_normalized_dcg

qrel_array = torch.tensor([[0, 1, -1, 0], [0, 2, 0, 1], [0, 1, 0, 2]])
score_array = torch.tensor([[0, 0.5, 0, 0.5], [0, 0.5, 0, 0.5], [0, 0.5, 0, 0.5]])
ndcg_torchmetrics = retrieval_normalized_dcg(score_array, qrel_array, k=5)

```
Should work but expects the targets to be in between 0 and 1 and thus throws an exception.

<!-- Ideally attach a minimal code sample to reproduce the decried issue.
Minimal means having the shortest code but still preserving the bug. -->

### Environment

 - PyTorch Version: 1.9.0
 - OS (e.g., Linux): Manjaro
 - How you installed PyTorch (`conda`, `pip`, source): conda
 - Build command you used (if compiling from source): None
 - Python version: 3.9
 - CUDA/cuDNN version: 11.4
 - GPU models and configuration: -
 - Any other relevant information: -

### Additional context

<!-- Add any other context about the problem here. -->



Discussion:

Resolving patch:
diff --git a/torchmetrics/functional/retrieval/ndcg.py b/torchmetrics/functional/retrieval/ndcg.py
--- a/torchmetrics/functional/retrieval/ndcg.py
+++ b/torchmetrics/functional/retrieval/ndcg.py
@@ -14,14 +14,14 @@
 from typing import Optional
 
 import torch
-from torch import Tensor, tensor
+from torch import Tensor
 
 from torchmetrics.utilities.checks import _check_retrieval_functional_inputs
 
 
 def _dcg(target: Tensor) -> Tensor:
     denom = torch.log2(torch.arange(target.shape[-1], device=target.device) + 2.0)
-    return (target / denom).sum()
+    return (target / denom).sum(dim=-1)
 
 
 def retrieval_normalized_dcg(preds: Tensor, target: Tensor, k: Optional[int] = None) -> Tensor:
@@ -55,10 +55,15 @@ def retrieval_normalized_dcg(preds: Tensor, target: Tensor, k: Optional[int] = N
     if not (isinstance(k, int) and k > 0):
         raise ValueError("`k` has to be a positive integer or None")
 
-    if not target.sum():
-        return tensor(0.0, device=preds.device)
-
     sorted_target = target[torch.argsort(preds, dim=-1, descending=True)][:k]
     ideal_target = torch.sort(target, descending=True)[0][:k]
 
-    return _dcg(sorted_target) / _dcg(ideal_target)
+    ideal_dcg = _dcg(ideal_target)
+    target_dcg = _dcg(sorted_target)
+
+    # filter undefined scores
+    all_irrelevant = ideal_dcg == 0
+    target_dcg[all_irrelevant] = 0
+    target_dcg[~all_irrelevant] /= ideal_dcg[~all_irrelevant]
+
+    return target_dcg.mean()
diff --git a/torchmetrics/utilities/checks.py b/torchmetrics/utilities/checks.py
--- a/torchmetrics/utilities/checks.py
+++ b/torchmetrics/utilities/checks.py
@@ -531,7 +531,7 @@ def _check_retrieval_functional_inputs(
     if not preds.is_floating_point():
         raise ValueError("`preds` must be a tensor of floats")
 
-    if not allow_non_binary_target and target.max() > 1 or target.min() < 0:
+    if not allow_non_binary_target and (target.max() > 1 or target.min() < 0):
         raise ValueError("`target` must contain `binary` values")
 
     return preds.float().flatten(), target.long().flatten()
@@ -575,7 +575,7 @@ def _check_retrieval_inputs(
     if target.dtype not in (torch.bool, torch.long, torch.int):
         raise ValueError("`target` must be a tensor of booleans or integers")
 
-    if not allow_non_binary_target and target.max() > 1 or target.min() < 0:
+    if not allow_non_binary_target and (target.max() > 1 or target.min() < 0):
         raise ValueError("`target` must contain `binary` values")
 
     return indexes.long().flatten(), preds.float().flatten(), target.long().flatten()


Test patch:
diff --git a/tests/retrieval/helpers.py b/tests/retrieval/helpers.py
--- a/tests/retrieval/helpers.py
+++ b/tests/retrieval/helpers.py
@@ -137,6 +137,19 @@ def _concat_tests(*tests: Tuple[Dict]) -> Dict:
     ]
 )
 
+_errors_test_functional_metric_parameters_with_nonbinary = dict(
+    argnames="preds,target,message,metric_args",
+    argvalues=[
+        # check input shapes are consistent (func)
+        (_irs_mis_sz_fn.preds, _irs_mis_sz_fn.target, "`preds` and `target` must be of the same shape", {}),
+        # check input tensors are not empty
+        (_irs_empty.preds, _irs_empty.target, "`preds` and `target` must be non-empty and non-scalar tensors", {}),
+        # check on input dtypes
+        (_irs.preds.bool(), _irs.target, "`preds` must be a tensor of floats", {}),
+        (_irs.preds, _irs.target.float(), "`target` must be a tensor of booleans or integers", {}),
+    ]
+)
+
 _errors_test_functional_metric_parameters_k = dict(
     argnames="preds,target,message,metric_args",
     argvalues=[
@@ -167,6 +180,42 @@ def _concat_tests(*tests: Tuple[Dict]) -> Dict:
     ]
 )
 
+_errors_test_class_metric_parameters_with_nonbinary = dict(
+    argnames="indexes,preds,target,message,metric_args",
+    argvalues=[
+        (None, _irs.preds, _irs.target, "`indexes` cannot be None", dict(empty_target_action="error")),
+        # check when input arguments are invalid
+        (
+            _irs.indexes, _irs.preds, _irs.target, "`empty_target_action` received a wrong value `casual_argument`.",
+            dict(empty_target_action="casual_argument")
+        ),
+        # check input shapes are consistent
+        (
+            _irs_mis_sz.indexes, _irs_mis_sz.preds, _irs_mis_sz.target,
+            "`indexes`, `preds` and `target` must be of the same shape", dict(empty_target_action="skip")
+        ),
+        # check input tensors are not empty
+        (
+            _irs_empty.indexes, _irs_empty.preds,
+            _irs_empty.target, "`indexes`, `preds` and `target` must be non-empty and non-scalar tensors",
+            dict(empty_target_action="skip")
+        ),
+        # check on input dtypes
+        (
+            _irs.indexes.bool(), _irs.preds, _irs.target, "`indexes` must be a tensor of long integers",
+            dict(empty_target_action="skip")
+        ),
+        (
+            _irs.indexes, _irs.preds.bool(), _irs.target, "`preds` must be a tensor of floats",
+            dict(empty_target_action="skip")
+        ),
+        (
+            _irs.indexes, _irs.preds, _irs.target.float(), "`target` must be a tensor of booleans or integers",
+            dict(empty_target_action="skip")
+        )
+    ]
+)
+
 _errors_test_class_metric_parameters_default = dict(
     argnames="indexes,preds,target,message,metric_args",
     argvalues=[
diff --git a/tests/retrieval/inputs.py b/tests/retrieval/inputs.py
--- a/tests/retrieval/inputs.py
+++ b/tests/retrieval/inputs.py
@@ -33,9 +33,9 @@
 )
 
 _input_retrieval_scores_non_binary_target = Input(
-    indexes=torch.randint(high=10, size=(NUM_BATCHES, BATCH_SIZE)),
-    preds=torch.rand(NUM_BATCHES, BATCH_SIZE),
-    target=torch.randint(high=4, size=(NUM_BATCHES, BATCH_SIZE)),
+    indexes=torch.randint(high=10, size=(NUM_BATCHES, 2 * BATCH_SIZE)),
+    preds=torch.rand(NUM_BATCHES, 2 * BATCH_SIZE),
+    target=torch.randint(low=-1, high=4, size=(NUM_BATCHES, 2 * BATCH_SIZE)),
 )
 
 # with errors
diff --git a/tests/retrieval/test_ndcg.py b/tests/retrieval/test_ndcg.py
--- a/tests/retrieval/test_ndcg.py
+++ b/tests/retrieval/test_ndcg.py
@@ -22,11 +22,10 @@
     _concat_tests,
     _default_metric_class_input_arguments_with_non_binary_target,
     _default_metric_functional_input_arguments_with_non_binary_target,
-    _errors_test_class_metric_parameters_default,
     _errors_test_class_metric_parameters_k,
-    _errors_test_class_metric_parameters_no_pos_target,
-    _errors_test_functional_metric_parameters_default,
+    _errors_test_class_metric_parameters_with_nonbinary,
     _errors_test_functional_metric_parameters_k,
+    _errors_test_functional_metric_parameters_with_nonbinary,
 )
 from torchmetrics.functional.retrieval.ndcg import retrieval_normalized_dcg
 from torchmetrics.retrieval.retrieval_ndcg import RetrievalNormalizedDCG
@@ -114,8 +113,7 @@ def test_precision_gpu(self, indexes: Tensor, preds: Tensor, target: Tensor):
 
     @pytest.mark.parametrize(
         **_concat_tests(
-            _errors_test_class_metric_parameters_default,
-            _errors_test_class_metric_parameters_no_pos_target,
+            _errors_test_class_metric_parameters_with_nonbinary,
             _errors_test_class_metric_parameters_k,
         )
     )
@@ -135,7 +133,7 @@ def test_arguments_class_metric(
 
     @pytest.mark.parametrize(
         **_concat_tests(
-            _errors_test_functional_metric_parameters_default,
+            _errors_test_functional_metric_parameters_with_nonbinary,
             _errors_test_functional_metric_parameters_k,
         )
     )