Repository: scipy/scipy

Issue:
BUG: `Halton(seed=rng)` does not consume `Generator` PRNG state
### Describe your issue.

Ported from [another thread](https://github.com/scipy/scipy/pull/16923#issuecomment-1234551154) on request.

The `Halton` `QMCEngine` implements scrambling in a way that does not advance the PRNG state of a `Generator` instance that is passed in. This has consequences for its ability to use it like other `QMCEngine`s in a randomized QMC integration like `qmc_quad()`, as shown in the reference PR.

```python

>>> rng = np.random.default_rng(0xa29cabb11cfdf44ff6cac8bec254c2a0)

>>> for i in range(3):
...     q = qmc.Halton(d=2, seed=rng)
...     print(q.random(4))
... 
[[0.92701897 0.40341341]
 [0.42701897 0.07008008]
 [0.67701897 0.73674674]
 [0.17701897 0.51452452]]
[[0.92701897 0.40341341]
 [0.42701897 0.07008008]
 [0.67701897 0.73674674]
 [0.17701897 0.51452452]]
[[0.92701897 0.40341341]
 [0.42701897 0.07008008]
 [0.67701897 0.73674674]
 [0.17701897 0.51452452]]
```

Contrast with `Sobol` which does advance the PRNG state in the way one would expect and desire in an RQMC scenario:

```python
>>> rng = np.random.default_rng(0xa29cabb11cfdf44ff6cac8bec254c2a0)

>>> for i in range(3):
...     q = qmc.Sobol(d=2, seed=rng)
...     print(q.random(4))
... 
[[0.12786313 0.37406467]
 [0.60378522 0.97493285]
 [0.78469998 0.05257057]
 [0.4483653  0.66849917]]
[[0.62039108 0.1264537 ]
 [0.34204027 0.88977271]
 [0.20952058 0.47037593]
 [0.98823756 0.73434347]]
[[0.403715   0.34581335]
 [0.57170838 0.63953364]
 [0.96115589 0.00454448]
 [0.01191571 0.97690696]]
```

The reason `Halton` behaves this way is that it delegates its work to multiple calls of [`van_der_corput()`](https://github.com/scipy/scipy/blob/cfc8958d1933de9a848c78703cea814e6ff0ce90/scipy/stats/_qmc.py#L1012-L1016), once for each dimension. In so doing, it deepcopies whatever `seed=` was passed in to give a separate identical copy to each dimension's call. As far as I can tell, the deepcopy isn't actually important to spread the work to each dimension; in fact, I think it's incorrect that they get identical copies. However, it is true that in order for multiple sequential calls to `random()` to be coherently scrambled, _something_ needs to be done to coordinate between the separate `random()` calls; here, that's being accomplished as a bankshot from the multiple deepcopiies but could be accomplished in other ways.

My [first assertion has been disputed](https://github.com/scipy/scipy/pull/17955#discussion_r1122225994), so let's walk through it. `Halton` assigns a prime number to each dimension. This is the `base` integer that's passed into `van_der_corput()`.

```python
>>> q = qmc.Halton(d=5)

>>> q.base
[2, 3, 5, 7, 11]
```

In the [guts of `van_der_corput()`](https://github.com/scipy/scipy/blob/cfc8958d1933de9a848c78703cea814e6ff0ce90/scipy/stats/_qmc.py#L583-L592) for one dimension with a given `base`, the scrambling needs to generate an `(count, base)` array of integers where each of the `count` rows is an independent permutation of `np.arange(base)`. `count` is a small integer that is a function of `base` and not the number of requested samples; the precise details are otherwise unimportant here. So when `van_der_corput()` gets called with different `base` prime numbers, the PRNG that is used to shuffle them is not trying to repeat the same shuffling _across dimensions_ because the lengths of the arrays that are being shuffled are quite different. And while it's probably not especially harmful that the same parts of the PRNG cycle are being used for calls to `rng.shuffle()`, each permuted row for each dimension _is_ supposed to be independent according to my reading of [the paper referenced in the comments](https://artowen.su.domains/reports/rhalton.pdf). The R code in that paper even takes special care to ensure that each dimension does _not_ get the same PRNG state.

But it _is_ true that the _same_ permutations need to get used for multiple calls to `random()`. The deepcopying to send identical copies of the `Generator` to each dimension had the _side effect_ of ensuring that each call to `random()` started off with the same `Generator` state, too. But I think the answer here is to precompute the scrambling permutation arrays inside of `Halton.__init__()` much like `Sobol.__init__()` computes its scrambling data there. That will end up advancing the state of the passed-in `Generator` when constructing `Halton` and restoring the desirable semantics for RQMC.


### Reproducing Code Example

```python
As above.
```


### Error message

```shell
As above.
```


### SciPy/NumPy/Python version and system information

```shell
1.8.0 1.21.2 sys.version_info(major=3, minor=8, micro=10, releaselevel='final', serial=0)
```



Discussion:
I will double check that and remove the deep copy if we can.
Ok so the deepcopy is not needed for the algorithm itself (could not see a difference in terms of convergence on my test suite), but for being able to continue sampling. e.g. the following test would fail.

```python
    def test_continuing(self, scramble):
        engine = self.engine(d=2, scramble=scramble)
        ref_sample = engine.random(n=8)

        engine = self.engine(d=2, scramble=scramble)

        n_half = len(ref_sample) // 2

        _ = engine.random(n=n_half)
        sample = engine.random(n=n_half)
        assert_allclose(sample, ref_sample[n_half:], atol=1e-1)
```

The first half of the sample would be the same. But the second half would be different as we would not use the same permutations.

So yes, pre-computing the permutations outside of VDC might be required. Looking into it.

Resolving patch:
diff --git a/scipy/integrate/_quadrature.py b/scipy/integrate/_quadrature.py
--- a/scipy/integrate/_quadrature.py
+++ b/scipy/integrate/_quadrature.py
@@ -1304,7 +1304,7 @@ def qmc_quad(func, a, b, *, n_estimates=8, n_points=1024, qrng=None,
     >>> n_estimates = 8
     >>> res = qmc_quad(func, a, b, n_estimates=n_estimates, qrng=qrng)
     >>> res.integral, res.standard_error
-    (0.00018441088533413305, 1.1255608140911588e-07)
+    (0.00018443143881633162, 4.709434153066518e-08)
 
     A two-sided, 99% confidence interval for the integral may be estimated
     as:
@@ -1312,7 +1312,7 @@ def qmc_quad(func, a, b, *, n_estimates=8, n_points=1024, qrng=None,
     >>> t = stats.t(df=n_estimates-1, loc=res.integral,
     ...             scale=res.standard_error)
     >>> t.interval(0.99)
-    (0.00018401699720722663, 0.00018480477346103947)
+    (0.00018426663295474533, 0.0001845962446779179)
 
     Indeed, the value reported by `scipy.stats.multivariate_normal` is
     within this range.
diff --git a/scipy/stats/_qmc.py b/scipy/stats/_qmc.py
--- a/scipy/stats/_qmc.py
+++ b/scipy/stats/_qmc.py
@@ -528,12 +528,51 @@ def n_primes(n: IntNumber) -> list[int]:
     return primes
 
 
+def _van_der_corput_permutations(
+    base: IntNumber, *, random_state: SeedType = None
+) -> np.ndarray:
+    """Permutations for scrambling a Van der Corput sequence.
+
+    Parameters
+    ----------
+    base : int
+        Base of the sequence.
+    random_state : {None, int, `numpy.random.Generator`}, optional
+        If `seed` is an int or None, a new `numpy.random.Generator` is
+        created using ``np.random.default_rng(seed)``.
+        If `seed` is already a ``Generator`` instance, then the provided
+        instance is used.
+
+    Returns
+    -------
+    permutations : array_like
+        Permutation indices.
+
+    Notes
+    -----
+    In Algorithm 1 of Owen 2017, a permutation of `np.arange(base)` is
+    created for each positive integer `k` such that `1 - base**-k < 1`
+    using floating-point arithmetic. For double precision floats, the
+    condition `1 - base**-k < 1` can also be written as `base**-k >
+    2**-54`, which makes it more apparent how many permutations we need
+    to create.
+    """
+    rng = check_random_state(random_state)
+    count = math.ceil(54 / math.log2(base)) - 1
+    permutations = np.repeat(np.arange(base)[None], count, axis=0)
+    for perm in permutations:
+        rng.shuffle(perm)
+
+    return permutations
+
+
 def van_der_corput(
         n: IntNumber,
         base: IntNumber = 2,
         *,
         start_index: IntNumber = 0,
         scramble: bool = False,
+        permutations: npt.ArrayLike | None = None,
         seed: SeedType = None,
         workers: IntNumber = 1) -> np.ndarray:
     """Van der Corput sequence.
@@ -555,6 +594,8 @@ def van_der_corput(
     scramble : bool, optional
         If True, use Owen scrambling. Otherwise no scrambling is done.
         Default is True.
+    permutations : array_like, optional
+        Permutations used for scrambling.
     seed : {None, int, `numpy.random.Generator`}, optional
         If `seed` is an int or None, a new `numpy.random.Generator` is
         created using ``np.random.default_rng(seed)``.
@@ -579,17 +620,12 @@ def van_der_corput(
         raise ValueError("'base' must be at least 2")
 
     if scramble:
-        rng = check_random_state(seed)
-        # In Algorithm 1 of Owen 2017, a permutation of `np.arange(base)` is
-        # created for each positive integer `k` such that `1 - base**-k < 1`
-        # using floating-point arithmetic. For double precision floats, the
-        # condition `1 - base**-k < 1` can also be written as `base**-k >
-        # 2**-54`, which makes it more apparent how many permutations we need
-        # to create.
-        count = math.ceil(54 / math.log2(base)) - 1
-        permutations = np.repeat(np.arange(base)[None], count, axis=0)
-        for perm in permutations:
-            rng.shuffle(perm)
+        if permutations is None:
+            permutations = _van_der_corput_permutations(
+                base=base, random_state=seed
+            )
+        else:
+            permutations = np.asarray(permutations)
 
         return _cy_van_der_corput_scrambled(n, base, start_index,
                                             permutations, workers)
@@ -983,9 +1019,27 @@ def __init__(
                            'optimization': optimization}
         super().__init__(d=d, optimization=optimization, seed=seed)
         self.seed = seed
-        self.base = n_primes(d)
+
+        # important to have ``type(bdim) == int`` for performance reason
+        self.base = [int(bdim) for bdim in n_primes(d)]
         self.scramble = scramble
 
+        self._initialize_permutations()
+
+    def _initialize_permutations(self) -> None:
+        """Initialize permutations for all Van der Corput sequences.
+
+        Permutations are only needed for scrambling.
+        """
+        self._permutations: list = [None] * len(self.base)
+        if self.scramble:
+            for i, bdim in enumerate(self.base):
+                permutations = _van_der_corput_permutations(
+                    base=bdim, random_state=self.rng
+                )
+
+                self._permutations[i] = permutations
+
     def _random(
         self, n: IntNumber = 1, *, workers: IntNumber = 1
     ) -> np.ndarray:
@@ -1008,12 +1062,11 @@ def _random(
         """
         workers = _validate_workers(workers)
         # Generate a sample using a Van der Corput sequence per dimension.
-        # important to have ``type(bdim) == int`` for performance reason
-        sample = [van_der_corput(n, int(bdim), start_index=self.num_generated,
+        sample = [van_der_corput(n, bdim, start_index=self.num_generated,
                                  scramble=self.scramble,
-                                 seed=copy.deepcopy(self.seed),
+                                 permutations=self._permutations[i],
                                  workers=workers)
-                  for bdim in self.base]
+                  for i, bdim in enumerate(self.base)]
 
         return np.array(sample).T.reshape(n, self.d)
 


Test patch:
diff --git a/scipy/stats/tests/test_qmc.py b/scipy/stats/tests/test_qmc.py
--- a/scipy/stats/tests/test_qmc.py
+++ b/scipy/stats/tests/test_qmc.py
@@ -471,8 +471,9 @@ class QMCEngineTests:
     scramble = [True, False]
     ids = ["Scrambled", "Unscrambled"]
 
-    def engine(self, scramble: bool, **kwargs) -> QMCEngine:
-        seed = np.random.default_rng(170382760648021597650530316304495310428)
+    def engine(self, scramble: bool, seed=None, **kwargs) -> QMCEngine:
+        if seed is None:
+            seed = np.random.default_rng(170382760648021597650530316304495310428)
         if self.can_scramble:
             return self.qmce(scramble=scramble, seed=seed, **kwargs)
         else:
@@ -605,6 +606,18 @@ def test_optimizers(self, optimization, metric):
 
         assert metric_ < metric_ref
 
+    def test_consume_prng_state(self):
+        rng = np.random.default_rng(0xa29cabb11cfdf44ff6cac8bec254c2a0)
+        sample = []
+        for i in range(3):
+            engine = self.engine(d=2, scramble=True, seed=rng)
+            sample.append(engine.random(4))
+
+        with pytest.raises(AssertionError, match="Arrays are not equal"):
+            assert_equal(sample[0], sample[1])
+        with pytest.raises(AssertionError, match="Arrays are not equal"):
+            assert_equal(sample[0], sample[2])
+
 
 class TestHalton(QMCEngineTests):
     qmce = qmc.Halton
@@ -615,14 +628,14 @@ class TestHalton(QMCEngineTests):
                               [1 / 8, 4 / 9], [5 / 8, 7 / 9],
                               [3 / 8, 2 / 9], [7 / 8, 5 / 9]])
     # theoretical values unknown: convergence properties checked
-    scramble_nd = np.array([[0.50246036, 0.09937553],
-                            [0.00246036, 0.43270887],
-                            [0.75246036, 0.7660422],
-                            [0.25246036, 0.32159776],
-                            [0.62746036, 0.65493109],
-                            [0.12746036, 0.98826442],
-                            [0.87746036, 0.21048664],
-                            [0.37746036, 0.54381998]])
+    scramble_nd = np.array([[0.50246036, 0.93382481],
+                            [0.00246036, 0.26715815],
+                            [0.75246036, 0.60049148],
+                            [0.25246036, 0.8227137 ],
+                            [0.62746036, 0.15604704],
+                            [0.12746036, 0.48938037],
+                            [0.87746036, 0.71160259],
+                            [0.37746036, 0.04493592]])
 
     def test_workers(self):
         ref_sample = self.reference(scramble=True)
@@ -641,7 +654,7 @@ def test_workers(self):
 
 class TestLHS(QMCEngineTests):
     qmce = qmc.LatinHypercube
-    can_scramble = False
+    can_scramble = True
 
     def test_continuing(self, *args):
         pytest.skip("Not applicable: not a sequence.")