Repository: dwavesystems/dimod

Issue:
Problem generators should accept seeds
**Application**
I love the problem generators in `dimod.generators`. It would be great if they accepted a seed so they could be reproducible!

**Proposed Solution**
Accept a `seed` parameter as an int, and pass that into `random.Random()`.


Discussion:

Patch:
diff --git a/dimod/decorators.py b/dimod/decorators.py
index 21aee399..7355bb1e 100644
--- a/dimod/decorators.py
+++ b/dimod/decorators.py
@@ -248,11 +248,41 @@ def vartype_argument(*arg_names):
     return _vartype_arg
 
 
-def graph_argument(*arg_names):
+def _is_integer(a):
+    if isinstance(a, integer_types):
+        return True
+    if hasattr(a, "is_integer") and a.is_integer():
+        return True
+    return False
+
+
+# we would like to do graph_argument(*arg_names, allow_None=False), but python2...
+def graph_argument(*arg_names, **options):
+    """Coerce given graphs into a consisent form.
+
+    Args:
+        *arg_names (optional, default='G'):
+            The names of the arguments for input graphs.
+
+        allow_None (bool, optional, default=False):
+            Allow None as an input graph in which case it is passed through as
+            None.
+
+    """
+
     # by default, constrain only one argument, the 'G`
     if not arg_names:
         arg_names = ['G']
 
+    # we only allow one option allow_None
+    allow_None = options.pop("allow_None", False)
+    if options:
+        # to keep it consistent with python3
+        # behaviour like graph_argument(*arg_names, allow_None=False)
+        key, _ = options.popitem()
+        msg = "graph_argument() for an unexpected keyword argument '{}'".format(key)
+        raise TypeError(msg)
+
     def _graph_arg(f):
         argspec = getargspec(f)
 
@@ -266,7 +296,7 @@ def graph_argument(*arg_names):
                 # networkx or perhaps a named tuple
                 kwargs[name] = (list(G.nodes), list(G.edges))
 
-            elif isinstance(G, integer_types):
+            elif _is_integer(G):
                 # an integer, cast to a complete graph
                 kwargs[name] = (list(range(G)), list(itertools.combinations(range(G), 2)))
 
@@ -276,9 +306,9 @@ def graph_argument(*arg_names):
                     # if nodes is an int
                     kwargs[name] = (list(range(G[0])), G[1])
 
-            elif isinstance(G, float) and G.is_integer():
-                G = int(G)
-                kwargs[name] = (list(range(G)), list(itertools.combinations(range(G), 2)))
+            elif allow_None and G is None:
+                # allow None to be passed through
+                return G
 
             else:
                 raise ValueError('Unexpected graph input form')
diff --git a/dimod/generators/chimera.py b/dimod/generators/chimera.py
index f1df6e7f..46d27844 100644
--- a/dimod/generators/chimera.py
+++ b/dimod/generators/chimera.py
@@ -12,21 +12,26 @@
 #    See the License for the specific language governing permissions and
 #    limitations under the License.
 #
-# ================================================================================================
+# =============================================================================
 from __future__ import absolute_import
 
-import random
+import numpy as np
+import numpy.random
 
 from dimod.binary_quadratic_model import BinaryQuadraticModel
+from dimod.decorators import graph_argument
 from dimod.vartypes import SPIN
 
 __all__ = ['chimera_anticluster']
 
 
-def chimera_anticluster(m, n=None, t=4, multiplier=3.0, cls=BinaryQuadraticModel):
+@graph_argument('subgraph', allow_None=True)
+def chimera_anticluster(m, n=None, t=4, multiplier=3.0,
+                        cls=BinaryQuadraticModel, subgraph=None, seed=None):
     """Generate an anticluster problem on a Chimera lattice.
 
-    An anticluster problem has weak interactions within a tile and strong interactions outside.
+    An anticluster problem has weak interactions within a tile and strong
+    interactions between tiles.
 
     Args:
         m (int):
@@ -35,19 +40,30 @@ def chimera_anticluster(m, n=None, t=4, multiplier=3.0, cls=BinaryQuadraticModel
         n (int, optional, default=m):
             Number of columns in the Chimera lattice.
 
-        t (int, optiona, default=t):
+        t (int, optional, default=t):
             Size of the shore within each Chimera tile.
 
-        multiplier (number):
+        multiplier (number, optional, default=3.0):
             Strength of the intertile edges.
 
-        cls (:class:`.BinaryQuadraticModel`):
+        cls (class, optional, default=:class:`.BinaryQuadraticModel`):
             Binary quadratic model class to build from.
 
+        subgraph (int/tuple[nodes, edges]/:obj:`~networkx.Graph`):
+            A subgraph of a Chimera(m, n, t) graph to build the anticluster
+            problem on.
+
+        seed (int, optional, default=None):
+            Random seed.
+
     Returns:
         :obj:`.BinaryQuadraticModel`: spin-valued binary quadratic model.
 
     """
+    if seed is None:
+        seed = numpy.random.randint(2**32, dtype=np.uint32)
+    r = numpy.random.RandomState(seed)
+
     m = int(m)
     if n is None:
         n = m
@@ -55,13 +71,49 @@ def chimera_anticluster(m, n=None, t=4, multiplier=3.0, cls=BinaryQuadraticModel
         n = int(n)
     t = int(t)
 
-    # only defined for Ising problems
-    linear = {}
+    ldata = np.zeros(m*n*t*2)  # number of nodes
+
+    if m and n and t:
+        inrow, incol = zip(*_iter_chimera_tile_edges(m, n, t))
+
+        if m > 1 or n > 1:
+            outrow, outcol = zip(*_iter_chimera_intertile_edges(m, n, t))
+        else:
+            outrow = outcol = tuple()
+
+        qdata = r.choice((-1., 1.), size=len(inrow)+len(outrow))
+
+        qdata[len(inrow):] *= multiplier
+
+        irow = inrow + outrow
+        icol = incol + outcol
+
+    else:
+        irow = icol = qdata = tuple()
+
+    bqm = cls.from_numpy_vectors(ldata, (irow, icol, qdata), 0.0, SPIN)
+
+    if subgraph is not None:
+        nodes, edges = subgraph
+
+        subbqm = cls.empty(SPIN)
+
+        try:
+            subbqm.add_variables_from((v, bqm.linear[v]) for v in nodes)
+
+        except KeyError:
+            msg = "given 'subgraph' contains nodes not in Chimera({}, {}, {})".format(m, n, t)
+            raise ValueError(msg)
+
+        try:
+            subbqm.add_interactions_from((u, v, bqm.adj[u][v]) for u, v in edges)
+        except KeyError:
+            msg = "given 'subgraph' contains edges not in Chimera({}, {}, {})".format(m, n, t)
+            raise ValueError(msg)
 
-    quadratic = {edge: random.choice((-1., 1.)) for edge in _iter_chimera_tile_edges(m, n, t)}
-    quadratic.update({edge: multiplier*random.choice((-1., 1.)) for edge in _iter_chimera_intertile_edges(m, n, t)})
+        bqm = subbqm
 
-    return cls(linear, quadratic, 0.0, SPIN)
+    return bqm
 
 
 def _iter_chimera_tile_edges(m, n, t):
diff --git a/dimod/generators/fcl.py b/dimod/generators/fcl.py
index f29a2c04..3cbca9dc 100644
--- a/dimod/generators/fcl.py
+++ b/dimod/generators/fcl.py
@@ -17,7 +17,9 @@
 from __future__ import absolute_import
 
 import itertools
-import random
+
+import numpy as np
+import numpy.random
 
 from dimod.binary_quadratic_model import BinaryQuadraticModel
 from dimod.decorators import graph_argument
@@ -27,7 +29,8 @@ __all__ = ['frustrated_loop']
 
 
 @graph_argument('graph')
-def frustrated_loop(graph, num_cycles, R=float('inf'), cycle_predicates=tuple(), max_failed_cycles=100):
+def frustrated_loop(graph, num_cycles, R=float('inf'), cycle_predicates=tuple(),
+                    max_failed_cycles=100, seed=None):
     """Generate a frustrated loop problem.
 
     A (generic) frustrated loop (FL) problem is a sum of Hamiltonians, each generated from a single
@@ -59,6 +62,9 @@ def frustrated_loop(graph, num_cycles, R=float('inf'), cycle_predicates=tuple(),
         max_failed_cycles (int, optional, default=100):
             Maximum number of failures to find a cycle before terminating.
 
+        seed (int, optional, default=None):
+            Random seed.
+
     .. [HJARTL] Hen, I., J. Job, T. Albash, T.F. Rønnow, M. Troyer, D. Lidar. Probing for quantum
         speedup in spin glass problems with planted solutions. https://arxiv.org/abs/1502.01663v2
 
@@ -67,9 +73,16 @@ def frustrated_loop(graph, num_cycles, R=float('inf'), cycle_predicates=tuple(),
 
     """
     nodes, edges = graph
-    assert num_cycles > 0
-    assert R > 0
-    assert max_failed_cycles > 0
+    if num_cycles <= 0:
+        raise ValueError("num_cycles should be a positive integer")
+    if R <= 0:
+        raise ValueError("R should be a positive integer")
+    if max_failed_cycles <= 0:
+        raise ValueError("max_failed_cycles should be a positive integer")
+
+    if seed is None:
+        seed = numpy.random.randint(2**32, dtype=np.uint32)
+    r = numpy.random.RandomState(seed)
 
     # G = nx.Graph(edges)
     # J = collections.defaultdict(int)
@@ -89,7 +102,7 @@ def frustrated_loop(graph, num_cycles, R=float('inf'), cycle_predicates=tuple(),
     good_cycles = 0
     while good_cycles < num_cycles and failed_cycles < max_failed_cycles:
 
-        cycle = _random_cycle(adj)
+        cycle = _random_cycle(adj, r)
 
         # if the cycle failed or it is otherwise invalid, mark as failed and continue
         if cycle is None or not all(pred(cycle) for pred in cycle_predicates):
@@ -102,7 +115,7 @@ def frustrated_loop(graph, num_cycles, R=float('inf'), cycle_predicates=tuple(),
         cycle_J = {(cycle[i - 1], cycle[i]): -1. for i in range(len(cycle))}
 
         # randomly select an edge and flip it
-        idx = random.randrange(len(cycle))
+        idx = r.randint(len(cycle))
         cycle_J[(cycle[idx - 1], cycle[idx])] *= -1.
 
         # update the bqm
@@ -119,11 +132,11 @@ def frustrated_loop(graph, num_cycles, R=float('inf'), cycle_predicates=tuple(),
     return bqm
 
 
-def _random_cycle(adj):
+def _random_cycle(adj, random_state):
     """Find a cycle using a random graph walk."""
 
     # step through idx values in adj to pick a random one, random.choice does not work on dicts
-    n = random.randrange(len(adj))
+    n = random_state.randint(len(adj))
     for idx, v in enumerate(adj):
         if idx == n:
             break
@@ -145,7 +158,7 @@ def _random_cycle(adj):
             return None
 
         # get a random neighbor
-        u = random.choice(neighbors)
+        u = random_state.choice(neighbors)
         if u in visited:
             # if we've seen this neighbour, then we have a cycle starting from it
             return walk[visited[u]:]
diff --git a/dimod/generators/random.py b/dimod/generators/random.py
index f1ef5b01..1125e9dc 100644
--- a/dimod/generators/random.py
+++ b/dimod/generators/random.py
@@ -15,7 +15,8 @@
 # ================================================================================================
 from __future__ import absolute_import
 
-import random
+import numpy as np
+import numpy.random
 
 from dimod.binary_quadratic_model import BinaryQuadraticModel
 from dimod.decorators import graph_argument
@@ -24,7 +25,8 @@ __all__ = ['uniform', 'randint']
 
 
 @graph_argument('graph')
-def uniform(graph, vartype, low=0.0, high=1.0, cls=BinaryQuadraticModel):
+def uniform(graph, vartype, low=0.0, high=1.0, cls=BinaryQuadraticModel,
+            seed=None):
     """Generate a bqm with random biases and offset.
 
     Biases and offset are drawn from a uniform distribution range (low, high).
@@ -49,16 +51,34 @@ def uniform(graph, vartype, low=0.0, high=1.0, cls=BinaryQuadraticModel):
         cls (:class:`.BinaryQuadraticModel`):
             Binary quadratic model class to build from.
 
+        seed (int, optional, default=None):
+            Random seed.
+
     """
-    nodes, edges = graph
-    return cls(((n, random.uniform(low, high)) for n in nodes),
-               ((u, v, random.uniform(low, high)) for u, v in edges),
-               random.uniform(low, high),
-               vartype)
+    if seed is None:
+        seed = numpy.random.randint(2**32, dtype=np.uint32)
+    r = numpy.random.RandomState(seed)
+
+    variables, edges = graph
+
+    index = {v: idx for idx, v in enumerate(variables)}
+
+    if edges:
+        irow, icol = zip(*((index[u], index[v]) for u, v in edges))
+    else:
+        irow = icol = tuple()
+
+    ldata = r.uniform(low, high, size=len(variables))
+    qdata = r.uniform(low, high, size=len(irow))
+    offset = r.uniform(low, high)
+
+    return cls.from_numpy_vectors(ldata, (irow, icol, qdata), offset, vartype,
+                                  variable_order=variables)
 
 
 @graph_argument('graph')
-def randint(graph, vartype, low=0, high=1, cls=BinaryQuadraticModel):
+def randint(graph, vartype, low=0, high=1, cls=BinaryQuadraticModel,
+            seed=None):
     """Generate a bqm with random biases and offset.
 
     Biases and offset are integer-valued in range [low, high] inclusive.
@@ -83,9 +103,27 @@ def randint(graph, vartype, low=0, high=1, cls=BinaryQuadraticModel):
         cls (:class:`.BinaryQuadraticModel`):
             Binary quadratic model class to build from.
 
+        seed (int, optional, default=None):
+            Random seed.
+
     """
-    nodes, edges = graph
-    return cls(((n, random.randint(low, high)) for n in nodes),
-               ((u, v, random.randint(low, high)) for u, v in edges),
-               0.0,
-               vartype)
+    if seed is None:
+        seed = numpy.random.randint(2**32, dtype=np.uint32)
+    r = numpy.random.RandomState(seed)
+
+    variables, edges = graph
+
+    index = {v: idx for idx, v in enumerate(variables)}
+
+    if edges:
+        irow, icol = zip(*((index[u], index[v]) for u, v in edges))
+    else:
+        irow = icol = tuple()
+
+    # high+1 for inclusive range
+    ldata = r.randint(low, high+1, size=len(variables))
+    qdata = r.randint(low, high+1, size=len(irow))
+    offset = r.randint(low, high+1)
+
+    return cls.from_numpy_vectors(ldata, (irow, icol, qdata), offset, vartype,
+                                  variable_order=variables)


Test patch:
diff --git a/tests/test_decorators.py b/tests/test_decorators.py
index 475f9328..32864d6b 100644
--- a/tests/test_decorators.py
+++ b/tests/test_decorators.py
@@ -174,3 +174,25 @@ class TestGraphArgument(unittest.TestCase):
             self.assertIn(n, range(N))
         for edge in edges:
             self.assertIn(edge, edgelist)
+
+    def test_allow_None_False(self):
+        @graph_argument('G')
+        def f(G=None):
+            return G
+
+        with self.assertRaises(ValueError):
+            f()
+
+    def test_allow_None_True(self):
+
+        @graph_argument('G', allow_None=True)
+        def f(G=None):
+            return G
+
+        self.assertIs(f(), None)
+
+    def test_other_kwarg(self):
+        with self.assertRaises(TypeError):
+            @graph_argument('G', b=True)
+            def f(G):
+                pass
diff --git a/tests/test_generators.py b/tests/test_generators.py
index 70799caf..49091c70 100644
--- a/tests/test_generators.py
+++ b/tests/test_generators.py
@@ -33,6 +33,22 @@ class TestRandomUniform(unittest.TestCase):
         self.assertEqual(len(bqm), 1)
         self.assertIn(0, bqm.variables)
 
+    def test_empty(self):
+        bqm = dimod.generators.random.uniform(0, dimod.BINARY)
+
+        # should have a single node
+        self.assertEqual(len(bqm), 0)
+
+    def test_seed(self):
+        bqm0 = dimod.generators.random.uniform(1, dimod.BINARY, seed=506)
+        bqm1 = dimod.generators.random.uniform(1, dimod.BINARY, seed=506)
+
+        self.assertEqual(bqm0, bqm1)
+
+        bqm2 = dimod.generators.random.uniform(1, dimod.BINARY, seed=123)
+
+        self.assertNotEqual(bqm2, bqm1)
+
 
 class TestRandomRandint(unittest.TestCase):
     def test_singleton(self):
@@ -42,6 +58,22 @@ class TestRandomRandint(unittest.TestCase):
         self.assertEqual(len(bqm), 1)
         self.assertIn(0, bqm.variables)
 
+    def test_empty(self):
+        bqm = dimod.generators.random.randint(0, dimod.BINARY)
+
+        # should have a single node
+        self.assertEqual(len(bqm), 0)
+
+    def test_seed(self):
+        bqm0 = dimod.generators.random.randint(100, dimod.BINARY, seed=506)
+        bqm1 = dimod.generators.random.randint(100, dimod.BINARY, seed=506)
+
+        self.assertEqual(bqm0, bqm1)
+
+        bqm2 = dimod.generators.random.randint(100, dimod.BINARY, seed=123)
+
+        self.assertNotEqual(bqm2, bqm1)
+
 
 class TestChimeraAnticluster(unittest.TestCase):
     def test_singletile(self):
@@ -55,9 +87,60 @@ class TestChimeraAnticluster(unittest.TestCase):
                 self.assertIn(j, bqm.adj[i])
                 self.assertIn(bqm.adj[i][j], (-1, 1))
 
+    @unittest.skipUnless(_networkx, "no networkx installed")
+    def test_multitile(self):
+        bqm = dimod.generators.chimera_anticluster(2, multiplier=4)
+
+        self.assertEqual(set(bqm.linear.values()), {0})
+
+        for (u, v), bias in bqm.quadratic.items():
+            if u // 8 == v // 8:
+                self.assertIn(bias, {-1, 1})
+            else:
+                self.assertIn(bias, {-4, 4})
+
+    def test_singletile_subgraph(self):
+        subgraph = ([0, 1, 2, 3, 4, 5, 6],
+                    [(0, 4), (0, 5), (0, 6),
+                     (1, 4), (1, 5), (1, 6),
+                     (2, 4), (2, 5),
+                     (3, 4), (3, 5), (3, 6)])
+        bqm = dimod.generators.chimera_anticluster(1, subgraph=subgraph)
+
+        self.assertEqual(len(bqm), 7)
+        self.assertEqual(len(bqm.quadratic), 11)
+
+        nodes, edges = subgraph
+        for v in nodes:
+            self.assertEqual(bqm.linear[v], 0)
+        for u, v in edges:
+            self.assertIn(bqm.quadratic[(u, v)], {-1, 1})
+
+    def test_singletile_not_subgraph(self):
+        subgraph = ([0, 'a'], [(0, 1)])
 
+        with self.assertRaises(ValueError):
+            dimod.generators.chimera_anticluster(1, subgraph=subgraph)
+
+    def test_seed(self):
+        bqm0 = dimod.generators.chimera_anticluster(2, 1, 3, seed=506)
+        bqm1 = dimod.generators.chimera_anticluster(2, 1, 3, seed=506)
+
+        self.assertEqual(bqm0, bqm1)
+
+        bqm2 = dimod.generators.chimera_anticluster(2, 1, 3, seed=123)
+
+        self.assertNotEqual(bqm2, bqm1)
+
+    def test_empty(self):
+        bqm = dimod.generators.chimera_anticluster(0)
+
+        # should have a single node
+        self.assertEqual(len(bqm), 0)
+
+
+@unittest.skipUnless(_networkx, "no networkx installed")
 class TestFCL(unittest.TestCase):
-    @unittest.skipUnless(_networkx, "no networks installed")
     def test_singletile(self):
         G = nx.Graph()
 
@@ -73,3 +156,19 @@ class TestFCL(unittest.TestCase):
             for j in range(4, 8):
                 self.assertIn(i, bqm.adj)
                 self.assertIn(j, bqm.adj[i])
+
+    def test_seed(self):
+        G = nx.Graph()
+
+        for u in range(4):
+            for v in range(4, 8):
+                G.add_edge(u, v)
+
+        bqm0 = dimod.generators.frustrated_loop(G, 10, seed=506)
+        bqm1 = dimod.generators.frustrated_loop(G, 10, seed=506)
+
+        self.assertEqual(bqm0, bqm1)
+
+        bqm2 = dimod.generators.frustrated_loop(G, 10, seed=123)
+
+        self.assertNotEqual(bqm2, bqm1)