Repository: graspologic-org/graspologic

Issue:
Clarify expected behavior for Leiden on a disconnected graph
## Is your feature request related to a problem? Please describe.
Several times I have been confused by output of Leiden only to realize it is because some nodes are disconnected. These nodes then will not show up in the partition dictionary. 

## Describe the solution you'd like
Just some clarification and/or a warning for what to expect

## Provide references (if applicable)
in this example node 0 is disconnected from the graph and thus key 0 is not present in the output dictionary
```python
g = nx.complete_graph(10)
nodelist = sorted(g.nodes)
for node in nodelist[1:]: 
  g.remove_edge(0, node)
leiden(g)
```
```
 {3: 0, 4: 0, 7: 0, 2: 0, 9: 0, 6: 0, 5: 0, 1: 0, 8: 0}
```


cc: @daxpryce


Discussion:
Would it be short sighted to just make these isolate nodes be in their own, isolate communities?

My big worry is what happens if you pass in this result dictionary referencing nodes that won't exist to the native code (because they aren't in the edge list) if you wanted to use that dictionary as the starting partitioning.  I think right now I throw an exception (https://github.com/microsoft/graspologic-native/blob/dev/packages/pyo3/src/mediator.rs#L204) if you pass in a node in the starting communities dictionary that isn't in the network edges you pass in. 

Maybe we just document it, we can detect and warn about it, and let people know that if they are doing a node level lookup and the node is an isolate they should probably do `partitions.get(node, None)` vs. `partitions[node]` or something?
> Would it be short sighted to just make these isolate nodes be in their own, isolate communities?

Probably. This is perfectly valid but I'm guessing is almost never what the user wants. Thoughts?

> Maybe we just document it, we can detect and warn about it

Yeah I think this sounds reasonable, personally I think a warning would be nice so when I go to use that dictionary as a map I know what to expect. 

In other places in the library if we are feeding nx graph and expecting a dictionary, and we internally remove isolates/non LCC nodes, I think we should probably throw this kind of warning
I could also change the return type to also return a `set` of unpartitioned nodes.  That might make a lot more sense anyway
im in favor of just a warning/explanation in the docs, just keeps the return type slightly simpler and still tells someone that some nodes are dropped and they cant blindly map using that partition. 

is there some reason you prefer specifying which nodes were dropped? 

don't feel too strongly though
Is there an established pattern for emitting warnings? Or do we just want to add a warning to the doc string?
`warnings` module, `warnings.warn(msg)` specifically
the `unittest.TestCase.assertWarns()` check is useful in unit tests too
Is it fair to say that we only care about isolates when the input is an nx Graph? Would not apply to edgelist as input. I guess for the matrix input forms we could detect if any rows are all zeros, but that seems like overkill
I think we need to do it for nx and for matrices.  I have no idea how involved that is in the sparse case :(
i'd recommend just using https://github.com/microsoft/graspologic/blob/29e18926bb14a0b98befa0557596384c7a149290/graspologic/utils/utils.py#L425 and throwing a warning if not. 

this check can be potentially expensive, but we could provide the option to turn it off a la `check_directed` in Leiden https://github.com/microsoft/graspologic/blob/29e18926bb14a0b98befa0557596384c7a149290/graspologic/partition/leiden.py#L212 or `check_lcc` in embed https://github.com/microsoft/graspologic/blob/29e18926bb14a0b98befa0557596384c7a149290/graspologic/embed/base.py#L84
Might I suggest a different approach?

```
if isinstance(graph, nx.Graph):
  # check if graph.number_of_nodes() == len(partitions), if not, we can presume isolates are removed
else if <is matrix form>:
  # check if graph.shape[0] == len(partitions), if not, we can presume isolates are removed
```
 Doing it this way means fewer check flags, which are an evil (though sometimes necessary), and also somewhat keeps the divergent-paths-depending-on-graph-representation-type stuff to a minimum  
> Might I suggest a different approach?
> 
> ```
> if isinstance(graph, nx.Graph):
>   # check if graph.number_of_nodes() == len(partitions), if not, we can presume isolates are removed
> else if <is matrix form>:
>   # check if graph.shape[0] == len(partitions), if not, we can presume isolates are removed
> ```
> 
> Doing it this way means fewer check flags, which are an evil (though sometimes necessary), and also somewhat keeps the divergent-paths-depending-on-graph-representation-type stuff to a minimum

love it! way simpler

i wonder though, what is happening internally in Leiden, such that the partition is always the same length as the LCC? 
I don't think it's to do with Leiden. When we build the edgelist to pass to Leiden we only iterate edges, which will drop any isolate nodes:

https://github.com/microsoft/graspologic/blob/29e18926bb14a0b98befa0557596384c7a149290/graspologic/partition/leiden.py#L80

If you pass a disconnected graph with components, then you'll get those nodes back in the output
well, partitions is the dictionary - we're just checking the size of it, or number of elements.

in other words, you know that leiden thinks there were, say, 15 nodes in the graph, but if your source graph says there are 20 then you know there's a mismatch
oh so this is less to do with connected components, and more just isolated (no edge) nodes that are ignored in the partition output?
I'm working on a variant of Dax's suggestion, but I'm doing it in the `_validate_and_build_edge_list` function, so that it returns all nodes
if I have a graph with 4 nodes, 2 edges which is not fully connected:
A <-> B
C <-> D

Leiden should still work and return a partition of length 4?
yep, that's what I was seeing in my testing
> if I have a graph with 4 nodes, 2 edges which is not fully connected:
> A <-> B
> C <-> D
> 
> Leiden should still work and return a partition of length 4?

Precisely!

It should even work if you have:

A <-> B
C <-> C

The node list is a superset of all nodes in the edge list, which is not necessarily the nodelist.

I wonder if we can emulate this in the algorithm by giving self loops in all disconnected edges with weight = 0?  Then everything would always get a partition, even if our graph we pass in is always defined by an edge list?
If you're super curious, the reason why this is a problem is that the leiden impl we have is **excessively** linked to  the in-memory representation of a graph.  It's ... non trivial to accommodate isolates - or at least, not off the top of my head!
for isolated nodes, if you wanted to include them like that, would you just envision having them each become their own "cluster"?

what about for a graph that is say, 2 connected components, each with 1000 nodes. what does Leiden do on that? is it effectively the same as running leiden twice, once on each component?
an isolated node should only ever be in it's own partition - it doesn't really have a community, yanno?

I would say that 2 connected components would probably give you roughly similar results to running leiden on each ~LCC~ (edit: CC).  the difference is all going to fall down to PRNG giving you slightly different results.  I bet if you ran some tests where you did a single edge list that contained 2 CCs vs. 2 separate edge lists corresponding to each CC, remapped the partition IDs so it was one big dictionary combining the two, and did an ARI over the partitions it would come out pretty favorably.  I don't have any data to support that, but that would be my expectation and intuition

edit: tried to clarify
> an isolated node should only ever be in it's own partition - it doesn't really have a community, yanno?

in fact, this is one of the enhancements leiden offers over louvain, which conceptually could randomly place isolate nodes in any community at all, whereas leiden restricts community membership to nodes that have a path into that community via their neighborhood
makes sense - i was just wondering whether we would be doing the user a favor by actually doing this LCC check for them. but in this case sense I think the notion of community/partition is well defined enough in the example above, seems like we should just let Leiden rip on the disconnected graph?
I don't know that the LCC should matter - it makes sense that your embeddings lose coherency/utility, but > 1 CC is perfectly fine in the leiden case.  I don't think we should lcc check, but we *should* make sure we warn if we're returning a partitions dictionary that doesn't contain a partition for every node :)
Aside, I wonder if I should be requiring an adjacency/neighbor list instead of edge list in rust, that way if we ever DO get wasm bindings or direct rust crate usage, it would support everyone, not just our own special case in python.  I am just not sure when I can get to it

Resolving patch:
diff --git a/graspologic/partition/leiden.py b/graspologic/partition/leiden.py
--- a/graspologic/partition/leiden.py
+++ b/graspologic/partition/leiden.py
@@ -1,6 +1,7 @@
 # Copyright (c) Microsoft Corporation.
 # Licensed under the MIT license.
 
+import warnings
 from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union
 
 import graspologic_native as gn
@@ -11,6 +12,25 @@
 from .. import utils
 
 
+def _put_node_in_node_str_map(node: Any, node_str_map: Dict[str, Any]) -> str:
+    """Add a node to the node_str_map, keyed by the node's string representation
+    which is returned by this function. Raise a ValueError in case of key collision."""
+    node_str = str(node)
+
+    if node_str in node_str_map and node_str_map[node_str] != node:
+        raise ValueError(
+            "str() representation collision in dataset. Please ensure that "
+            "str(node_id) cannot result in multiple node_ids being turned "
+            "into the same string. This exception is unlikely but would "
+            "result if a non primitive node ID of some sort had a "
+            "barebones __str__() definition for it."
+        )
+
+    node_str_map[node_str] = node
+
+    return node_str
+
+
 def _validate_and_build_edge_list(
     graph: Union[
         List[Tuple[Any, Any, Union[int, float]]],
@@ -35,7 +55,6 @@ def _validate_and_build_edge_list(
             "was found to be directed"
         )
 
-        # if we're already an edge list of str, str, float, return it
     if weight_default is not None and not isinstance(weight_default, (float, int)):
         raise TypeError("weight default must be a float or int")
 
@@ -49,26 +68,14 @@ def _validate_and_build_edge_list(
                 f"{type(graph[0])}, {repr(graph[0])}"
             )
 
-        new_to_old = {}
+        new_to_old: Dict[str, Any] = {}
         stringified_new = []
 
         for source, target, weight in graph:
-            source_str = str(source)
-            target_str = str(target)
-            weight = float(weight)
-            if (source_str in new_to_old and new_to_old[source_str] != source) or (
-                target_str in new_to_old and new_to_old[target_str] != target
-            ):
-                raise ValueError(
-                    "str() representation collision in dataset. Please ensure that "
-                    "str(node_id) cannot result in multiple node_ids being turned "
-                    "into the same string. This exception is unlikely but would "
-                    "result if a non primitive node ID of some sort had a "
-                    "barebones __str__() definition for it."
-                )
-            new_to_old[source_str] = source
-            new_to_old[target_str] = target
-            stringified_new.append((source_str, target_str, weight))
+            source_str = _put_node_in_node_str_map(source, new_to_old)
+            target_str = _put_node_in_node_str_map(target, new_to_old)
+            weight_as_float = float(weight)
+            stringified_new.append((source_str, target_str, weight_as_float))
 
         return new_to_old, stringified_new
 
@@ -76,24 +83,16 @@ def _validate_and_build_edge_list(
         # will catch all networkx graph types
         try:
             new_to_old = {}
+            for node in graph.nodes:
+                _put_node_in_node_str_map(node, new_to_old)
+
             stringified_new = []
             for source, target, data in graph.edges(data=True):
                 source_str = str(source)
                 target_str = str(target)
                 weight = float(data.get(weight_attribute, weight_default))
-                if (source_str in new_to_old and new_to_old[source_str] != source) or (
-                    target_str in new_to_old and new_to_old[target_str] != target
-                ):
-                    raise ValueError(
-                        "str() representation collision in dataset. Please ensure that "
-                        "str(node_id) cannot result in multiple node_ids being turned "
-                        "into the same string. This exception is unlikely but would "
-                        "result if a non primitive node ID of some sort had a "
-                        "barebones __str__() definition for it."
-                    )
-                new_to_old[source_str] = source
-                new_to_old[target_str] = target
                 stringified_new.append((source_str, target_str, weight))
+
             return new_to_old, stringified_new
         except TypeError:
             # None is returned for the weight if it doesn't exist and a weight_default
@@ -137,11 +136,13 @@ def _validate_and_build_edge_list(
             for i in range(0, len(rows)):
                 row = rows[i]
                 column = columns[i]
-                new_to_old[str(row)] = row
-                new_to_old[str(column)] = column
                 if row <= column:
                     edges.append((str(row), str(column), float(graph[row, column])))
 
+            # populate the node map using values of the same type as the CSR rows
+            for i in np.arange(shape[0], dtype=rows.dtype):
+                _put_node_in_node_str_map(i, new_to_old)
+
         return new_to_old, edges
 
     raise TypeError(
@@ -160,7 +161,7 @@ def _validate_common_arguments(
     is_weighted: Optional[bool] = None,
     weight_default: float = 1.0,
     check_directed: bool = True,
-):
+) -> None:
     if starting_communities is not None and not isinstance(starting_communities, dict):
         raise TypeError("starting_communities must be a dictionary")
     if not isinstance(extra_forced_iterations, int):
@@ -291,8 +292,8 @@ def leiden(
     -------
     Dict[str, int]
         The results of running leiden over the provided graph, a dictionary containing
-        mappings of node -> community id. The keys in the dictionary are the string
-        representations of the nodes.
+        mappings of node -> community id. Isolate nodes in the input graph are not returned
+        in the result.
 
     Raises
     ------
@@ -330,12 +331,12 @@ def leiden(
         raise TypeError("trials must be a positive integer")
     if trials < 1:
         raise ValueError("trials must be a positive integer")
-    node_id_mapping, graph = _validate_and_build_edge_list(
+    node_id_mapping, edges = _validate_and_build_edge_list(
         graph, is_weighted, weight_attribute, check_directed, weight_default
     )
 
     _modularity, partitions = gn.leiden(
-        edges=graph,
+        edges=edges,
         starting_communities=starting_communities,
         resolution=resolution,
         randomness=randomness,
@@ -349,6 +350,12 @@ def leiden(
         node_id_mapping[key]: value for key, value in partitions.items()
     }
 
+    if len(proper_partitions) < len(node_id_mapping):
+        warnings.warn(
+            "Leiden partitions do not contain all nodes from the input graph because input graph "
+            "contained isolate nodes."
+        )
+
     return proper_partitions
 
 
@@ -394,7 +401,12 @@ def _from_native(
 
 
 def hierarchical_leiden(
-    graph: Union[List[Tuple[str, str, float]], nx.Graph],
+    graph: Union[
+        List[Tuple[Any, Any, Union[int, float]]],
+        nx.Graph,
+        np.ndarray,
+        scipy.sparse.csr.csr_matrix,
+    ],
     max_cluster_size: int = 1000,
     starting_communities: Optional[Dict[str, int]] = None,
     extra_forced_iterations: int = 0,
@@ -512,7 +524,8 @@ def hierarchical_leiden(
         HierarchicalClusters identifying the state of every node and cluster at each
         level. The function
         :func:`graspologic.partition.HierarchicalCluster.final_hierarchical_clustering`
-        can be used to create a dictionary mapping of node -> cluster ID
+        can be used to create a dictionary mapping of node -> cluster ID. Isolate nodes
+        in the input graph are not returned in the result.
 
     Raises
     ------
@@ -565,6 +578,17 @@ def hierarchical_leiden(
         seed=random_seed,
     )
 
-    return [
-        _from_native(entry, node_id_mapping) for entry in hierarchical_clusters_native
-    ]
+    result_partitions = []
+    all_nodes = set()
+    for entry in hierarchical_clusters_native:
+        partition = _from_native(entry, node_id_mapping)
+        result_partitions.append(partition)
+        all_nodes.add(partition.node)
+
+    if len(result_partitions) < len(node_id_mapping):
+        warnings.warn(
+            "Leiden partitions do not contain all nodes from the input graph because input graph "
+            "contained isolate nodes."
+        )
+
+    return result_partitions
diff --git a/graspologic/utils/utils.py b/graspologic/utils/utils.py
--- a/graspologic/utils/utils.py
+++ b/graspologic/utils/utils.py
@@ -225,11 +225,35 @@ def is_unweighted(
         )
 
 
-def is_almost_symmetric(X, atol=1e-15):
-    if (X.ndim != 2) or (X.shape[0] != X.shape[1]):
+def is_almost_symmetric(
+    x: Union[np.ndarray, scipy.sparse.spmatrix], atol: float = 1e-15
+) -> bool:
+    """
+    Returns True if input x is nearly symmetric, which means that the entries differ by
+    no more than atol.
+
+    Parameters
+    ----------
+    x: Union[np.ndarray, scipy.sparse.spmatrix]
+        a square matrix
+    atol : float
+        a threshold for comparing the difference between off-diagonal entries
+        default 1e-15
+
+    Returns
+    -------
+    bool
+        True if x is a nearly symmetric square matrix
+
+    Raises
+    ------
+    TypeError
+        If the provided graph is not a numpy.ndarray or scipy.sparse.spmatrix
+    """
+    if (x.ndim != 2) or (x.shape[0] != x.shape[1]):
         return False
-    if isinstance(X, (np.ndarray, scipy.sparse.spmatrix)):
-        return abs(X - X.T).max() <= atol
+    if isinstance(x, (np.ndarray, scipy.sparse.spmatrix)):
+        return abs(x - x.T).max() <= atol
     else:
         raise TypeError("input a correct matrix type.")
 


Test patch:
diff --git a/tests/partition/test_leiden.py b/tests/partition/test_leiden.py
--- a/tests/partition/test_leiden.py
+++ b/tests/partition/test_leiden.py
@@ -2,7 +2,7 @@
 # Licensed under the MIT license.
 
 import unittest
-from typing import List, Tuple
+from typing import Dict, List, Tuple
 
 import networkx as nx
 import numpy as np
@@ -203,6 +203,118 @@ def test_matching_return_types(self):
             self.assertTrue(isinstance(node_id, int))
 
 
+class TestLeidenIsolates(unittest.TestCase):
+    """
+    Tests to verify fix for Github issue: 803 - isolate nodes are dropped silently
+    """
+
+    def setUp(self) -> None:
+        # prepare a graph with an isolate node
+        self.graph: nx.Graph = nx.complete_graph(10)
+        nodelist = sorted(self.graph.nodes)
+        for node in nodelist[1:]:
+            self.graph.remove_edge(0, node)
+
+    def assert_isolate_not_in_result(self, partitions: Dict[str, int]):
+        """verify that isolate node was not returned"""
+        self.assertTrue(
+            0 not in partitions, "the isolate node is not in the result from leiden"
+        )
+        self.assertTrue(
+            3 in partitions, "a node that was not removed is in the result from leiden"
+        )
+        self.assertEqual(
+            9,
+            len(partitions),
+            "the result contains all nodes in the connected component",
+        )
+
+    def assert_isolate_not_in_hierarchical_result(
+        self, partitions: List[HierarchicalCluster]
+    ):
+        """verify that isolate node was not returned"""
+        all_nodes = {p.node for p in partitions}
+
+        self.assertTrue(
+            0 not in all_nodes, "the isolate node is not in the result from leiden"
+        )
+        self.assertTrue(
+            3 in all_nodes, "a node that was not removed is in the result from leiden"
+        )
+        self.assertEqual(
+            9,
+            len(all_nodes),
+            "the result contains all nodes in the connected component",
+        )
+
+    def test_isolate_nodes_in_nx_graph_are_not_returned(self):
+        self.assertEqual(
+            10,
+            len(self.graph.nodes),
+            "the input graph contains all nodes including isolate",
+        )
+
+        with self.assertWarnsRegex(
+            UserWarning, "isolate", msg="should warn about isolates in input graph"
+        ):
+            partitions = leiden(self.graph)
+
+        self.assert_isolate_not_in_result(partitions)
+
+        with self.assertWarnsRegex(
+            UserWarning, "isolate", msg="hierarchical leiden should warn about isolates"
+        ):
+            hierarchical_partitions = hierarchical_leiden(self.graph)
+
+        self.assert_isolate_not_in_hierarchical_result(hierarchical_partitions)
+
+    def test_isolate_nodes_in_ndarray_are_not_returned(self):
+        ndarray_adj_matrix = nx.to_numpy_array(self.graph)
+
+        self.assertEqual(
+            10,
+            ndarray_adj_matrix.shape[0],
+            "the input array contains all nodes including isolate",
+        )
+
+        with self.assertWarnsRegex(
+            UserWarning, "isolate", msg="should warn about isolates in input graph"
+        ):
+            partitions = leiden(ndarray_adj_matrix)
+
+        self.assert_isolate_not_in_result(partitions)
+
+        with self.assertWarnsRegex(
+            UserWarning, "isolate", msg="hierarchical leiden should warn about isolates"
+        ):
+            hierarchical_partitions = hierarchical_leiden(ndarray_adj_matrix)
+
+        self.assert_isolate_not_in_hierarchical_result(hierarchical_partitions)
+
+    def test_isolate_nodes_in_csr_matrix_are_not_returned(self):
+        sparse_adj_matrix = nx.to_scipy_sparse_matrix(self.graph)
+
+        self.assertEqual(
+            10,
+            sparse_adj_matrix.shape[0],
+            "the input csr contains all nodes including isolate",
+        )
+
+        with self.assertWarnsRegex(
+            UserWarning, "isolate", msg="should warn about isolates in input graph"
+        ):
+            partitions = leiden(sparse_adj_matrix)
+
+        self.assert_isolate_not_in_result(partitions)
+
+        with self.assertWarnsRegex(
+            UserWarning, "isolate", msg="hierarchical leiden should warn about isolates"
+        ):
+            hierarchical_partitions = hierarchical_leiden(sparse_adj_matrix)
+
+        self.assert_isolate_not_in_hierarchical_result(hierarchical_partitions)
+
+
 def add_edges_to_graph(graph: nx.Graph) -> nx.Graph:
     graph.add_edge("nick", "dwayne", weight=1.0)
     graph.add_edge("nick", "dwayne", weight=3.0)
@@ -224,6 +336,28 @@ def test_empty_edge_list(self):
         )
         self.assertEqual([], results[1])
 
+    def test_assert_list_does_not_contain_tuples(self):
+        edges = ["invalid"]
+        with self.assertRaisesRegex(TypeError, "list of tuples"):
+            _validate_and_build_edge_list(
+                graph=edges,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=False,
+                weight_default=1.0,
+            )
+
+    def test_assert_list_contains_misshapen_tuple(self):
+        edges = [(1, 2, 1.0, 1.0)]
+        with self.assertRaisesRegex(TypeError, "list of tuples"):
+            _validate_and_build_edge_list(
+                graph=edges,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=False,
+                weight_default=1.0,
+            )
+
     def test_assert_wrong_types_in_tuples(self):
         edges = [(True, 4, "sandwich")]
         with self.assertRaises(ValueError):
@@ -450,3 +584,36 @@ def test_misshapen_matrices(self):
                 check_directed=True,
                 weight_default=1.0,
             )
+
+    def test_invalid_weight_default(self):
+        graph = nx.complete_graph(10)
+        with self.assertRaisesRegex(TypeError, "weight default"):
+            _validate_and_build_edge_list(
+                graph=graph,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=True,
+                weight_default="invalid",
+            )
+
+    def test_nx_graph_node_str_collision(self):
+        graph = nx.Graph()
+        graph.add_edge("1", 1, weight=1.0)
+        with self.assertRaisesRegex(ValueError, "representation collision"):
+            _validate_and_build_edge_list(
+                graph=graph,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=True,
+                weight_default=1.0,
+            )
+
+    def test_edgelist_node_str_collision(self):
+        with self.assertRaisesRegex(ValueError, "representation collision"):
+            _validate_and_build_edge_list(
+                graph=[("1", 1, 1.0)],
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=True,
+                weight_default=1.0,
+            )