Repository: pyg-team/pytorch_geometric

Issue:
Inconsistent behaviour with `to_hetero()` and custom edge types
### 🐛 Describe the bug

Hi, I'm trying to implement a heterogeneous network with a custom dataset. I've run into some issues but seems like it boils down to the following: when running the below, the transformed network seems to have different behaviour depending on the order of the node types in the edge.

```python
import torch
from torch_geometric.nn import Linear, GATConv, to_hetero

class HeteroNet(torch.nn.Module):
    def __init__(self):
        super(HeteroNet, self).__init__()
        self.fc_1_input = Linear(-1, 64)

        self.hidden_gat = GATConv((-1,-1), 64, heads=4, add_self_loops=False)

        self.fc_1_output = Linear(-1, 1)
            

    def forward(self, x, edge_index):
        x = self.fc_1_input(x)
        x = self.hidden_gat(x, edge_index)
        x = self.fc_1_output(x)
        return x
    
        
model_1 = HeteroNet()
model_1 = to_hetero(model_1, (['not_node', '<=_node'],[('<=_node', 'to', 'not_node')]))
print("Printing model_1!")
print(model_1)

model_2 = HeteroNet()
model_2 = to_hetero(model_2, (['not_node', '<=_node'],[('not_node', 'to', '<=_node')]))
print("Printing model_2!")
print(model_2)
```

This outputs the following:

```
Printing model_1!
GraphModule(
  (fc_1_input): ModuleDict(
    (not_node): Linear(-1, 64, bias=True)
    (<=_node): Linear(-1, 64, bias=True)
  )
  (hidden_gat): ModuleDict(
    (<=_node__to__not_node): GATConv((-1, -1), 64, heads=4)
  )
  (fc_1_output): ModuleDict(
    (not_node): Linear(-1, 1, bias=True)
    (<=_node): Linear(-1, 1, bias=True)
  )
)



def forward(self, x, edge_index):
    x__not_node = x.get('not_node');  x = None
    fc_1_input__not_node = self.fc_1_input.not_node(x__not_node);  x__not_node = None
    hidden_gat__not_node = getattr(self.hidden_gat, "<=_node__to__not_node")((None, fc_1_input__not_node), (None, None));  fc_1_input__not_node = None
    fc_1_output__not_node = self.fc_1_output.not_node(hidden_gat__not_node);  hidden_gat__not_node = None
    return {'not_node': fc_1_output__not_node, '<=_node': None}

Printing model_2!
GraphModule(
  (fc_1_input): ModuleDict(
    (not_node): Linear(-1, 64, bias=True)
    (<=_node): Linear(-1, 64, bias=True)
  )
  (hidden_gat): ModuleDict(
    (not_node): GATConv((-1, -1), 64, heads=4)
    (<=_node): GATConv((-1, -1), 64, heads=4)
  )
  (fc_1_output): ModuleDict(
    (not_node): Linear(-1, 1, bias=True)
    (<=_node): Linear(-1, 1, bias=True)
  )
)



def forward(self, x, edge_index):
    fc_1_output__not_node = self.fc_1_output.not_node(None)
    return {'not_node': fc_1_output__not_node, '<=_node': None}

```

As you can see, for `model_1` the transformed model contains the correct edge type for the `hidden_gat` layer, but with the reversed node types `model_2` doesn't generate the same network with edge reversed.

### Environment

* PyG version: 2.0.3
* PyTorch version: 1.10.2
* OS: Ubuntu 20.04.3 LTSc
* Python version: 3.8.10
* CUDA/cuDNN version: 11.6
* How you installed PyTorch and PyG: pip



Discussion:
Thanks for reporting. Both `<` and `=` are non-valid characters for defining a `to_hetero` model since they are not allowed as part of Python attribute names. Furthermore, adding reverse edges fixes remaining issues for me:
```python
model_1 = to_hetero(model_1, (['not_node', 'node'], [
    ('node', 'to', 'not_node'),
    ('not_node', 'to', 'node'),
]))
```
Without it, we already print the following warning in PyG master:
```
UserWarning: There exist node types ({'node'}) whose representations do not get updated during message passing as they do not occur as destination type in any edge type. This may lead to unexpected behaviour.
```
Hi ! I came across a similar situation on a custom dataset whose node type and edge type names contained strings like "__", "-", ":" and camelCase names. It took me a while to realize some of these conventions were forbidden (I ended up reading the generated function thanks the `debug=True` option and saw the weird behavior). I think it would be great to have a sanity check within `to_hetero` or at least to raise a warning !
That is a great suggestion, will work on it!

Resolving patch:
diff --git a/torch_geometric/data/hetero_data.py b/torch_geometric/data/hetero_data.py
--- a/torch_geometric/data/hetero_data.py
+++ b/torch_geometric/data/hetero_data.py
@@ -1,5 +1,6 @@
 import copy
 import re
+import warnings
 from collections import defaultdict, namedtuple
 from collections.abc import Mapping
 from itertools import chain
@@ -477,6 +478,18 @@ def collect(self, key: str) -> Dict[NodeOrEdgeType, Any]:
                 mapping[subtype] = getattr(store, key)
         return mapping
 
+    def _check_type_name(self, name: str):
+        if not name.isidentifier():
+            warnings.warn(f"The type '{name}' contains invalid characters "
+                          f"which might lead to unexpected behavior down the "
+                          f"line. To avoid any issues, ensure that your type "
+                          f"only contains letters, numbers and underscores.")
+        elif '__' in name:
+            warnings.warn(f"The type '{name}' contains double underscores "
+                          f"('__') which might lead to unexpected behavior "
+                          f"down the line. To avoid any issues, ensure that "
+                          f"your type only contains single underscores.")
+
     def get_node_store(self, key: NodeType) -> NodeStorage:
         r"""Gets the :class:`~torch_geometric.data.storage.NodeStorage` object
         of a particular node type :attr:`key`.
@@ -491,6 +504,7 @@ def get_node_store(self, key: NodeType) -> NodeStorage:
         """
         out = self._node_store_dict.get(key, None)
         if out is None:
+            self._check_type_name(key)
             out = NodeStorage(_parent=self, _key=key)
             self._node_store_dict[key] = out
         return out
@@ -510,6 +524,7 @@ def get_edge_store(self, src: str, rel: str, dst: str) -> EdgeStorage:
         key = (src, rel, dst)
         out = self._edge_store_dict.get(key, None)
         if out is None:
+            self._check_type_name(rel)
             out = EdgeStorage(_parent=self, _key=key)
             self._edge_store_dict[key] = out
         return out


Test patch:
diff --git a/test/data/test_hetero_data.py b/test/data/test_hetero_data.py
--- a/test/data/test_hetero_data.py
+++ b/test/data/test_hetero_data.py
@@ -420,6 +420,16 @@ def test_hetero_data_to_canonical():
         data['user', 'product']
 
 
+def test_hetero_data_invalid_names():
+    data = HeteroData()
+    with pytest.warns(UserWarning, match="letters, numbers and underscores"):
+        data['my test'].x = torch.randn(10, 16)
+    assert data.node_types == ['my test']
+    with pytest.warns(UserWarning, match="single underscores"):
+        data['my test', 'a__b', 'my test'].edge_attr = torch.randn(10, 16)
+    assert data.edge_types == [('my test', 'a__b', 'my test')]
+
+
 # Feature Store ###############################################################
 
 
diff --git a/test/loader/test_dynamic_batch_sampler.py b/test/loader/test_dynamic_batch_sampler.py
--- a/test/loader/test_dynamic_batch_sampler.py
+++ b/test/loader/test_dynamic_batch_sampler.py
@@ -1,5 +1,6 @@
 from typing import List
 
+import pytest
 import torch
 
 from torch_geometric.data import Data
@@ -37,7 +38,8 @@ def test_dataloader_with_dynamic_batches():
                                         num_steps=2)
     loader = DataLoader(data_list, batch_sampler=batch_sampler)
 
-    num_nodes_total = 0
-    for data in loader:
-        num_nodes_total += data.num_nodes
-    assert num_nodes_total == 601
+    with pytest.warns(UserWarning, match="is larger than 300 nodes"):
+        num_nodes_total = 0
+        for data in loader:
+            num_nodes_total += data.num_nodes
+        assert num_nodes_total == 601
diff --git a/test/nn/conv/test_message_passing.py b/test/nn/conv/test_message_passing.py
--- a/test/nn/conv/test_message_passing.py
+++ b/test/nn/conv/test_message_passing.py
@@ -80,7 +80,7 @@ def test_my_conv():
     assert torch.allclose(conv((x1, x2), adj.t()), out1)
     assert torch.allclose(conv((x1, x2), torch_adj.t()), out1)
     assert torch.allclose(conv((x1, None), adj.t()), out2)
-    assert torch.allclose(conv((x1, None), torch_adj.t()), out2)
+    assert torch.allclose(conv((x1, None), torch_adj.t()), out2, atol=1e-6)
     conv.fuse = False
     assert torch.allclose(conv((x1, x2), adj.t()), out1)
     assert torch.allclose(conv((x1, x2), torch_adj.t()), out1)
diff --git a/test/transforms/test_random_link_split.py b/test/transforms/test_random_link_split.py
--- a/test/transforms/test_random_link_split.py
+++ b/test/transforms/test_random_link_split.py
@@ -1,3 +1,4 @@
+import pytest
 import torch
 
 from torch_geometric.data import Data, HeteroData
@@ -205,7 +206,9 @@ def test_random_link_split_insufficient_negative_edges():
     transform = RandomLinkSplit(num_val=0.34, num_test=0.34,
                                 is_undirected=False, neg_sampling_ratio=2,
                                 split_labels=True)
-    train_data, val_data, test_data = transform(data)
+
+    with pytest.warns(UserWarning, match="not enough negative edges"):
+        train_data, val_data, test_data = transform(data)
 
     assert train_data.neg_edge_label_index.size() == (2, 2)
     assert val_data.neg_edge_label_index.size() == (2, 2)
diff --git a/test/utils/test_dropout.py b/test/utils/test_dropout.py
--- a/test/utils/test_dropout.py
+++ b/test/utils/test_dropout.py
@@ -1,3 +1,4 @@
+import pytest
 import torch
 
 from torch_geometric.testing import withPackage
@@ -16,17 +17,20 @@ def test_dropout_adj():
     ])
     edge_attr = torch.Tensor([1, 2, 3, 4, 5, 6])
 
-    out = dropout_adj(edge_index, edge_attr, training=False)
+    with pytest.warns(UserWarning, match="'dropout_adj' is deprecated"):
+        out = dropout_adj(edge_index, edge_attr, training=False)
     assert edge_index.tolist() == out[0].tolist()
     assert edge_attr.tolist() == out[1].tolist()
 
     torch.manual_seed(5)
-    out = dropout_adj(edge_index, edge_attr)
+    with pytest.warns(UserWarning, match="'dropout_adj' is deprecated"):
+        out = dropout_adj(edge_index, edge_attr)
     assert out[0].tolist() == [[0, 1, 2, 2], [1, 2, 1, 3]]
     assert out[1].tolist() == [1, 3, 4, 5]
 
     torch.manual_seed(6)
-    out = dropout_adj(edge_index, edge_attr, force_undirected=True)
+    with pytest.warns(UserWarning, match="'dropout_adj' is deprecated"):
+        out = dropout_adj(edge_index, edge_attr, force_undirected=True)
     assert out[0].tolist() == [[0, 1, 1, 2], [1, 2, 0, 1]]
     assert out[1].tolist() == [1, 3, 1, 3]