Repository: graspologic-org/graspologic

Issue:
[BUG] Using layout_umap and getting key error
## Expected Behavior
a graph layout using UMAP for dim reduction to 2d

## Actual Behavior
coming from layouts\auto.py
 KeyError: '2'

## Example Code
Please see [How to create a Minimal, Reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) for some guidance on creating the best possible example of the problem
```python
import networkx as nx
from graspologic.layouts import layout_umap

g = nx.erdos_renyi_graph(10, 0.7, directed=False)
layout_umap(g)
```

## Full Traceback
```pytb
Paste the full traceback in case there is an exception

---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-17-1c062a6ca19a> in <module>
      3 
      4 g = nx.erdos_renyi_graph(10, 0.7, directed=False)
----> 5 layout_umap(g)

~\miniconda3\lib\site-packages\graspologic\layouts\auto.py in layout_umap(graph, min_dist, n_neighbors, max_edges, random_seed)
    177         min_dist=min_dist, n_neighbors=n_neighbors, random_state=random_seed
    178     ).fit_transform(tensors)
--> 179     positions = _node_positions_from(lcc_graph, labels, points, random_seed=random_seed)
    180     return lcc_graph, positions
    181 

~\miniconda3\lib\site-packages\graspologic\layouts\auto.py in _node_positions_from(graph, labels, down_projection_2d, random_seed)
    241     scaled_points = _scale_points(down_projection_2d, covered_area)
    242     partitions = leiden(graph, random_seed=random_seed)
--> 243     positions = [
    244         NodePosition(
    245             node_id=key,

~\miniconda3\lib\site-packages\graspologic\layouts\auto.py in <listcomp>(.0)
    246             x=scaled_points[index][0],
    247             y=scaled_points[index][1],
--> 248             size=sizes[key],
    249             community=partitions[key],
    250         )

KeyError: '2'

```

## Your Environment
* Python version:  3.8
* graspologic version:



[BUG] Using layout_umap and getting key error
## Expected Behavior
a graph layout using UMAP for dim reduction to 2d

## Actual Behavior
coming from layouts\auto.py
 KeyError: '2'

## Example Code
Please see [How to create a Minimal, Reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) for some guidance on creating the best possible example of the problem
```python
import networkx as nx
from graspologic.layouts import layout_umap

g = nx.erdos_renyi_graph(10, 0.7, directed=False)
layout_umap(g)
```

## Full Traceback
```pytb
Paste the full traceback in case there is an exception

---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-17-1c062a6ca19a> in <module>
      3 
      4 g = nx.erdos_renyi_graph(10, 0.7, directed=False)
----> 5 layout_umap(g)

~\miniconda3\lib\site-packages\graspologic\layouts\auto.py in layout_umap(graph, min_dist, n_neighbors, max_edges, random_seed)
    177         min_dist=min_dist, n_neighbors=n_neighbors, random_state=random_seed
    178     ).fit_transform(tensors)
--> 179     positions = _node_positions_from(lcc_graph, labels, points, random_seed=random_seed)
    180     return lcc_graph, positions
    181 

~\miniconda3\lib\site-packages\graspologic\layouts\auto.py in _node_positions_from(graph, labels, down_projection_2d, random_seed)
    241     scaled_points = _scale_points(down_projection_2d, covered_area)
    242     partitions = leiden(graph, random_seed=random_seed)
--> 243     positions = [
    244         NodePosition(
    245             node_id=key,

~\miniconda3\lib\site-packages\graspologic\layouts\auto.py in <listcomp>(.0)
    246             x=scaled_points[index][0],
    247             y=scaled_points[index][1],
--> 248             size=sizes[key],
    249             community=partitions[key],
    250         )

KeyError: '2'

```

## Your Environment
* Python version:  3.8
* graspologic version:





Discussion:
The core problem going on here is a bit complicated but also very simple at the same time - the TLDR is that we need to ensure we are always treating our keys as str(key) when we do these lookups because `leiden` is 100% of the time going to give you the string representation of your node back as the ID - which is what gets put into the partitions dictionary.  I had no interest trying to get Python's "pass any type of node id you want of any type" thing working in Rust so I just forced everything to encode as a string, which is great when the calling graph's nodes are strings too, but apparently has some issues in auto.py where it's using the original node ID (as an `int`, in this case) as the lookup to generate our `size` dictionary, but using str(node_id) as the key it's looking up with - which isn't going to appear in the sizes dict.

We just need to be careful that any dicts we create in this process are based on a `str(node_id)` or else we'll get things like this.

There's a similar typing issue in `leiden` and `modularity` where we implicitly cast your provided graph as `str(node_id)` for you to be nice and helpful when you call leiden, return `str(node_id) -> partition_id` mappings from leiden, and then when you try to call modularity on it to see how it turned out, it explodes because the graph you provide has `int(node_id)` keys and you're trying to look up `str(node_id)` mappings.

All of this is to say that maybe the right fix is for the python wrapper to our rust native impl should capture the node type in Python, let it cast to string for Rust and Leiden to process it, then before we hand the partition dictionary back we re-cast back to the original type.  And if we purposefully kept the original -> new mapping in memory in Python, we don't even have to have a lossy "cast back", since we'd just replace the new string representation of it back with the original as it was.

And that's the short explanation.
> All of this is to say that maybe the right fix is for the python wrapper to our rust native impl should capture the node type in Python, let it cast to string for Rust and Leiden to process it, then before we hand the partition dictionary back we re-cast back to the original type. And if we purposefully kept the original -> new mapping in memory in Python, we don't even have to have a lossy "cast back", since we'd just replace the new string representation of it back with the original as it was.

Big +1 for this, since I definitely think we should allow int or other datatypes as node keys, and this seems like the cleanest/simplest way to allow this.
It would also fix up our modularity bug calculation too, because the types would be what you'd have expected coming back.  So that's probably the right approach
@bdpedigo blech - we generate the edge list that leiden requires in `utils.py` where we generate a `List[Tuple[str, str, float]]`.  That's reasonable as a utility, but I think a `Tuple[Dict[str, Any], List[Tuple[str, str, float]]]` is not really something the common person is going to want - but it is what I need to do to fix this.

So the question is whether I can just axe this from utils or not.  There's benefits to having a "given a graph give me a basic edge list" but for all I know there's like 15 different versions of that behavior already.  So I figured I'd bring it up with you and see if you have a preference for what we do; axe it from utils or give everyone the `Tuple[Dict[str, Any], List[Tuple[str, str, float]]]` return type, which... doesn't seem super useful.  Or, I guess, replicate the function and have a private leiden specific version that does that.
hmmm i guess i dont see why the conversion to edgelist needs to be exposed to the user? am i missing something?

> Or, I guess, replicate the function and have a private leiden specific version that does that.

Is this you suggesting we don't need the user to see/care about what leiden is really eating under the hood? If so I agree with that.
We only exposed it before because having a quick way to write out an edge list regardless of what your current graph is was useful for us - sure it only saves like 2 lines of code, but a penny saved is a penny earned or something.  I don't know.

Regardless, it's currently in utils, and changing it to the new way is going to make it less obviously useful, so I'd rather remove it from utils entirely rather than say "here's this wacky function that almost nobody in the world is going to care about now but I guess you can use it to turn a graph in csr matrix form into an edge list if you want as long as you throw half of the top level tuple away" just doesn't sound particularly useful
totally fine with removing it from utils, personally!
The core problem going on here is a bit complicated but also very simple at the same time - the TLDR is that we need to ensure we are always treating our keys as str(key) when we do these lookups because `leiden` is 100% of the time going to give you the string representation of your node back as the ID - which is what gets put into the partitions dictionary.  I had no interest trying to get Python's "pass any type of node id you want of any type" thing working in Rust so I just forced everything to encode as a string, which is great when the calling graph's nodes are strings too, but apparently has some issues in auto.py where it's using the original node ID (as an `int`, in this case) as the lookup to generate our `size` dictionary, but using str(node_id) as the key it's looking up with - which isn't going to appear in the sizes dict.

We just need to be careful that any dicts we create in this process are based on a `str(node_id)` or else we'll get things like this.

There's a similar typing issue in `leiden` and `modularity` where we implicitly cast your provided graph as `str(node_id)` for you to be nice and helpful when you call leiden, return `str(node_id) -> partition_id` mappings from leiden, and then when you try to call modularity on it to see how it turned out, it explodes because the graph you provide has `int(node_id)` keys and you're trying to look up `str(node_id)` mappings.

All of this is to say that maybe the right fix is for the python wrapper to our rust native impl should capture the node type in Python, let it cast to string for Rust and Leiden to process it, then before we hand the partition dictionary back we re-cast back to the original type.  And if we purposefully kept the original -> new mapping in memory in Python, we don't even have to have a lossy "cast back", since we'd just replace the new string representation of it back with the original as it was.

And that's the short explanation.
> All of this is to say that maybe the right fix is for the python wrapper to our rust native impl should capture the node type in Python, let it cast to string for Rust and Leiden to process it, then before we hand the partition dictionary back we re-cast back to the original type. And if we purposefully kept the original -> new mapping in memory in Python, we don't even have to have a lossy "cast back", since we'd just replace the new string representation of it back with the original as it was.

Big +1 for this, since I definitely think we should allow int or other datatypes as node keys, and this seems like the cleanest/simplest way to allow this.
It would also fix up our modularity bug calculation too, because the types would be what you'd have expected coming back.  So that's probably the right approach
@bdpedigo blech - we generate the edge list that leiden requires in `utils.py` where we generate a `List[Tuple[str, str, float]]`.  That's reasonable as a utility, but I think a `Tuple[Dict[str, Any], List[Tuple[str, str, float]]]` is not really something the common person is going to want - but it is what I need to do to fix this.

So the question is whether I can just axe this from utils or not.  There's benefits to having a "given a graph give me a basic edge list" but for all I know there's like 15 different versions of that behavior already.  So I figured I'd bring it up with you and see if you have a preference for what we do; axe it from utils or give everyone the `Tuple[Dict[str, Any], List[Tuple[str, str, float]]]` return type, which... doesn't seem super useful.  Or, I guess, replicate the function and have a private leiden specific version that does that.
hmmm i guess i dont see why the conversion to edgelist needs to be exposed to the user? am i missing something?

> Or, I guess, replicate the function and have a private leiden specific version that does that.

Is this you suggesting we don't need the user to see/care about what leiden is really eating under the hood? If so I agree with that.
We only exposed it before because having a quick way to write out an edge list regardless of what your current graph is was useful for us - sure it only saves like 2 lines of code, but a penny saved is a penny earned or something.  I don't know.

Regardless, it's currently in utils, and changing it to the new way is going to make it less obviously useful, so I'd rather remove it from utils entirely rather than say "here's this wacky function that almost nobody in the world is going to care about now but I guess you can use it to turn a graph in csr matrix form into an edge list if you want as long as you throw half of the top level tuple away" just doesn't sound particularly useful
totally fine with removing it from utils, personally!

Resolving patch:
diff --git a/graspologic/partition/leiden.py b/graspologic/partition/leiden.py
--- a/graspologic/partition/leiden.py
+++ b/graspologic/partition/leiden.py
@@ -21,7 +21,7 @@ def _validate_and_build_edge_list(
     weight_attribute: str,
     check_directed: bool,
     weight_default: float,
-) -> List[Tuple[str, str, float]]:
+) -> Tuple[Dict[str, Any], List[Tuple[str, str, float]]]:
     if isinstance(graph, (nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph)):
         raise TypeError("directed or multigraphs are not supported in these functions")
     if (
@@ -34,12 +34,118 @@ def _validate_and_build_edge_list(
             "was found to be directed"
         )
 
-    return utils.to_weighted_edge_list(
-        graph=graph,
-        weight_attribute=weight_attribute,
-        weight_default=weight_default,
-        is_weighted=is_weighted,
-        is_directed=False,
+        # 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")
+
+    if isinstance(graph, list):
+        if len(graph) == 0:
+            return {}, []
+        if not isinstance(graph[0], tuple) or len(graph[0]) != 3:
+            raise TypeError(
+                "If the provided graph is a list, it must be a list of tuples with 3 "
+                "values in the form of Tuple[Any, Any, Union[int, float]], you provided"
+                f"{type(graph[0])}, {repr(graph[0])}"
+            )
+
+        new_to_old = {}
+        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))
+
+        return new_to_old, stringified_new
+
+    if isinstance(graph, nx.Graph):
+        # will catch all networkx graph types
+        try:
+            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
+            # is not set, which results in a TypeError when you call float(None)
+            raise ValueError(
+                f"The networkx graph provided did not contain a {weight_attribute} that"
+                " could be cast to float in one of the edges"
+            )
+
+    if isinstance(graph, (np.ndarray, scipy.sparse.csr.csr_matrix)):
+        shape = graph.shape
+        if len(shape) != 2 or shape[0] != shape[1]:
+            raise ValueError(
+                "graphs of type np.ndarray or csr.sparse.csr.csr_matrix should be "
+                "adjacency matrices with n x n shape"
+            )
+
+        if is_weighted is None:
+            is_weighted = not utils.is_unweighted(graph)
+
+        if not is_weighted and weight_default is None:
+            raise ValueError(
+                "the adjacency matrix provided is not weighted and a default weight has"
+                " not been set"
+            )
+
+        edges = []
+        new_to_old = {}
+        if isinstance(graph, np.ndarray):
+            for i in range(0, shape[0]):
+                new_to_old[str(i)] = i
+                start = i
+                for j in range(start, shape[1]):
+                    weight = graph[i][j]
+                    if weight != 0:
+                        if not is_weighted and weight == 1:
+                            weight = weight_default
+                        edges.append((str(i), str(j), float(weight)))
+        else:
+            rows, columns = graph.nonzero()
+            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])))
+
+        return new_to_old, edges
+
+    raise TypeError(
+        f"The type of graph provided {type(graph)} is not a list of 3-tuples, networkx "
+        f"graph, numpy.ndarray, or scipy.sparse.csr_matrix"
     )
 
 
@@ -211,7 +317,7 @@ def leiden(
         weight_default,
         check_directed,
     )
-    graph = _validate_and_build_edge_list(
+    node_id_mapping, graph = _validate_and_build_edge_list(
         graph, is_weighted, weight_attribute, check_directed, weight_default
     )
 
@@ -225,32 +331,20 @@ def leiden(
         seed=random_seed,
     )
 
-    return partitions
+    proper_partitions = {
+        node_id_mapping[key]: value for key, value in partitions.items()
+    }
+
+    return proper_partitions
 
 
 class HierarchicalCluster(NamedTuple):
-    node: str
+    node: Any
     cluster: int
     parent_cluster: Optional[int]
     level: int
     is_final_cluster: bool
 
-    @classmethod
-    def from_native(
-        cls, native_cluster: gn.HierarchicalCluster
-    ) -> "HierarchicalCluster":
-        if not isinstance(native_cluster, gn.HierarchicalCluster):
-            raise TypeError(
-                "This class method is only valid for graspologic_native.HierarchicalCluster"
-            )
-        return cls(
-            node=native_cluster.node,
-            cluster=native_cluster.cluster,
-            parent_cluster=native_cluster.parent_cluster,
-            level=native_cluster.level,
-            is_final_cluster=native_cluster.is_final_cluster,
-        )
-
     @staticmethod
     def final_hierarchical_clustering(
         hierarchical_clusters: List[
@@ -267,6 +361,24 @@ def final_hierarchical_clustering(
         return {cluster.node: cluster.cluster for cluster in final_clusters}
 
 
+def _from_native(
+    native_cluster: gn.HierarchicalCluster,
+    node_id_map: Dict[str, Any],
+) -> HierarchicalCluster:
+    if not isinstance(native_cluster, gn.HierarchicalCluster):
+        raise TypeError(
+            "This class method is only valid for graspologic_native.HierarchicalCluster"
+        )
+    node_id = node_id_map[native_cluster.node]
+    return HierarchicalCluster(
+        node=node_id,
+        cluster=native_cluster.cluster,
+        parent_cluster=native_cluster.parent_cluster,
+        level=native_cluster.level,
+        is_final_cluster=native_cluster.is_final_cluster,
+    )
+
+
 def hierarchical_leiden(
     graph: Union[List[Tuple[str, str, float]], nx.Graph],
     max_cluster_size: int = 1000,
@@ -425,7 +537,7 @@ def hierarchical_leiden(
     if max_cluster_size <= 0:
         raise ValueError("max_cluster_size must be a positive int")
 
-    graph = _validate_and_build_edge_list(
+    node_id_mapping, graph = _validate_and_build_edge_list(
         graph, is_weighted, weight_attribute, check_directed, weight_default
     )
     hierarchical_clusters_native = gn.hierarchical_leiden(
@@ -440,5 +552,5 @@ def hierarchical_leiden(
     )
 
     return [
-        HierarchicalCluster.from_native(entry) for entry in hierarchical_clusters_native
+        _from_native(entry, node_id_mapping) for entry in hierarchical_clusters_native
     ]
diff --git a/graspologic/utils/__init__.py b/graspologic/utils/__init__.py
--- a/graspologic/utils/__init__.py
+++ b/graspologic/utils/__init__.py
@@ -21,7 +21,6 @@
     fit_plug_in_variance_estimator,
     remove_vertices,
     remap_labels,
-    to_weighted_edge_list,
 )
 from .ptr import pass_to_ranks
 
diff --git a/graspologic/utils/utils.py b/graspologic/utils/utils.py
--- a/graspologic/utils/utils.py
+++ b/graspologic/utils/utils.py
@@ -944,144 +944,6 @@ def remap_labels(
         return remapped_y_pred
 
 
-def to_weighted_edge_list(
-    graph: Union[
-        List[Tuple[Any, Any, Union[float, int]]],
-        nx.Graph,
-        nx.DiGraph,
-        nx.MultiGraph,
-        nx.MultiDiGraph,
-        np.ndarray,
-        scipy.sparse.csr_matrix,
-    ],
-    is_directed: Optional[bool] = None,
-    is_weighted: Optional[bool] = None,
-    weight_attribute: Any = "weight",
-    weight_default: Optional[float] = None,
-) -> List[Tuple[str, str, float]]:
-    """
-    Creates a weighted edge list with string representations of the nodes and float
-    weights.
-
-    Parameters
-    ----------
-    graph : Union[List[Tuple[Any, Any, Union[float, int]]], nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph, np.ndarray, scipy.sparse.csr_matrix]
-        A representation of a graph either as a list of tuples, a networkx graph, or an
-        adjacency matrix from numpy or scipy.sparse.csr_matrix
-    is_directed : Optional[bool]
-        Default is ``None``. Ignored if an edge tuple list or networkx graph is
-        provided, but can be used to short circuit an exhaustive matrix symmetry check
-        for numpy and scipy matrices.
-    is_weighted : Optional[bool]
-        Default is ``None``. Ignored if an edge tuple list or networkx graph is
-        provided, but can be used to short circuit an exhaustive matrix weight check for
-        numpy and scipy matrices.
-    weight_attribute : Any
-        Default is the string ``weight``.  Used to retrieve the weight from networkx
-        edge attribute dictionary.
-    weight_default : Optional[float]
-        Default is ``None``. If given an unweighted graph, will use as a default weight
-        to output in the weighted edge list.
-
-    Raises
-    ------
-    TypeError
-        If the graph is not a type we support
-    ValueError
-        If an adjacency matrix shape is not :math:`n x n`
-        If the weight attribute on a networkx graph is not a float
-        If the adjacency matrix is unweighted and a default weight is not set
-
-    Returns
-    -------
-    List[Tuple[str, str, float]]
-        A list of edges for the type of graph.  Undirected graphs will only contain a
-        single entry for an edge between any two nodes.
-    """
-    # 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")
-
-    if isinstance(graph, list):
-        if len(graph) == 0:
-            return graph
-        if not isinstance(graph[0], tuple) or len(graph[0]) != 3:
-            raise TypeError(
-                "If the provided graph is a list, it must be a list of tuples with 3 "
-                "values in the form of Tuple[Any, Any, Union[int, float]], you provided"
-                f"{type(graph[0])}, {repr(graph[0])}"
-            )
-        return [
-            (str(source), str(target), float(weight))
-            for source, target, weight in graph
-        ]
-
-    if isinstance(graph, nx.Graph):
-        # will catch all networkx graph types
-        try:
-            return [
-                (
-                    str(source),
-                    str(target),
-                    float(data.get(weight_attribute, weight_default)),
-                )
-                for source, target, data in graph.edges(data=True)
-            ]
-        except TypeError:
-            # None is returned for the weight if it doesn't exist and a weight_default
-            # is not set, which results in a TypeError when you call float(None)
-            raise ValueError(
-                f"The networkx graph provided did not contain a {weight_attribute} that"
-                " could be cast to float in one of the edges"
-            )
-
-    if isinstance(graph, (np.ndarray, csr_matrix)):
-        shape = graph.shape
-        if len(shape) != 2 or shape[0] != shape[1]:
-            raise ValueError(
-                "graphs of type np.ndarray or csr.sparse.csr.csr_matrix should be "
-                "adjacency matrices with n x n shape"
-            )
-
-        if is_weighted is None:
-            is_weighted = not is_unweighted(graph)
-
-        if not is_weighted and weight_default is None:
-            raise ValueError(
-                "the adjacency matrix provided is not weighted and a default weight has"
-                " not been set"
-            )
-
-        if is_directed is None:
-            is_directed = not is_almost_symmetric(graph)
-
-        edges = []
-        if isinstance(graph, np.ndarray):
-            for i in range(0, shape[0]):
-                start = i if not is_directed else 0
-                for j in range(start, shape[1]):
-                    weight = graph[i][j]
-                    if weight != 0:
-                        if not is_weighted and weight == 1:
-                            weight = weight_default
-                        edges.append((str(i), str(j), float(weight)))
-        else:
-            edges = []
-            rows, columns = graph.nonzero()
-            for i in range(0, len(rows)):
-                row = rows[i]
-                column = columns[i]
-                if is_directed or (not is_directed and row <= column):
-                    edges.append((str(row), str(column), float(graph[row, column])))
-
-        return edges
-
-    raise TypeError(
-        f"The type of graph provided {type(graph)} is not a list of 3-tuples, networkx "
-        f"graph, numpy.ndarray, or scipy.sparse.csr_matrix"
-    )
-
-
 def suppress_common_warnings():
     """
     Suppresses common warnings that occur when using graspologic.


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
@@ -5,8 +5,11 @@
 
 import unittest
 import networkx as nx
+import numpy as np
+import scipy
 
 from graspologic.partition import HierarchicalCluster, hierarchical_leiden, leiden
+from graspologic.partition.leiden import _validate_and_build_edge_list, _from_native
 
 from tests.utils import data_file
 
@@ -14,7 +17,7 @@
 class TestHierarchicalCluster(unittest.TestCase):
     def test_from_native(self):
         with self.assertRaises(TypeError):
-            HierarchicalCluster.from_native("foo")
+            _from_native(1, {"1": 1})
 
         # note: it is impossible to create a native instance of a HierarchicalCluster.  We will
         # test from_native indirectly through calling graspologic.partition.hierarchical_leiden()
@@ -162,6 +165,15 @@ def test_correct_types(self):
             args["max_cluster_size"] = 0
             hierarchical_leiden(graph=graph, **args)
 
+        as_csr = nx.to_scipy_sparse_matrix(graph)
+        partitions = leiden(graph=as_csr, **good_args)
+        node_ids = partitions.keys()
+        for node_id in node_ids:
+            self.assertTrue(
+                isinstance(node_id, (np.int32, np.intc)),
+                f"{node_id} has {type(node_id)} should be an np.int32/np.intc",
+            )
+
     def test_hierarchical(self):
         # most of leiden is tested in unit / integration tests in graspologic-native.
         # All we're trying to test through these unit tests are the python conversions
@@ -173,3 +185,259 @@ def test_hierarchical(self):
 
         partitions = HierarchicalCluster.final_hierarchical_clustering(results)
         self.assertEqual(total_nodes, len(partitions))
+
+    # Github issue: 738
+    def test_matching_return_types(self):
+        graph = nx.erdos_renyi_graph(20, 0.4, seed=1234)
+        partitions = leiden(graph)
+        for node_id in partitions:
+            self.assertTrue(isinstance(node_id, int))
+
+
+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)
+    graph.add_edge("dwayne", "nick", weight=2.2)
+    graph.add_edge("dwayne", "ben", weight=4.2)
+    graph.add_edge("ben", "dwayne", weight=0.001)
+    return graph
+
+
+class TestValidEdgeList(unittest.TestCase):
+    def test_empty_edge_list(self):
+        edges = []
+        results = _validate_and_build_edge_list(
+            graph=edges,
+            is_weighted=True,
+            weight_attribute="weight",
+            check_directed=False,
+            weight_default=1.0,
+        )
+        self.assertEqual([], results[1])
+
+    def test_assert_wrong_types_in_tuples(self):
+        edges = [(True, 4, "sandwich")]
+        with self.assertRaises(ValueError):
+            _validate_and_build_edge_list(
+                graph=edges,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=False,
+                weight_default=1.0,
+            )
+
+        edges = [(True, False, 3.2)]
+        results = _validate_and_build_edge_list(
+            graph=edges,
+            is_weighted=True,
+            weight_attribute="weight",
+            check_directed=False,
+            weight_default=1.0,
+        )
+        self.assertEqual([("True", "False", 3.2)], results[1])
+
+    def test_empty_nx(self):
+        expected = {}, []
+        results = _validate_and_build_edge_list(
+            graph=nx.Graph(),
+            is_weighted=True,
+            weight_attribute="weight",
+            check_directed=False,
+            weight_default=1.0,
+        )
+        self.assertEqual(expected, results)
+        with self.assertRaises(TypeError):
+            _validate_and_build_edge_list(
+                graph=nx.DiGraph(),
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=False,
+                weight_default=1.0,
+            )
+        with self.assertRaises(TypeError):
+            _validate_and_build_edge_list(
+                graph=nx.MultiGraph(),
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=False,
+                weight_default=1.0,
+            )
+        with self.assertRaises(TypeError):
+            _validate_and_build_edge_list(
+                graph=nx.MultiDiGraph(),
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=False,
+                weight_default=1.0,
+            )
+
+    def test_valid_nx(self):
+        graph = add_edges_to_graph(nx.Graph())
+        expected = [("nick", "dwayne", 2.2), ("dwayne", "ben", 0.001)]
+        _, edges = _validate_and_build_edge_list(
+            graph=graph,
+            is_weighted=True,
+            weight_attribute="weight",
+            check_directed=False,
+            weight_default=1.0,
+        )
+        self.assertEqual(expected, edges)
+
+        with self.assertRaises(TypeError):
+            graph = add_edges_to_graph(nx.DiGraph())
+            _validate_and_build_edge_list(
+                graph=graph,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=False,
+                weight_default=1.0,
+            )
+
+        with self.assertRaises(TypeError):
+            graph = add_edges_to_graph(nx.MultiGraph())
+            _validate_and_build_edge_list(
+                graph=graph,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=False,
+                weight_default=1.0,
+            )
+
+        with self.assertRaises(TypeError):
+            graph = add_edges_to_graph(nx.MultiDiGraph())
+            _validate_and_build_edge_list(
+                graph=graph,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=False,
+                weight_default=1.0,
+            )
+
+    def test_unweighted_nx(self):
+        graph = nx.Graph()
+        graph.add_edge("dwayne", "nick")
+        graph.add_edge("nick", "ben")
+
+        with self.assertRaises(ValueError):
+            _validate_and_build_edge_list(
+                graph=graph,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=False,
+                weight_default=None,
+            )
+
+        _, edges = _validate_and_build_edge_list(
+            graph=graph,
+            is_weighted=True,
+            weight_attribute="weight",
+            check_directed=False,
+            weight_default=3.33333,
+        )
+        self.assertEqual(
+            [("dwayne", "nick", 3.33333), ("nick", "ben", 3.33333)],
+            edges,
+        )
+
+        graph.add_edge("salad", "sandwich", weight=100)
+        _, edges = _validate_and_build_edge_list(
+            graph=graph,
+            is_weighted=True,
+            weight_attribute="weight",
+            check_directed=False,
+            weight_default=3.33333,
+        )
+        self.assertEqual(
+            [
+                ("dwayne", "nick", 3.33333),
+                ("nick", "ben", 3.33333),
+                ("salad", "sandwich", 100),
+            ],
+            edges,
+        )
+
+    def test_matrices(self):
+        graph = add_edges_to_graph(nx.Graph())
+        di_graph = add_edges_to_graph(nx.DiGraph())
+
+        dense_undirected = nx.to_numpy_array(graph)
+        dense_directed = nx.to_numpy_array(di_graph)
+
+        sparse_undirected = nx.to_scipy_sparse_matrix(graph)
+        sparse_directed = nx.to_scipy_sparse_matrix(di_graph)
+
+        expected = [("0", "1", 2.2), ("1", "2", 0.001)]
+        _, edges = _validate_and_build_edge_list(
+            graph=dense_undirected,
+            is_weighted=True,
+            weight_attribute="weight",
+            check_directed=True,
+            weight_default=1.0,
+        )
+        self.assertEqual(expected, edges)
+        _, edges = _validate_and_build_edge_list(
+            graph=sparse_undirected,
+            is_weighted=True,
+            weight_attribute="weight",
+            check_directed=True,
+            weight_default=1.0,
+        )
+        self.assertEqual(expected, edges)
+
+        with self.assertRaises(ValueError):
+            _validate_and_build_edge_list(
+                graph=dense_directed,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=True,
+                weight_default=1.0,
+            )
+
+        with self.assertRaises(ValueError):
+            _validate_and_build_edge_list(
+                graph=sparse_directed,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=True,
+                weight_default=1.0,
+            )
+
+    def test_empty_adj_matrices(self):
+        dense = np.array([])
+        with self.assertRaises(ValueError):
+            _validate_and_build_edge_list(
+                graph=dense,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=True,
+                weight_default=1.0,
+            )
+
+        sparse = scipy.sparse.csr_matrix([])
+        with self.assertRaises(ValueError):
+            _validate_and_build_edge_list(
+                graph=sparse,
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=True,
+                weight_default=1.0,
+            )
+
+    def test_misshapen_matrices(self):
+        data = [[3, 2, 0], [2, 0, 1]]  # this is utter gibberish
+        with self.assertRaises(ValueError):
+            _validate_and_build_edge_list(
+                graph=np.array(data),
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=True,
+                weight_default=1.0,
+            )
+        with self.assertRaises(ValueError):
+            _validate_and_build_edge_list(
+                graph=scipy.sparse.csr_matrix(data),
+                is_weighted=True,
+                weight_attribute="weight",
+                check_directed=True,
+                weight_default=1.0,
+            )
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -6,7 +6,6 @@
 
 import networkx as nx
 import numpy as np
-import scipy
 import pytest
 from numpy.testing import assert_equal
 
@@ -548,128 +547,3 @@ def test_inputs(self):
 
         with pytest.raises(ValueError):
             remap_labels(self.y_pred, ["ant", "ant", "cat", "cat", "bird", "bird"])
-
-
-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)
-    graph.add_edge("dwayne", "nick", weight=2.2)
-    graph.add_edge("dwayne", "ben", weight=4.2)
-    graph.add_edge("ben", "dwayne", weight=0.001)
-    return graph
-
-
-class TestToWeightedEdgeList(unittest.TestCase):
-    def test_empty_edge_list(self):
-        edges = []
-        self.assertEqual([], gus.to_weighted_edge_list(edges))
-
-    def test_assert_wrong_types_in_tuples(self):
-        edges = [(True, 4, "sandwich")]
-        with self.assertRaises(ValueError):
-            gus.to_weighted_edge_list(edges)
-        edges = [(True, False, 3.2)]
-        self.assertEqual([("True", "False", 3.2)], gus.to_weighted_edge_list(edges))
-
-    def test_empty_nx(self):
-        self.assertEqual([], gus.to_weighted_edge_list(nx.Graph()))
-        self.assertEqual([], gus.to_weighted_edge_list(nx.DiGraph()))
-        self.assertEqual([], gus.to_weighted_edge_list(nx.MultiGraph()))
-        self.assertEqual([], gus.to_weighted_edge_list(nx.MultiDiGraph()))
-
-    def test_valid_nx(self):
-        graph = add_edges_to_graph(nx.Graph())
-        expected = [("nick", "dwayne", 2.2), ("dwayne", "ben", 0.001)]
-        self.assertEqual(expected, gus.to_weighted_edge_list(graph))
-
-        graph = add_edges_to_graph(nx.DiGraph())
-        expected = [
-            ("nick", "dwayne", 3.0),
-            ("dwayne", "nick", 2.2),
-            ("dwayne", "ben", 4.2),
-            ("ben", "dwayne", 0.001),
-        ]
-        self.assertEqual(expected, gus.to_weighted_edge_list(graph))
-
-        graph = add_edges_to_graph(nx.MultiGraph())
-        expected = [
-            ("nick", "dwayne", 1.0),
-            ("nick", "dwayne", 3.0),
-            ("nick", "dwayne", 2.2),
-            ("dwayne", "ben", 4.2),
-            ("dwayne", "ben", 0.001),
-        ]
-        self.assertEqual(expected, gus.to_weighted_edge_list(graph))
-
-        graph = add_edges_to_graph(nx.MultiDiGraph())
-        expected = [
-            ("nick", "dwayne", 1.0),
-            ("nick", "dwayne", 3.0),
-            ("dwayne", "nick", 2.2),
-            ("dwayne", "ben", 4.2),
-            ("ben", "dwayne", 0.001),
-        ]
-        self.assertEqual(expected, gus.to_weighted_edge_list(graph))
-
-    def test_unweighted_nx(self):
-        graph = nx.Graph()
-        graph.add_edge("dwayne", "nick")
-        graph.add_edge("nick", "ben")
-
-        with self.assertRaises(ValueError):
-            gus.to_weighted_edge_list(graph)
-
-        self.assertEqual(
-            [("dwayne", "nick", 3.33333), ("nick", "ben", 3.33333)],
-            gus.to_weighted_edge_list(graph, weight_default=3.33333),
-        )
-
-        graph.add_edge("salad", "sandwich", weight=100)
-
-        self.assertEqual(
-            [
-                ("dwayne", "nick", 3.33333),
-                ("nick", "ben", 3.33333),
-                ("salad", "sandwich", 100),
-            ],
-            gus.to_weighted_edge_list(graph, weight_default=3.33333),
-        )
-
-    def test_matrices(self):
-        graph = add_edges_to_graph(nx.Graph())
-        di_graph = add_edges_to_graph(nx.DiGraph())
-
-        dense_undirected = nx.to_numpy_array(graph)
-        dense_directed = nx.to_numpy_array(di_graph)
-
-        sparse_undirected = nx.to_scipy_sparse_matrix(graph)
-        sparse_directed = nx.to_scipy_sparse_matrix(di_graph)
-
-        expected = [("0", "1", 2.2), ("1", "2", 0.001)]
-        self.assertEqual(expected, gus.to_weighted_edge_list(dense_undirected))
-        self.assertEqual(expected, gus.to_weighted_edge_list(sparse_undirected))
-
-        expected = [
-            ("0", "1", 3.0),
-            ("1", "0", 2.2),
-            ("1", "2", 4.2),
-            ("2", "1", 0.001),
-        ]
-        self.assertEqual(expected, gus.to_weighted_edge_list(dense_directed))
-        self.assertEqual(expected, gus.to_weighted_edge_list(sparse_directed))
-
-    def test_empty_adj_matrices(self):
-        dense = np.array([])
-        with self.assertRaises(ValueError):
-            gus.to_weighted_edge_list(dense)
-
-        sparse = scipy.sparse.csr_matrix([])
-        with self.assertRaises(ValueError):
-            gus.to_weighted_edge_list(sparse)
-
-    def test_misshapen_matrices(self):
-        data = [[3, 2, 0], [2, 0, 1]]  # this is utter gibberish
-        with self.assertRaises(ValueError):
-            gus.to_weighted_edge_list(np.array(data))
-        with self.assertRaises(ValueError):
-            gus.to_weighted_edge_list(scipy.sparse.csr_matrix(data))