Repository: graspologic-org/graspologic

Issue:
[BUG] Layouts fail for directed graphs
## Expected Behavior
Layouts should work with directed graphs (IMO)
Perhaps not a bug in the sense that I'm not sure we promise this works for directed, but still. If we decided to only support undirected maybe a clearer error is warranted. 

## Actual Behavior
``NetworkXNotImplemented: not implemented for directed type``

## Example Code
```python
import networkx as nx
from graspologic.layouts import layout_umap

g = nx.erdos_renyi_graph(10, 0.7, directed=True)
layout_umap(g)
```

## Full Traceback
```pytb
---------------------------------------------------------------------------
NetworkXNotImplemented                    Traceback (most recent call last)
~/JHU_code/maggot/maggot_connectome/scripts/what_is_flow_rank.py in 
      3 
      4 g = nx.erdos_renyi_graph(10, 0.7, directed=True)
----> 5 layout_umap(g)

~/miniconda3/envs/maggot-revamp/lib/python3.8/site-packages/graspologic/layouts/auto.py in layout_umap(graph, min_dist, n_neighbors, max_edges, random_seed)
    173     """
    174 
--> 175     lcc_graph, tensors, labels = _node2vec_for_layout(graph, max_edges, random_seed)
    176     points = umap.UMAP(
    177         min_dist=min_dist, n_neighbors=n_neighbors, random_state=random_seed

~/miniconda3/envs/maggot-revamp/lib/python3.8/site-packages/graspologic/layouts/auto.py in _node2vec_for_layout(graph, max_edges, random_seed)
    214 ) -> Tuple[nx.Graph, np.ndarray, np.ndarray]:
    215     graph = _approximate_prune(graph, max_edges)
--> 216     graph = _largest_connected_component(graph)
    217 
    218     start = time.time()

~/miniconda3/envs/maggot-revamp/lib/python3.8/site-packages/graspologic/layouts/auto.py in _largest_connected_component(graph)
    182 
    183 def _largest_connected_component(graph: nx.Graph) -> nx.Graph:
--> 184     largest_component = max(nx.connected_components(graph), key=len)
    185     return graph.subgraph(largest_component).copy()
    186 

 in connected_components(G)

~/miniconda3/envs/maggot-revamp/lib/python3.8/site-packages/networkx/utils/decorators.py in _not_implemented_for(not_implement_for_func, *args, **kwargs)
     74         if match:
     75             msg = f"not implemented for {' '.join(graph_types)} type"
---> 76             raise nx.NetworkXNotImplemented(msg)
     77         else:
     78             return not_implement_for_func(*args, **kwargs)

NetworkXNotImplemented: not implemented for directed type
```

## Your Environment
* Python version: 3.8
* graspologic version: dev

## Additional Details
```pytb
183 def _largest_connected_component(graph: nx.Graph) -> nx.Graph:
--> 184     largest_component = max(nx.connected_components(graph), key=len)
```
Just from these 2 lines on the traceback, I suspect this just needs to be adapted to check for weakly connected components. Or, use `graspologic.utils.is_fully_connected` from our own codebase. However in #112 we are talking about replacing the networkx-based check with a faster one from scipy. Not sure if the layouts code will always assume networkx input going forward. AFAIK there is nothing that inherently needs to be undirected for the layouts process, or am I missing something? 



Discussion:
should discuss to what extent this should support directed graphs
@bryantower - we should talk about this.  I would imagine we should be able to make layouts work for a directed graph, but maybe I'm missing something.  I know we had concerns over node2vec with directed but I'm still not convinced we actually have a problem. 

Of the phases of auto layouts, we have:
- create node2vec embedding of LCC
- down project into 2d using umap or tsne
- no overlap
- leiden (must be undirected)
- render

This specific ticket is primarily talking about how we don't have support for the right way to get a directed LCC (we're calling the undirected LCC from networkx every time regardless of type).  But thinking more broadly - should we be able to conceivably do a directed layout?  I would think yes, but I know you were talking about it before and I don't remember what exactly you told me about it.
I think the only place where undirected is really required is the Leiden portion. We should definitely be able to support a layout for directed graphs. We use the Leiden partitioning to generate the colors. 

In the path specified in this bug, we should find the weakly connected component if the graph is directed and then do the appropriate transformation to make Leiden work.
if leiden is used for auto-layouts coloring I do see how that could be an issue, but dont know that it should be a breaking one - i could imagine a world where embeddings happen on the directed graph but leiden on the undirected version of that graph, cause modularity makes sense in undirected land
> I think the only place where undirected is really required is the Leiden portion. We should definitely be able to support a layout for directed graphs. We use the Leiden partitioning to generate the colors.
> 
> In the path specified in this bug, we should find the weakly connected component if the graph is directed and then do the appropriate transformation to make Leiden work.

Seems like we're on the same page!
Exactly, we just need to be able to call the right LCC function based on type.  As long as we're confident the rest makes sense we should be able to make this happen easily enough.
@dwaynepryce I agree.
https://github.com/microsoft/graspologic/blob/e6b72994f9b71ba2cfaaa2dcf0da33ebd2483ff9/graspologic/utils/utils.py#L417

https://github.com/microsoft/graspologic/blob/e6b72994f9b71ba2cfaaa2dcf0da33ebd2483ff9/graspologic/utils/utils.py#L463

these both are probably relevant - but i was also in the middle of having someone work on #112 - the idea being that scipy's LCC check is much faster than networkx. for an embedding (say a sparse matrix is being passed to ASE) i think making that switch makes more sense. but for your use case it is probably easier to stay in networkx. perhaps we should talk about what those functions should do?
Would be interesting to see if the time-to-convert an nx graph into a csr matrix and run that lcc check is faster than doing it all in networkx.  I'll keep you guys posted after I crunch some numbers :) 
thought about that also - i bet for a big graph it might be worth it 
FYI @kareef928 has been working on the conversion stuff so we'll keep him in the loop too!

Resolving patch:
diff --git a/graspologic/layouts/auto.py b/graspologic/layouts/auto.py
--- a/graspologic/layouts/auto.py
+++ b/graspologic/layouts/auto.py
@@ -13,7 +13,9 @@
 
 from ..embed import node2vec_embed
 from ..partition import leiden
+from ..preconditions import is_real_weighted
 from ..preprocessing import cut_edges_by_weight, histogram_edge_weight
+from ..utils import largest_connected_component
 from .classes import NodePosition
 from .nooverlap import remove_overlaps
 
@@ -27,6 +29,7 @@ def layout_tsne(
     perplexity: int,
     n_iter: int,
     max_edges: int = 10000000,
+    weight_attribute: str = "weight",
     random_seed: Optional[int] = None,
     adjust_overlaps: bool = True,
 ) -> Tuple[nx.Graph, List[NodePosition]]:
@@ -58,7 +61,7 @@ def layout_tsne(
         The perplexity is related to the number of nearest neighbors that is used in
         other manifold learning algorithms. Larger datasets usually require a larger
         perplexity. Consider selecting a value between 4 and 100. Different values can
-        result in significanlty different results.
+        result in significantly different results.
     n_iter : int
         Maximum number of iterations for the optimization. We have found in practice
         that larger graphs require more iterations. We hope to eventually have more
@@ -68,8 +71,11 @@ def layout_tsne(
         The maximum number of edges to use when generating the embedding.  Default is
         ``10000000``. The edges with the lowest weights will be pruned until at most
         ``max_edges`` exist. Warning: this pruning is approximate and more edges than
-        are necessary may be pruned. Running in 32 bit enviornment you will most
+        are necessary may be pruned. Running in 32 bit environment you will most
         likely need to reduce this number or you will out of memory.
+    weight_attribute: str
+        The edge dictionary data attribute that holds the weight. Default is ``weight``.
+        Note that the graph must be fully weighted or unweighted.
     random_seed : int
         Seed to be used for reproducible results. Default is None and will produce
         a new random state. Specifying a random state will provide consistent results
@@ -105,6 +111,7 @@ def layout_tsne(
         points,
         random_seed=random_seed,
         adjust_overlaps=adjust_overlaps,
+        weight_attribute=weight_attribute,
     )
     return lcc_graph, positions
 
@@ -114,6 +121,7 @@ def layout_umap(
     min_dist: float = 0.75,
     n_neighbors: int = 25,
     max_edges: int = 10000000,
+    weight_attribute: str = "weight",
     random_seed: Optional[int] = None,
     adjust_overlaps: bool = True,
 ) -> Tuple[nx.Graph, List[NodePosition]]:
@@ -159,6 +167,9 @@ def layout_umap(
         ``max_edges`` exist. Warning: this pruning is approximate and more edges than
         are necessary may be pruned. Running in 32 bit environment you will most
         likely need to reduce this number or you will out of memory.
+    weight_attribute: str
+        The edge dictionary data attribute that holds the weight. Default is ``weight``.
+        Note that the graph must be fully weighted or unweighted.
     random_seed : int
         Seed to be used for reproducible results. Default is None and will produce
         random results.
@@ -196,15 +207,11 @@ def layout_umap(
         points,
         random_seed=random_seed,
         adjust_overlaps=adjust_overlaps,
+        weight_attribute=weight_attribute,
     )
     return lcc_graph, positions
 
 
-def _largest_connected_component(graph: nx.Graph) -> nx.Graph:
-    largest_component = max(nx.connected_components(graph), key=len)
-    return graph.subgraph(largest_component).copy()
-
-
 def _approximate_prune(graph: nx.Graph, max_edges_to_keep: int = 1000000):
     num_edges = len(graph.edges())
     logger.info(f"num edges: {num_edges}")
@@ -233,7 +240,7 @@ def _node2vec_for_layout(
     random_seed: Optional[int] = None,
 ) -> Tuple[nx.Graph, np.ndarray, np.ndarray]:
     graph = _approximate_prune(graph, max_edges)
-    graph = _largest_connected_component(graph)
+    graph = largest_connected_component(graph)
 
     start = time.time()
     tensors, labels = node2vec_embed(
@@ -249,10 +256,33 @@ def _node2vec_for_layout(
     return graph, tensors, labels
 
 
+def _to_undirected(graph: nx.DiGraph, weight_attribute: str = "weight") -> nx.Graph:
+    sym_g = nx.Graph()
+    weighted = is_real_weighted(graph, weight_attribute=weight_attribute)
+    for source, target, weight in graph.edges.data(weight_attribute):
+        if weight is not None:
+            edge_weighted = True
+            if sym_g.has_edge(source, target):
+                sym_g[source][target][weight_attribute] = (
+                    sym_g[source][target][weight_attribute] + weight * 0.5
+                )
+            else:
+                sym_g.add_edge(source, target)
+                sym_g.edges[source, target].update({weight_attribute: weight * 0.5})
+        else:
+            edge_weighted = False
+            sym_g.add_edge(source, target)
+        if weighted != edge_weighted:
+            msg = "Graph must be fully weighted or unweighted"
+            raise ValueError(msg)
+    return sym_g
+
+
 def _node_positions_from(
     graph: nx.Graph,
     labels: np.ndarray,
     down_projection_2d: np.ndarray,
+    weight_attribute: str = "weight",
     random_seed: Optional[int] = None,
     adjust_overlaps: bool = True,
 ) -> List[NodePosition]:
@@ -260,7 +290,14 @@ def _node_positions_from(
     sizes = _compute_sizes(degree)
     covered_area = _covered_size(sizes)
     scaled_points = _scale_points(down_projection_2d, covered_area)
-    partitions = leiden(graph, random_seed=random_seed)
+    if graph.is_directed():
+        temp_graph = _to_undirected(graph, weight_attribute=weight_attribute)
+        logger.warning(
+            "Directed graph converted to undirected graph for community detection"
+        )
+        partitions = leiden(temp_graph, random_seed=random_seed)
+    else:
+        partitions = leiden(graph, random_seed=random_seed)
     positions = [
         NodePosition(
             node_id=key,


Test patch:
diff --git a/tests/layouts/test_auto.py b/tests/layouts/test_auto.py
--- a/tests/layouts/test_auto.py
+++ b/tests/layouts/test_auto.py
@@ -46,6 +46,23 @@ def test_layout_umap_int_node_ids(self):
 
         self.assertEqual(len(node_positions), len(graph.nodes()))
 
+    def test_layout_umap_directed_weighted(self):
+        graph = nx.erdos_renyi_graph(10, 0.7, directed=True)
+
+        for s, t in graph.edges():
+            graph.edges[s, t]["weight"] = numpy.random.randint(0, 10)
+
+        _, node_positions = layout_umap(graph=graph)
+
+        self.assertEqual(len(node_positions), len(graph.nodes()))
+
+    def test_layout_umap_directed_unweighted(self):
+        graph = nx.erdos_renyi_graph(10, 0.7, directed=True)
+
+        _, node_positions = layout_umap(graph=graph)
+
+        self.assertEqual(len(node_positions), len(graph.nodes()))
+
 
 if __name__ == "__main__":
     unittest.main()