Repository: dmlc/dgl

Issue:
[BUG] dgl.heterograph() not supporting node types with no inbound/outbound edges
## 🐛 Bug

See below:

## To Reproduce

```
In [2]: g = dgl.heterograph({('A', 'AB', 'B'): [(0, 1), (1, 2)]}, num_nodes_dict={'A': 3, 'B': 3, 'C': 3})

In [3]: g
Out[3]: 
Graph(num_nodes={'A': 3, 'B': 3},
      num_edges={('A', 'AB', 'B'): 2},
      metagraph=[('A', 'B')])
```

The node type `C` is missing.

## Expected behavior

Node type `C` should be there even if no edges connect to/from C.

## Environment

 - DGL Version (e.g., 1.0): master
 - Backend Library & Version (e.g., PyTorch 0.4.1, MXNet/Gluon 1.3):
 - OS (e.g., Linux):
 - How you installed DGL (`conda`, `pip`, source):
 - Build command you used (if compiling from source):
 - Python version:
 - CUDA/cuDNN version (if applicable):
 - GPU models and configuration (e.g. V100):
 - Any other relevant information:

## Additional context

A similar issue exist in the C API `CreateHeteroGraph` where we have no way to specify the number of nodes of a node type without any inbound/outbound edges.


Discussion:

Resolving patch:
diff --git a/python/dgl/convert.py b/python/dgl/convert.py
--- a/python/dgl/convert.py
+++ b/python/dgl/convert.py
@@ -281,7 +281,7 @@ def bipartite(data, utype='_U', etype='_E', vtype='_V', card=None, validate=True
     else:
         raise DGLError('Unsupported graph data type:', type(data))
 
-def hetero_from_relations(rel_graphs):
+def hetero_from_relations(rel_graphs, num_nodes_per_type=None):
     """Create a heterograph from graphs representing connections of each relation.
 
     The input is a list of heterographs where the ``i``th graph contains edges of type
@@ -294,6 +294,9 @@ def hetero_from_relations(rel_graphs):
     ----------
     rel_graphs : list of DGLHeteroGraph
         Each element corresponds to a heterograph for one (src, edge, dst) relation.
+    num_nodes_per_type : dict[str, Tensor], optional
+        Number of nodes per node type.  If not given, DGL will infer the number of nodes
+        from the given relation graphs.
 
     Returns
     -------
@@ -349,32 +352,35 @@ def hetero_from_relations(rel_graphs):
     # TODO(minjie): this API can be generalized as a union operation of the input graphs
     # TODO(minjie): handle node/edge data
     # infer meta graph
-    ntype_set = set()
-    meta_edges = []
+    meta_edges_src, meta_edges_dst = [], []
     ntypes = []
     etypes = []
     # TODO(BarclayII): I'm keeping the node type names sorted because even if
     # the metagraph is the same, the same node type name in different graphs may
     # map to different node type IDs.
     # In the future, we need to lower the type names into C++.
-    for rgrh in rel_graphs:
-        assert len(rgrh.etypes) == 1
-        stype, etype, dtype = rgrh.canonical_etypes[0]
-        ntype_set.add(stype)
-        ntype_set.add(dtype)
-    ntypes = list(sorted(ntype_set))
+    if num_nodes_per_type is None:
+        ntype_set = set()
+        for rgrh in rel_graphs:
+            assert len(rgrh.etypes) == 1
+            stype, etype, dtype = rgrh.canonical_etypes[0]
+            ntype_set.add(stype)
+            ntype_set.add(dtype)
+        ntypes = list(sorted(ntype_set))
+    else:
+        ntypes = list(sorted(num_nodes_per_type.keys()))
+        num_nodes_per_type = utils.toindex([num_nodes_per_type[ntype] for ntype in ntypes])
     ntype_dict = {ntype: i for i, ntype in enumerate(ntypes)}
     for rgrh in rel_graphs:
         stype, etype, dtype = rgrh.canonical_etypes[0]
-        stid = ntype_dict[stype]
-        dtid = ntype_dict[dtype]
-        meta_edges.append((stid, dtid))
+        meta_edges_src.append(ntype_dict[stype])
+        meta_edges_dst.append(ntype_dict[dtype])
         etypes.append(etype)
-    metagraph = graph_index.from_edge_list(meta_edges, True, True)
+    metagraph = graph_index.from_coo(len(ntypes), meta_edges_src, meta_edges_dst, True, True)
 
     # create graph index
     hgidx = heterograph_index.create_heterograph_from_relations(
-        metagraph, [rgrh._graph for rgrh in rel_graphs])
+        metagraph, [rgrh._graph for rgrh in rel_graphs], num_nodes_per_type)
     retg = DGLHeteroGraph(hgidx, ntypes, etypes)
     for i, rgrh in enumerate(rel_graphs):
         for ntype in rgrh.ntypes:
@@ -441,8 +447,12 @@ def heterograph(data_dict, num_nodes_dict=None):
                     nsrc = len({n for n, d in data.nodes(data=True) if d['bipartite'] == 0})
                     ndst = data.number_of_nodes() - nsrc
             elif isinstance(data, DGLHeteroGraph):
-                # Do nothing; handled in the next loop
-                continue
+                # original node type and edge type of ``data`` is ignored.
+                assert len(data.canonical_etypes) == 1, \
+                    "Relational graphs must have only one edge type."
+                srctype, _, dsttype = data.canonical_etypes[0]
+                nsrc = data.number_of_nodes(srctype)
+                ndst = data.number_of_nodes(dsttype)
             else:
                 raise DGLError('Unsupported graph data type %s for %s' % (
                     type(data), (srctype, etype, dsttype)))
@@ -464,7 +474,7 @@ def heterograph(data_dict, num_nodes_dict=None):
                 data, srctype, etype, dsttype,
                 card=(num_nodes_dict[srctype], num_nodes_dict[dsttype]), validate=False))
 
-    return hetero_from_relations(rel_graphs)
+    return hetero_from_relations(rel_graphs, num_nodes_dict)
 
 def to_hetero(G, ntypes, etypes, ntype_field=NTYPE, etype_field=ETYPE, metagraph=None):
     """Convert the given homogeneous graph to a heterogeneous graph.
@@ -622,7 +632,8 @@ def to_hetero(G, ntypes, etypes, ntype_field=NTYPE, etype_field=ETYPE, metagraph
                 card=(ntype_count[stid], ntype_count[dtid]), validate=False)
         rel_graphs.append(rel_graph)
 
-    hg = hetero_from_relations(rel_graphs)
+    hg = hetero_from_relations(
+        rel_graphs, {ntype: count for ntype, count in zip(ntypes, ntype_count)})
 
     ntype2ngrp = {ntype : node_groups[ntid] for ntid, ntype in enumerate(ntypes)}
     for ntid, ntype in enumerate(hg.ntypes):
diff --git a/python/dgl/heterograph.py b/python/dgl/heterograph.py
--- a/python/dgl/heterograph.py
+++ b/python/dgl/heterograph.py
@@ -1684,6 +1684,7 @@ def node_type_subgraph(self, ntypes):
         node_frames = [self._node_frames[self.get_ntype_id(ntype)] for ntype in ntypes]
         edge_frames = []
 
+        num_nodes_per_type = [self.number_of_nodes(ntype) for ntype in ntypes]
         ntypes_invmap = {ntype: i for i, ntype in enumerate(ntypes)}
         srctype_id, dsttype_id, _ = self._graph.metagraph.edges('eid')
         for i in range(len(self._etypes)):
@@ -1697,7 +1698,8 @@ def node_type_subgraph(self, ntypes):
                 edge_frames.append(self._edge_frames[i])
 
         metagraph = graph_index.from_edge_list(meta_edges, True, True)
-        hgidx = heterograph_index.create_heterograph_from_relations(metagraph, rel_graphs)
+        hgidx = heterograph_index.create_heterograph_from_relations(
+            metagraph, rel_graphs, utils.toindex(num_nodes_per_type))
         hg = DGLHeteroGraph(hgidx, ntypes, induced_etypes, node_frames, edge_frames)
         return hg
 
@@ -1767,9 +1769,11 @@ def edge_type_subgraph(self, etypes):
         edge_frames = [self._edge_frames[i] for i in etype_ids]
         induced_ntypes = [self._ntypes[i] for i in ntypes_invmap]
         induced_etypes = [self._etypes[i] for i in etype_ids]   # get the "name" of edge type
+        num_nodes_per_induced_type = [self.number_of_nodes(ntype) for ntype in induced_ntypes]
 
         metagraph = graph_index.from_edge_list((mapped_meta_src, mapped_meta_dst), True, True)
-        hgidx = heterograph_index.create_heterograph_from_relations(metagraph, rel_graphs)
+        hgidx = heterograph_index.create_heterograph_from_relations(
+            metagraph, rel_graphs, utils.toindex(num_nodes_per_induced_type))
         hg = DGLHeteroGraph(hgidx, induced_ntypes, induced_etypes, node_frames, edge_frames)
         return hg
 
diff --git a/python/dgl/heterograph_index.py b/python/dgl/heterograph_index.py
--- a/python/dgl/heterograph_index.py
+++ b/python/dgl/heterograph_index.py
@@ -1008,7 +1008,7 @@ def create_unitgraph_from_csr(num_ntypes, num_src, num_dst, indptr, indices, edg
         indptr.todgltensor(), indices.todgltensor(), edge_ids.todgltensor(),
         restrict_format)
 
-def create_heterograph_from_relations(metagraph, rel_graphs):
+def create_heterograph_from_relations(metagraph, rel_graphs, num_nodes_per_type):
     """Create a heterograph from metagraph and graphs of every relation.
 
     Parameters
@@ -1017,12 +1017,18 @@ def create_heterograph_from_relations(metagraph, rel_graphs):
         Meta-graph.
     rel_graphs : list of HeteroGraphIndex
         Bipartite graph of each relation.
+    num_nodes_per_type : utils.Index, optional
+        Number of nodes per node type
 
     Returns
     -------
     HeteroGraphIndex
     """
-    return _CAPI_DGLHeteroCreateHeteroGraph(metagraph, rel_graphs)
+    if num_nodes_per_type is None:
+        return _CAPI_DGLHeteroCreateHeteroGraph(metagraph, rel_graphs)
+    else:
+        return _CAPI_DGLHeteroCreateHeteroGraphWithNumNodes(
+            metagraph, rel_graphs, num_nodes_per_type.todgltensor())
 
 def disjoint_union(metagraph, graphs):
     """Return a disjoint union of the input heterographs.
@@ -1085,6 +1091,17 @@ def metagraph(self):
         """
         return _CAPI_DGLHeteroPickleStatesGetMetagraph(self)
 
+    @property
+    def num_nodes_per_type(self):
+        """Number of nodes per edge type
+
+        Returns
+        -------
+        Tensor
+            Array of number of nodes for each type
+        """
+        return F.zerocopy_from_dgl_ndarray(_CAPI_DGLHeteroPickleStatesGetNumVertices(self))
+
     @property
     def adjs(self):
         """Adjacency matrices of all the relation graphs
@@ -1097,11 +1114,12 @@ def adjs(self):
         return list(_CAPI_DGLHeteroPickleStatesGetAdjs(self))
 
     def __getstate__(self):
-        return self.metagraph, self.adjs
+        return self.metagraph, self.num_nodes_per_type, self.adjs
 
     def __setstate__(self, state):
-        metagraph, adjs = state
+        metagraph, num_nodes_per_type, adjs = state
+        num_nodes_per_type = F.zerocopy_to_dgl_ndarray(num_nodes_per_type)
         self.__init_handle_by_constructor__(
-            _CAPI_DGLCreateHeteroPickleStates, metagraph, adjs)
+            _CAPI_DGLCreateHeteroPickleStates, metagraph, num_nodes_per_type, adjs)
 
 _init_api("dgl.heterograph_index")


Test patch:
diff --git a/tests/compute/test_heterograph.py b/tests/compute/test_heterograph.py
--- a/tests/compute/test_heterograph.py
+++ b/tests/compute/test_heterograph.py
@@ -1448,6 +1448,31 @@ def mfunc(edges):
             'stack')
     assert g.nodes['game'].data['y'].shape == (g.number_of_nodes('game'), 1, 200)
 
+def test_isolated_ntype():
+    g = dgl.heterograph({
+        ('A', 'AB', 'B'): [(0, 1), (1, 2), (2, 3)]},
+        num_nodes_dict={'A': 3, 'B': 4, 'C': 4})
+    assert g.number_of_nodes('A') == 3
+    assert g.number_of_nodes('B') == 4
+    assert g.number_of_nodes('C') == 4
+
+    g = dgl.heterograph({
+        ('A', 'AC', 'C'): [(0, 1), (1, 2), (2, 3)]},
+        num_nodes_dict={'A': 3, 'B': 4, 'C': 4})
+    assert g.number_of_nodes('A') == 3
+    assert g.number_of_nodes('B') == 4
+    assert g.number_of_nodes('C') == 4
+
+    G = dgl.DGLGraph()
+    G.add_nodes(11)
+    G.add_edges([0, 1, 2], [4, 5, 6])
+    G.ndata[dgl.NTYPE] = F.tensor([0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2], dtype=F.int64)
+    G.edata[dgl.ETYPE] = F.tensor([0, 0, 0], dtype=F.int64)
+    g = dgl.to_hetero(G, ['A', 'B', 'C'], ['AB'])
+    assert g.number_of_nodes('A') == 3
+    assert g.number_of_nodes('B') == 4
+    assert g.number_of_nodes('C') == 4
+
 if __name__ == '__main__':
     test_create()
     test_query()
@@ -1470,3 +1495,4 @@ def mfunc(edges):
     test_empty_heterograph()
     test_types_in_function()
     test_stack_reduce()
+    test_isolated_ntype()