Repository: graspologic-org/graspologic

Issue:
Seed automatic layout generation
So many parts of the layout generation process use random number generators behind the scenes. We should update our current algorithm to use a user provided seed so we can get deterministic output.


Discussion:
I would like to contribute if it's ok.
Will you please direct me towards something? @dwaynepryce @bdpedigo 
@vmdhhh thanks for your interest! I can't speak to this particular issue, and the requisite code is not yet merged for this (see #611). I'll let @dwaynepryce weigh in if there are tasks for that that can be done already. 

In the meantime, we do have a label for [good first issue](https://github.com/microsoft/graspologic/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) if you just want to get started with any contribution. Welcome to look at those for ones which people aren't already doing. 
@bdpedigo Thanks for letting me know. 
I will look into good first issues in the meantime :)
@vmdhhh we merged #611 in - if you want to pull latest from `dev` and take a look at where we'd need to pipe the seed down through the process, you can at any time! @bryantower and I are here if you have any questions for us about it, so let us know!

Resolving patch:
diff --git a/graspologic/embed/n2v.py b/graspologic/embed/n2v.py
--- a/graspologic/embed/n2v.py
+++ b/graspologic/embed/n2v.py
@@ -3,7 +3,6 @@
 
 import logging
 import math
-import random
 import time
 from typing import Any, List, Optional, Tuple, Union
 
@@ -22,6 +21,7 @@ def node2vec_embed(
     workers: int = 8,
     iterations: int = 1,
     interpolate_walk_lengths_by_node_degree: bool = True,
+    random_seed: Optional[int] = None,
 ) -> Tuple[np.ndarray, np.ndarray]:
     """
     Generates a node2vec embedding from a given graph. Will follow the word2vec algorithm to create the embedding.
@@ -57,6 +57,11 @@ def node2vec_embed(
         number of walks as a high degree node (which it will if this setting is not on), then the lower degree nodes
         will take a smaller breadth of random walks when compared to the high degree nodes. This will result in your
         lower degree walks dominating your higher degree nodes.
+    random_seed : int
+        Seed to be used for reproducible results. Default is None and will produce a random output. Note that for a fully
+        deterministically-reproducible run, you must also limit to a single worker thread (`workers=1`), to eliminate
+        ordering jitter from OS thread scheduling. In addition the environment variable ``PYTHONHASHSEED`` must be set
+        to control hash randomization.
 
     Returns
     -------
@@ -92,7 +97,11 @@ def node2vec_embed(
         interpolate_walk_lengths_by_node_degree,
     )
 
-    node2vec_graph = _Node2VecGraph(graph, return_hyperparameter, inout_hyperparameter)
+    random_state = np.random.RandomState(seed=random_seed)
+
+    node2vec_graph = _Node2VecGraph(
+        graph, return_hyperparameter, inout_hyperparameter, random_state
+    )
 
     logging.info(
         f"Starting preprocessing of transition probabilities on graph with {str(len(graph.nodes()))} nodes and "
@@ -110,7 +119,9 @@ def node2vec_embed(
     )
 
     logging.info(f"Learning embeddings at time {str(time.time())}")
-    model = _learn_embeddings(walks, dimensions, window_size, workers, iterations)
+    model = _learn_embeddings(
+        walks, dimensions, window_size, workers, iterations, random_seed
+    )
 
     end = time.time()
     logging.info(
@@ -168,7 +179,12 @@ def _preconditions(
 
 
 def _learn_embeddings(
-    walks: List[Any], dimensions: int, window_size: int, workers: int, iterations: int
+    walks: List[Any],
+    dimensions: int,
+    window_size: int,
+    workers: int,
+    iterations: int,
+    random_seed: Optional[int],
 ):
     """
     Learn embeddings by optimizing the skip-gram objective using SGD.
@@ -186,6 +202,7 @@ def _learn_embeddings(
         sg=1,  # Training algorithm: 1 for skip-gram; otherwise CBOW
         workers=workers,
         iter=iterations,
+        seed=random_seed,
     )
 
     return model
@@ -204,15 +221,23 @@ class _Node2VecGraph:
         Return hyperparameter
     inout_hyperparameter : float
         Inout hyperparameter
+    random_state : np.random.RandomState
+        Random State for reproducible results. Default is None and will produce random
+        results
     """
 
     def __init__(
-        self, graph: nx.Graph, return_hyperparameter: float, inout_hyperparameter: float
+        self,
+        graph: nx.Graph,
+        return_hyperparameter: float,
+        inout_hyperparameter: float,
+        random_state: Optional[np.random.RandomState] = None,
     ):
         self.graph: nx.Graph = graph
         self.is_directed = self.graph.is_directed()
         self.p = return_hyperparameter
         self.q = inout_hyperparameter
+        self.random_state = random_state
 
     def node2vec_walk(
         self,
@@ -251,7 +276,9 @@ def node2vec_walk(
                     walk.append(
                         current_neighbors[
                             _alias_draw(
-                                alias_nodes[current][0], alias_nodes[current][1]
+                                alias_nodes[current][0],
+                                alias_nodes[current][1],
+                                self.random_state,
                             )
                         ]
                     )
@@ -261,6 +288,7 @@ def node2vec_walk(
                         _alias_draw(
                             alias_edges[(prev, current)][0],
                             alias_edges[(prev, current)][1],
+                            self.random_state,
                         )
                     ]
                     walk.append(next)
@@ -325,7 +353,8 @@ def _simulate_walks(
             logging.info(
                 "Walk iteration: " + str(walk_iteration + 1) + "/" + str(num_walks)
             )
-            random.shuffle(nodes)
+
+            self.random_state.shuffle(nodes)
             for node in nodes:
                 walks.append(
                     self.node2vec_walk(
@@ -469,14 +498,16 @@ def _alias_setup(probabilities: List[float]):
     return sampled_probabilities, alias
 
 
-def _alias_draw(probabilities: List[float], alias: List[float]):
+def _alias_draw(
+    probabilities: List[float], alias: List[float], random_state: np.random.RandomState
+):
     """
     Draw sample from a non-uniform discrete distribution using alias sampling.
     """
     number_of_outcomes = len(probabilities)
-    random_index = int(np.floor(np.random.rand() * number_of_outcomes))
+    random_index = int(np.floor(random_state.rand() * number_of_outcomes))
 
-    if np.random.rand() < alias[random_index]:
+    if random_state.rand() < alias[random_index]:
         return random_index
     else:
         return probabilities[random_index]
diff --git a/graspologic/layouts/auto.py b/graspologic/layouts/auto.py
--- a/graspologic/layouts/auto.py
+++ b/graspologic/layouts/auto.py
@@ -1,24 +1,21 @@
 # Copyright (c) Microsoft Corporation.
 # Licensed under the MIT license.
 
-from typing import Any, Dict, List, Tuple
-
 import logging
 import math
 import time
+from typing import Any, Dict, List, Optional, Tuple
 
 import networkx as nx
 import numpy as np
-
-from sklearn.manifold import TSNE
 import umap
-
-from .classes import NodePosition
-from .nooverlap import remove_overlaps
+from sklearn.manifold import TSNE
 
 from ..embed import node2vec_embed
 from ..partition import leiden
 from ..preprocessing import cut_edges_by_weight, histogram_edge_weight
+from .classes import NodePosition
+from .nooverlap import remove_overlaps
 
 logger = logging.getLogger(__name__)
 
@@ -30,6 +27,7 @@ def layout_tsne(
     perplexity: int,
     n_iter: int,
     max_edges: int = 10000000,
+    random_seed: Optional[int] = None,
 ) -> Tuple[nx.Graph, List[NodePosition]]:
     """
     Automatic graph layout generation by creating a generalized node2vec embedding,
@@ -71,6 +69,11 @@ def layout_tsne(
         ``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
         likely need to reduce this number or you will out of memory.
+    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
+        between runs. In addition the environment variable ``PYTHONHASHSEED`` must be
+        set to control hash randomization.
 
     Returns
     -------
@@ -88,9 +91,11 @@ def layout_tsne(
     .. [1] van der Maaten, L.J.P.; Hinton, G.E. Visualizing High-Dimensional Data Using
         t-SNE. Journal of Machine Learning Research 9:2579-2605, 2008.
     """
-    lcc_graph, tensors, labels = _node2vec_for_layout(graph, max_edges)
-    points = TSNE(perplexity=perplexity, n_iter=n_iter).fit_transform(tensors)
-    positions = _node_positions_from(lcc_graph, labels, points)
+    lcc_graph, tensors, labels = _node2vec_for_layout(graph, max_edges, random_seed)
+    points = TSNE(
+        perplexity=perplexity, n_iter=n_iter, random_state=random_seed
+    ).fit_transform(tensors)
+    positions = _node_positions_from(lcc_graph, labels, points, random_seed=random_seed)
     return lcc_graph, positions
 
 
@@ -99,6 +104,7 @@ def layout_umap(
     min_dist: float = 0.75,
     n_neighbors: int = 25,
     max_edges: int = 10000000,
+    random_seed: Optional[int] = None,
 ) -> Tuple[nx.Graph, List[NodePosition]]:
     """
     Automatic graph layout generation by creating a generalized node2vec embedding,
@@ -142,6 +148,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 enviornment you will most
         likely need to reduce this number or you will out of memory.
+    random_seed : int
+        Seed to be used for reproducible results. Default is None and will produce
+        random results.
 
     Returns
     -------
@@ -163,11 +172,11 @@ def layout_umap(
         2007.08902v1, 17 Jul 2020.
     """
 
-    lcc_graph, tensors, labels = _node2vec_for_layout(graph, max_edges)
-    points = umap.UMAP(min_dist=min_dist, n_neighbors=n_neighbors).fit_transform(
-        tensors
-    )
-    positions = _node_positions_from(lcc_graph, labels, points)
+    lcc_graph, tensors, labels = _node2vec_for_layout(graph, max_edges, random_seed)
+    points = umap.UMAP(
+        min_dist=min_dist, n_neighbors=n_neighbors, random_state=random_seed
+    ).fit_transform(tensors)
+    positions = _node_positions_from(lcc_graph, labels, points, random_seed=random_seed)
     return lcc_graph, positions
 
 
@@ -201,13 +210,19 @@ def _approximate_prune(graph: nx.Graph, max_edges_to_keep: int = 1000000):
 def _node2vec_for_layout(
     graph: nx.Graph,
     max_edges: int = 10000000,
+    random_seed: Optional[int] = None,
 ) -> Tuple[nx.Graph, np.ndarray, np.ndarray]:
     graph = _approximate_prune(graph, max_edges)
     graph = _largest_connected_component(graph)
 
     start = time.time()
     tensors, labels = node2vec_embed(
-        graph=graph, dimensions=128, num_walks=10, window_size=2, iterations=3
+        graph=graph,
+        dimensions=128,
+        num_walks=10,
+        window_size=2,
+        iterations=3,
+        random_seed=random_seed,
     )
     embedding_time = time.time() - start
     logger.info(f"embedding completed in {embedding_time} seconds")
@@ -218,12 +233,13 @@ def _node_positions_from(
     graph: nx.Graph,
     labels: np.ndarray,
     down_projection_2d: np.ndarray,
+    random_seed: Optional[int] = None,
 ) -> List[NodePosition]:
     degree = graph.degree()
     sizes = _compute_sizes(degree)
     covered_area = _covered_size(sizes)
     scaled_points = _scale_points(down_projection_2d, covered_area)
-    partitions = leiden(graph)
+    partitions = leiden(graph, random_seed=random_seed)
     positions = [
         NodePosition(
             node_id=key,


Test patch:
diff --git a/tests/embed/__init__.py b/tests/embed/__init__.py
new file mode 100644
diff --git a/tests/embed/test_n2v.py b/tests/embed/test_n2v.py
new file mode 100644
--- /dev/null
+++ b/tests/embed/test_n2v.py
@@ -0,0 +1,25 @@
+# Copyright (c) Microsoft Corporation and contributors.
+# Licensed under the MIT License.
+
+import unittest
+import networkx as nx
+import numpy as np
+from graspologic.embed import n2v
+
+
+class TestN2V(unittest.TestCase):
+    def test_node2vec_embed(self):
+        g = nx.florentine_families_graph()
+
+        for s, t in g.edges():
+            g.add_edge(s, t, weight=1)
+
+        embedding = n2v.node2vec_embed(g, random_seed=1)
+
+        embedding2 = n2v.node2vec_embed(g, random_seed=1)
+
+        np.testing.assert_array_equal(embedding[0], embedding2[0])
+
+
+if __name__ == "__main__":
+    unittest.main()