Repository: scipy/scipy

Issue:
allow seeding of init methods in vq.kmeans2 
The `vq.kmeans2` function has initialization methods (that can be selected by the `minit` keyword) that do random number generation without allowing for seeding. See e.g. https://github.com/scipy/scipy/pull/9037#pullrequestreview-137675449

It should be possible for the user to pass in a `np.random.RandomState` instance or set a seed, rather than having to rely on setting the global state. Can be done in a similar way as the `random_state` keyword in the stats distributions.


Discussion:

Resolving patch:
diff --git a/scipy/cluster/vq.py b/scipy/cluster/vq.py
--- a/scipy/cluster/vq.py
+++ b/scipy/cluster/vq.py
@@ -67,7 +67,8 @@
 import warnings
 import numpy as np
 from collections import deque
-from scipy._lib._util import _asarray_validated
+from scipy._lib._util import _asarray_validated, check_random_state,\
+    rng_integers
 from scipy.spatial.distance import cdist
 
 from . import _vq
@@ -314,7 +315,8 @@ def _kmeans(obs, guess, thresh=1e-5):
     return code_book, prev_avg_dists[1]
 
 
-def kmeans(obs, k_or_guess, iter=20, thresh=1e-5, check_finite=True):
+def kmeans(obs, k_or_guess, iter=20, thresh=1e-5, check_finite=True,
+           *, seed=None):
     """
     Performs k-means on a set of observation vectors forming k clusters.
 
@@ -361,6 +363,18 @@ def kmeans(obs, k_or_guess, iter=20, thresh=1e-5, check_finite=True):
         (crashes, non-termination) if the inputs do contain infinities or NaNs.
         Default: True
 
+    seed : {None, int, `numpy.random.Generator`,
+            `numpy.random.RandomState`}, optional
+
+        Seed for initializing the pseudo-random number generator.
+        If `seed` is None (or `numpy.random`), the `numpy.random.RandomState`
+        singleton is used.
+        If `seed` is an int, a new ``RandomState`` instance is used,
+        seeded with `seed`.
+        If `seed` is already a ``Generator`` or ``RandomState`` instance then
+        that instance is used.
+        The default is None.
+
     Returns
     -------
     codebook : ndarray
@@ -457,11 +471,13 @@ def kmeans(obs, k_or_guess, iter=20, thresh=1e-5, check_finite=True):
     if k < 1:
         raise ValueError("Asked for %d clusters." % k)
 
+    rng = check_random_state(seed)
+
     # initialize best distance value to a large value
     best_dist = np.inf
     for i in range(iter):
         # the initial code book is randomly selected from observations
-        guess = _kpoints(obs, k)
+        guess = _kpoints(obs, k, rng)
         book, dist = _kmeans(obs, guess, thresh=thresh)
         if dist < best_dist:
             best_book = book
@@ -469,7 +485,7 @@ def kmeans(obs, k_or_guess, iter=20, thresh=1e-5, check_finite=True):
     return best_book, best_dist
 
 
-def _kpoints(data, k):
+def _kpoints(data, k, rng):
     """Pick k points at random in data (one row = one observation).
 
     Parameters
@@ -480,6 +496,8 @@ def _kpoints(data, k):
         row is one observation.
     k : int
         Number of samples to generate.
+    rng : `numpy.random.Generator` or `numpy.random.RandomState`
+        Random number generator.
 
     Returns
     -------
@@ -487,11 +505,11 @@ def _kpoints(data, k):
         A 'k' by 'N' containing the initial centroids
 
     """
-    idx = np.random.choice(data.shape[0], size=k, replace=False)
+    idx = rng.choice(data.shape[0], size=k, replace=False)
     return data[idx]
 
 
-def _krandinit(data, k):
+def _krandinit(data, k, rng):
     """Returns k samples of a random variable whose parameters depend on data.
 
     More precisely, it returns k observations sampled from a Gaussian random
@@ -505,6 +523,8 @@ def _krandinit(data, k):
         row is one observation.
     k : int
         Number of samples to generate.
+    rng : `numpy.random.Generator` or `numpy.random.RandomState`
+        Random number generator.
 
     Returns
     -------
@@ -516,12 +536,12 @@ def _krandinit(data, k):
 
     if data.ndim == 1:
         cov = np.cov(data)
-        x = np.random.randn(k)
+        x = rng.standard_normal(size=k)
         x *= np.sqrt(cov)
     elif data.shape[1] > data.shape[0]:
         # initialize when the covariance matrix is rank deficient
         _, s, vh = np.linalg.svd(data - mu, full_matrices=False)
-        x = np.random.randn(k, s.size)
+        x = rng.standard_normal(size=(k, s.size))
         sVh = s[:, None] * vh / np.sqrt(data.shape[0] - 1)
         x = x.dot(sVh)
     else:
@@ -529,14 +549,14 @@ def _krandinit(data, k):
 
         # k rows, d cols (one row = one obs)
         # Generate k sample of a random variable ~ Gaussian(mu, cov)
-        x = np.random.randn(k, mu.size)
+        x = rng.standard_normal(size=(k, mu.size))
         x = x.dot(np.linalg.cholesky(cov).T)
 
     x += mu
     return x
 
 
-def _kpp(data, k):
+def _kpp(data, k, rng):
     """ Picks k points in the data based on the kmeans++ method.
 
     Parameters
@@ -547,6 +567,8 @@ def _kpp(data, k):
         row is one observation.
     k : int
         Number of samples to generate.
+    rng : `numpy.random.Generator` or `numpy.random.RandomState`
+        Random number generator.
 
     Returns
     -------
@@ -565,13 +587,13 @@ def _kpp(data, k):
 
     for i in range(k):
         if i == 0:
-            init[i, :] = data[np.random.randint(data.shape[0])]
+            init[i, :] = data[rng_integers(rng, data.shape[0])]
 
         else:
             D2 = cdist(init[:i,:], data, metric='sqeuclidean').min(axis=0)
             probs = D2/D2.sum()
             cumprobs = probs.cumsum()
-            r = np.random.rand()
+            r = rng.uniform()
             init[i, :] = data[np.searchsorted(cumprobs, r)]
 
     return init
@@ -596,7 +618,7 @@ def _missing_raise():
 
 
 def kmeans2(data, k, iter=10, thresh=1e-5, minit='random',
-            missing='warn', check_finite=True):
+            missing='warn', check_finite=True, *, seed=None):
     """
     Classify a set of observations into k clusters using the k-means algorithm.
 
@@ -647,6 +669,17 @@ def kmeans2(data, k, iter=10, thresh=1e-5, minit='random',
         Disabling may give a performance gain, but may result in problems
         (crashes, non-termination) if the inputs do contain infinities or NaNs.
         Default: True
+    seed : {None, int, `numpy.random.Generator`,
+            `numpy.random.RandomState`}, optional
+
+        Seed for initializing the pseudo-random number generator.
+        If `seed` is None (or `numpy.random`), the `numpy.random.RandomState`
+        singleton is used.
+        If `seed` is an int, a new ``RandomState`` instance is used,
+        seeded with `seed`.
+        If `seed` is already a ``Generator`` or ``RandomState`` instance then
+        that instance is used.
+        The default is None.
 
     Returns
     -------
@@ -751,7 +784,8 @@ def kmeans2(data, k, iter=10, thresh=1e-5, minit='random',
         except KeyError as e:
             raise ValueError("Unknown init method %r" % (minit,)) from e
         else:
-            code_book = init_meth(data, k)
+            rng = check_random_state(seed)
+            code_book = init_meth(data, k, rng)
 
     for i in range(iter):
         # Compute the nearest neighbor for each obs using the current code book


Test patch:
diff --git a/scipy/cluster/tests/test_vq.py b/scipy/cluster/tests/test_vq.py
--- a/scipy/cluster/tests/test_vq.py
+++ b/scipy/cluster/tests/test_vq.py
@@ -265,8 +265,13 @@ def test_krandinit(self):
         datas = [data.reshape((200, 2)), data.reshape((20, 20))[:10]]
         k = int(1e6)
         for data in datas:
-            np.random.seed(1234)
-            init = _krandinit(data, k)
+            # check that np.random.Generator can be used (numpy >= 1.17)
+            if hasattr(np.random, 'default_rng'):
+                rng = np.random.default_rng(1234)
+            else:
+                rng = np.random.RandomState(1234)
+
+            init = _krandinit(data, k, rng)
             orig_cov = np.cov(data, rowvar=0)
             init_cov = np.cov(init, rowvar=0)
             assert_allclose(orig_cov, init_cov, atol=1e-2)
@@ -309,3 +314,23 @@ def test_kmeans2_kpp_high_dim(self):
         ])
         res, _ = kmeans2(data, 2, minit='++')
         assert_array_almost_equal(res, centers, decimal=0)
+
+    def test_kmeans_and_kmeans2_random_seed(self):
+
+        seed_list = [1234, np.random.RandomState(1234)]
+
+        # check that np.random.Generator can be used (numpy >= 1.17)
+        if hasattr(np.random, 'default_rng'):
+            seed_list.append(np.random.default_rng(1234))
+
+        for seed in seed_list:
+            # test for kmeans
+            res1, _ = kmeans(TESTDATA_2D, 2, seed=seed)
+            res2, _ = kmeans(TESTDATA_2D, 2, seed=seed)
+            assert_allclose(res1, res1)  # should be same results
+
+            # test for kmeans2
+            for minit in ["random", "points", "++"]:
+                res1, _ = kmeans2(TESTDATA_2D, 2, minit=minit, seed=seed)
+                res2, _ = kmeans2(TESTDATA_2D, 2, minit=minit, seed=seed)
+                assert_allclose(res1, res1)  # should be same results