Repository: pyg-team/pytorch_geometric
Pull request #10007 (2025-02-07 08:56:57)

Support `LinkPredCoverage` in `LinkPredMetricCollection`


Patch:
diff --git a/torch_geometric/edge_index.py b/torch_geometric/edge_index.py
index a4422a8ec..df9a5a686 100644
--- a/torch_geometric/edge_index.py
+++ b/torch_geometric/edge_index.py
@@ -183,7 +183,7 @@ class EdgeIndex(Tensor):
 
         edge_index = EdgeIndex(
             [[0, 1, 1, 2],
-             [1, 0, 2, 1]]
+             [1, 0, 2, 1]],
             sparse_size=(3, 3),
             sort_order='row',
             is_undirected=True,
@@ -210,7 +210,7 @@ class EdgeIndex(Tensor):
         assert not edge_index.is_undirected
 
         # Sparse-Dense Matrix Multiplication:
-        out = edge_index.flip(0) @ torch.randn(3, 16)
+        out = edge_index.flip(0) @ torch.randn(3, 16)
         assert out.size() == (3, 16)
     """
     # See "https://pytorch.org/docs/stable/notes/extending.html"
diff --git a/torch_geometric/metrics/link_pred.py b/torch_geometric/metrics/link_pred.py
index 4947a8916..f4204494b 100644
--- a/torch_geometric/metrics/link_pred.py
+++ b/torch_geometric/metrics/link_pred.py
@@ -138,7 +138,7 @@ class LinkPredMetricData:
         return pos
 
 
-class LinkPredMetric(BaseMetric):
+class _LinkPredMetric(BaseMetric):
     r"""An abstract class for computing link prediction retrieval metrics.
 
     Args:
@@ -147,7 +147,6 @@ class LinkPredMetric(BaseMetric):
     is_differentiable: bool = False
     full_state_update: bool = False
     higher_is_better: Optional[bool] = None
-    weighted: bool
 
     def __init__(self, k: int) -> None:
         super().__init__()
@@ -158,16 +157,6 @@ class LinkPredMetric(BaseMetric):
 
         self.k = k
 
-        self.accum: Tensor
-        self.total: Tensor
-
-        if WITH_TORCHMETRICS:
-            self.add_state('accum', torch.tensor(0.), dist_reduce_fx='sum')
-            self.add_state('total', torch.tensor(0), dist_reduce_fx='sum')
-        else:
-            self.register_buffer('accum', torch.tensor(0.))
-            self.register_buffer('total', torch.tensor(0))
-
     def update(
         self,
         pred_index_mat: Tensor,
@@ -194,6 +183,53 @@ class LinkPredMetric(BaseMetric):
                 a vector of positive values. Required for weighted metrics,
                 ignored otherwise. (default: :obj:`None`)
         """
+        raise NotImplementedError
+
+    def compute(self) -> Tensor:
+        r"""Computes the final metric value."""
+        raise NotImplementedError
+
+    def reset(self) -> None:
+        r"""Resets metric state variables to their default value."""
+        if WITH_TORCHMETRICS:
+            super().reset()
+        else:
+            self._reset()
+
+    def _reset(self) -> None:
+        raise NotImplementedError
+
+    def __repr__(self) -> str:
+        return f'{self.__class__.__name__}(k={self.k})'
+
+
+class LinkPredMetric(_LinkPredMetric):
+    r"""An abstract class for computing link prediction retrieval metrics.
+
+    Args:
+        k (int): The number of top-:math:`k` predictions to evaluate against.
+    """
+    weighted: bool
+
+    def __init__(self, k: int) -> None:
+        super().__init__(k)
+
+        self.accum: Tensor
+        self.total: Tensor
+
+        if WITH_TORCHMETRICS:
+            self.add_state('accum', torch.tensor(0.), dist_reduce_fx='sum')
+            self.add_state('total', torch.tensor(0), dist_reduce_fx='sum')
+        else:
+            self.register_buffer('accum', torch.tensor(0.))
+            self.register_buffer('total', torch.tensor(0))
+
+    def update(
+        self,
+        pred_index_mat: Tensor,
+        edge_label_index: Union[Tensor, Tuple[Tensor, Tensor]],
+        edge_label_weight: Optional[Tensor] = None,
+    ) -> None:
         if self.weighted and edge_label_weight is None:
             raise ValueError(f"'edge_label_weight' is a required argument for "
                              f"weighted '{self.__class__.__name__}' metrics")
@@ -214,19 +250,10 @@ class LinkPredMetric(BaseMetric):
         self.total += (data.label_count > 0).sum()
 
     def compute(self) -> Tensor:
-        r"""Computes the final metric value."""
         if self.total == 0:
             return torch.zeros_like(self.accum)
         return self.accum / self.total
 
-    def reset(self) -> None:
-        r"""Resets metric state variables to their default value."""
-        if WITH_TORCHMETRICS:
-            super().reset()
-        else:
-            self.accum.zero_()
-            self.total.zero_()
-
     def _compute(self, data: LinkPredMetricData) -> Tensor:
         r"""Computes the specific metric.
         To be implemented separately for each metric class.
@@ -237,6 +264,10 @@ class LinkPredMetric(BaseMetric):
         """
         raise NotImplementedError
 
+    def _reset(self) -> None:
+        self.accum.zero_()
+        self.total.zero_()
+
     def __repr__(self) -> str:
         weighted_repr = ', weighted=True' if self.weighted else ''
         return f'{self.__class__.__name__}(k={self.k}{weighted_repr})'
@@ -284,7 +315,7 @@ class LinkPredMetricCollection(torch.nn.ModuleDict):
 
         if isinstance(metrics, (list, tuple)):
             metrics = {
-                (f'{"Weighted" if metric.weighted else ""}'
+                (f'{"Weighted" if getattr(metric, "weighted", False) else ""}'
                  f'{metric.__class__.__name__}@{metric.k}'):
                 metric
                 for metric in metrics
@@ -293,6 +324,7 @@ class LinkPredMetricCollection(torch.nn.ModuleDict):
         assert isinstance(metrics, dict)
 
         for name, metric in metrics.items():
+            assert isinstance(metric, _LinkPredMetric)
             self[name] = metric
 
     @property
@@ -307,7 +339,8 @@ class LinkPredMetricCollection(torch.nn.ModuleDict):
         r"""Returns :obj:`True` in case the collection holds at least one
         weighted link prediction metric.
         """
-        return any([metric.weighted for metric in self.values()])
+        return any(
+            [getattr(metric, 'weighted', False) for metric in self.values()])
 
     def update(  # type: ignore
         self,
@@ -348,7 +381,7 @@ class LinkPredMetricCollection(torch.nn.ModuleDict):
         )
 
         for metric in self.values():
-            if metric.weighted:
+            if isinstance(metric, LinkPredMetric) and metric.weighted:
                 metric._update(data)
                 if WITH_TORCHMETRICS:
                     metric._update_count += 1
@@ -362,11 +395,19 @@ class LinkPredMetricCollection(torch.nn.ModuleDict):
             del data._edge_label_weight_pos
 
         for metric in self.values():
-            if not metric.weighted:
+            if isinstance(metric, LinkPredMetric) and not metric.weighted:
                 metric._update(data)
                 if WITH_TORCHMETRICS:
                     metric._update_count += 1
 
+        for metric in self.values():
+            if not isinstance(metric, LinkPredMetric):
+                metric.update(
+                    pred_index_mat=pred_index_mat,
+                    edge_label_index=edge_label_index,
+                    edge_label_weight=edge_label_weight,
+                )
+
     def compute(self) -> Dict[str, Tensor]:
         r"""Computes the final metric values."""
         return {name: metric.compute() for name, metric in self.items()}
@@ -523,27 +564,20 @@ class LinkPredMRR(LinkPredMetric):
         return (pred_rel_mat / arange).max(dim=-1)[0]
 
 
-class LinkPredCoverage(BaseMetric):
+class LinkPredCoverage(_LinkPredMetric):
     r"""A link prediction metric to compute the Coverage @ :math:`k`.
 
     Args:
         k (int): The number of top-:math:`k` predictions to evaluate against.
+        num_dst_nodes (int): The total number of destination nodes.
     """
-    is_differentiable: bool = False
-    full_state_update: bool = True
     higher_is_better: bool = True
-    weighted: bool = False
 
     def __init__(self, k: int, num_dst_nodes: int) -> None:
-        super().__init__()
-
-        if k <= 0:
-            raise ValueError(f"'k' needs to be a positive integer in "
-                             f"'{self.__class__.__name__}' (got {k})")
-
-        self.k = k
+        super().__init__(k)
         self.num_dst_nodes = num_dst_nodes
 
+        self.mask: Tensor
         mask = torch.zeros(num_dst_nodes, dtype=torch.bool)
         if WITH_TORCHMETRICS:
             self.add_state('mask', mask, dist_reduce_fx='max')
@@ -556,16 +590,13 @@ class LinkPredCoverage(BaseMetric):
         edge_label_index: Union[Tensor, Tuple[Tensor, Tensor]],
         edge_label_weight: Optional[Tensor] = None,
     ) -> None:
-        self.mask[pred_index_mat[:, :self.k].view(-1)] = True
+        self.mask[pred_index_mat[:, :self.k].flatten()] = True
 
     def compute(self) -> Tensor:
         return self.mask.to(torch.get_default_dtype()).mean()
 
-    def reset(self) -> None:
-        if WITH_TORCHMETRICS:
-            super().reset()
-        else:
-            self.mask.zero_()
+    def _reset(self) -> None:
+        self.mask.zero_()
 
     def __repr__(self) -> str:
         return (f'{self.__class__.__name__}(k={self.k}, '


Test patch:
diff --git a/test/metrics/test_link_pred_metric.py b/test/metrics/test_link_pred_metric.py
index 803cf4927..8d76c6d25 100644
--- a/test/metrics/test_link_pred_metric.py
+++ b/test/metrics/test_link_pred_metric.py
@@ -205,7 +205,7 @@ def test_coverage():
 @pytest.mark.parametrize('num_src_nodes', [10])
 @pytest.mark.parametrize('num_dst_nodes', [50])
 @pytest.mark.parametrize('num_edges', [200])
-def test_link_pred_metric_collection(num_src_nodes, num_dst_nodes, num_edges):
+def test_metric_collection(num_src_nodes, num_dst_nodes, num_edges):
     metrics = [
         LinkPredMAP(k=10),
         LinkPredPrecision(k=100),
@@ -213,6 +213,7 @@ def test_link_pred_metric_collection(num_src_nodes, num_dst_nodes, num_edges):
         LinkPredF1(k=20),
         LinkPredMRR(k=40),
         LinkPredNDCG(k=80),
+        LinkPredCoverage(k=5, num_dst_nodes=num_dst_nodes),
     ]
 
     row = torch.randint(0, num_src_nodes, (num_edges, ))
@@ -232,6 +233,7 @@ def test_link_pred_metric_collection(num_src_nodes, num_dst_nodes, num_edges):
         '  LinkPredF1@20: LinkPredF1(k=20),\n'
         '  LinkPredMRR@40: LinkPredMRR(k=40),\n'
         '  LinkPredNDCG@80: LinkPredNDCG(k=80),\n'
+        '  LinkPredCoverage@5: LinkPredCoverage(k=5, num_dst_nodes=50),\n'
         '])')
     assert metric_collection.max_k == 100