Repository: graspologic-org/graspologic

Issue:
[BUG] Fix random state behavior with multiple inits in GraphMatch
## Expected Behavior
Even when passing a `random_state` to `GraphMatch`, each init (child process) should get it's own random state 

## Actual Behavior
Each one gets the same

## Additional Details
Joblib has a nice tutorial on "differently random, but deterministic" https://joblib.readthedocs.io/en/latest/auto_examples/parallel_random_state.html#fixing-the-random-state-to-obtain-deterministic-results

We might as well take care of this and #761 at the same time?
cc @asaadeldin11 lmk if you agree that we might as well do both at once?


Discussion:

Resolving patch:
diff --git a/docs/reference/conf.py b/docs/reference/conf.py
--- a/docs/reference/conf.py
+++ b/docs/reference/conf.py
@@ -67,6 +67,7 @@
 intersphinx_mapping = {
     "anytree": ("https://anytree.readthedocs.io/en/latest/", None),
     "hyppo": ("https://hyppo.neurodata.io", None),
+    "joblib": ("https://joblib.readthedocs.io/en/latest/", None),
     "matplotlib": ("https://matplotlib.org", None),
     "networkx": ("https://networkx.org/documentation/stable", None),
     "numpy": ("https://numpy.org/doc/stable", None),
diff --git a/graspologic/match/gmp.py b/graspologic/match/gmp.py
--- a/graspologic/match/gmp.py
+++ b/graspologic/match/gmp.py
@@ -2,8 +2,9 @@
 # Licensed under the MIT License.
 
 import numpy as np
+from joblib import Parallel, delayed
 from sklearn.base import BaseEstimator
-from sklearn.utils import check_array, column_or_1d
+from sklearn.utils import check_array, check_random_state, column_or_1d
 
 from .qap import quadratic_assignment
 
@@ -85,6 +86,12 @@ class GraphMatch(BaseEstimator):
         instance, then that object is used.
         Default is None.
 
+    n_jobs : int or None, (default = None)
+        The number of jobs to run in parallel. Parallelization is over the
+        initializations, so only relevant when ``n_init > 1``. None means 1 unless in a
+        joblib.parallel_backend context. -1 means using all processors. See
+        :class:`joblib.Parallel` for more details.
+
     Attributes
     ----------
 
@@ -126,6 +133,7 @@ def __init__(
         gmp=True,
         padding="adopted",
         random_state=None,
+        n_jobs=None,
     ):
         if type(n_init) is int and n_init > 0:
             self.n_init = n_init
@@ -170,6 +178,7 @@ def __init__(
             msg = '"padding" parameter must be of type string'
             raise TypeError(msg)
         self.random_state = random_state
+        self.n_jobs = n_jobs
 
     def fit(self, A, B, seeds_A=[], seeds_B=[], S=None):
         """
@@ -228,15 +237,20 @@ def fit(self, A, B, seeds_A=[], seeds_B=[], S=None):
             "maximize": self.gmp,
             "partial_match": partial_match,
             "S": S,
-            "rng": self.random_state,
             "P0": self.init,
             "shuffle_input": self.shuffle_input,
             "maxiter": self.max_iter,
             "tol": self.eps,
         }
 
-        res = min(
-            [quadratic_assignment(A, B, options=options) for i in range(self.n_init)],
+        rng = check_random_state(self.random_state)
+        results = Parallel(n_jobs=self.n_jobs)(
+            delayed(quadratic_assignment)(A, B, options={**options, **{"rng": r}})
+            for r in rng.randint(np.iinfo(np.int32).max, size=self.n_init)
+        )
+        func = max if self.gmp else min
+        res = func(
+            results,
             key=lambda x: x.fun,
         )
 


Test patch:
diff --git a/tests/test_match.py b/tests/test_match.py
--- a/tests/test_match.py
+++ b/tests/test_match.py
@@ -10,7 +10,7 @@
 from graspologic.embed import AdjacencySpectralEmbed
 from graspologic.align import SignFlips
 
-np.random.seed(0)
+np.random.seed(1)
 
 
 class TestGMP:
@@ -169,17 +169,23 @@ def test_rand_SGM(self):
         score = chr12c.score_
         assert 11156 <= score < 12500
 
+    def test_parallel(self):
+        A, B = self._get_AB()
+        gmp = GMP(gmp=False, n_init=2, n_jobs=2)
+        gmp.fit(A, B)
+        score = gmp.score_
+        assert 11156 <= score < 13500
+
     def test_padding(self):
         n = 50
         p = 0.4
 
-        np.random.seed(1)
         G1 = er_np(n=n, p=p)
-        G2 = G1[: (n - 1), : (n - 1)]  # remove two nodes
+        G2 = G1[:-2, :-2]  # remove two nodes
         gmp_adopted = GMP(padding="adopted")
         res = gmp_adopted.fit(G1, G2)
 
-        assert 1.0 == (sum(res.perm_inds_ == np.arange(n)) / n)
+        assert 0.95 <= (sum(res.perm_inds_ == np.arange(n)) / n)
 
     def test_custom_init(self):
         A, B = self._get_AB()
@@ -222,7 +228,7 @@ def test_custom_init_seeds(self):
         assert gm.score_ == 11156
 
     def test_sim(self):
-        n = 90
+        n = 150
         rho = 0.9
         n_per_block = int(n / 3)
         n_blocks = 3