Coverage for /usr/lib/python3/dist-packages/scipy/stats/_qmc.py: 14%

562 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-14 15:55 +0200

1"""Quasi-Monte Carlo engines and helpers.""" 

2from __future__ import annotations 

3 

4import copy 

5import math 

6import numbers 

7import os 

8import warnings 

9from abc import ABC, abstractmethod 

10from functools import partial 

11from typing import ( 

12 Callable, 

13 ClassVar, 

14 Literal, 

15 overload, 

16 TYPE_CHECKING, 

17) 

18 

19import numpy as np 

20 

21if TYPE_CHECKING: 

22 import numpy.typing as npt 

23 from scipy._lib._util import ( 

24 DecimalNumber, GeneratorType, IntNumber, SeedType 

25 ) 

26 

27import scipy.stats as stats 

28from scipy._lib._util import rng_integers, _rng_spawn 

29from scipy.spatial import distance, Voronoi 

30from scipy.special import gammainc 

31from ._sobol import ( 

32 _initialize_v, _cscramble, _fill_p_cumulative, _draw, _fast_forward, 

33 _categorize, _MAXDIM 

34) 

35from ._qmc_cy import ( 

36 _cy_wrapper_centered_discrepancy, 

37 _cy_wrapper_wrap_around_discrepancy, 

38 _cy_wrapper_mixture_discrepancy, 

39 _cy_wrapper_l2_star_discrepancy, 

40 _cy_wrapper_update_discrepancy, 

41 _cy_van_der_corput_scrambled, 

42 _cy_van_der_corput, 

43) 

44 

45 

46__all__ = ['scale', 'discrepancy', 'update_discrepancy', 

47 'QMCEngine', 'Sobol', 'Halton', 'LatinHypercube', 'PoissonDisk', 

48 'MultinomialQMC', 'MultivariateNormalQMC'] 

49 

50 

51@overload 

52def check_random_state(seed: IntNumber | None = ...) -> np.random.Generator: 

53 ... 

54 

55 

56@overload 

57def check_random_state(seed: GeneratorType) -> GeneratorType: 

58 ... 

59 

60 

61# Based on scipy._lib._util.check_random_state 

62def check_random_state(seed=None): 

63 """Turn `seed` into a `numpy.random.Generator` instance. 

64 

65 Parameters 

66 ---------- 

67 seed : {None, int, `numpy.random.Generator`, `numpy.random.RandomState`}, optional # noqa 

68 If `seed` is an int or None, a new `numpy.random.Generator` is 

69 created using ``np.random.default_rng(seed)``. 

70 If `seed` is already a ``Generator`` or ``RandomState`` instance, then 

71 the provided instance is used. 

72 

73 Returns 

74 ------- 

75 seed : {`numpy.random.Generator`, `numpy.random.RandomState`} 

76 Random number generator. 

77 

78 """ 

79 if seed is None or isinstance(seed, (numbers.Integral, np.integer)): 

80 return np.random.default_rng(seed) 

81 elif isinstance(seed, (np.random.RandomState, np.random.Generator)): 

82 return seed 

83 else: 

84 raise ValueError(f'{seed!r} cannot be used to seed a' 

85 ' numpy.random.Generator instance') 

86 

87 

88def scale( 

89 sample: npt.ArrayLike, 

90 l_bounds: npt.ArrayLike, 

91 u_bounds: npt.ArrayLike, 

92 *, 

93 reverse: bool = False 

94) -> np.ndarray: 

95 r"""Sample scaling from unit hypercube to different bounds. 

96 

97 To convert a sample from :math:`[0, 1)` to :math:`[a, b), b>a`, 

98 with :math:`a` the lower bounds and :math:`b` the upper bounds. 

99 The following transformation is used: 

100 

101 .. math:: 

102 

103 (b - a) \cdot \text{sample} + a 

104 

105 Parameters 

106 ---------- 

107 sample : array_like (n, d) 

108 Sample to scale. 

109 l_bounds, u_bounds : array_like (d,) 

110 Lower and upper bounds (resp. :math:`a`, :math:`b`) of transformed 

111 data. If `reverse` is True, range of the original data to transform 

112 to the unit hypercube. 

113 reverse : bool, optional 

114 Reverse the transformation from different bounds to the unit hypercube. 

115 Default is False. 

116 

117 Returns 

118 ------- 

119 sample : array_like (n, d) 

120 Scaled sample. 

121 

122 Examples 

123 -------- 

124 Transform 3 samples in the unit hypercube to bounds: 

125 

126 >>> from scipy.stats import qmc 

127 >>> l_bounds = [-2, 0] 

128 >>> u_bounds = [6, 5] 

129 >>> sample = [[0.5 , 0.75], 

130 ... [0.5 , 0.5], 

131 ... [0.75, 0.25]] 

132 >>> sample_scaled = qmc.scale(sample, l_bounds, u_bounds) 

133 >>> sample_scaled 

134 array([[2. , 3.75], 

135 [2. , 2.5 ], 

136 [4. , 1.25]]) 

137 

138 And convert back to the unit hypercube: 

139 

140 >>> sample_ = qmc.scale(sample_scaled, l_bounds, u_bounds, reverse=True) 

141 >>> sample_ 

142 array([[0.5 , 0.75], 

143 [0.5 , 0.5 ], 

144 [0.75, 0.25]]) 

145 

146 """ 

147 sample = np.asarray(sample) 

148 

149 # Checking bounds and sample 

150 if not sample.ndim == 2: 

151 raise ValueError('Sample is not a 2D array') 

152 

153 lower, upper = _validate_bounds( 

154 l_bounds=l_bounds, u_bounds=u_bounds, d=sample.shape[1] 

155 ) 

156 

157 if not reverse: 

158 # Checking that sample is within the hypercube 

159 if (sample.max() > 1.) or (sample.min() < 0.): 

160 raise ValueError('Sample is not in unit hypercube') 

161 

162 return sample * (upper - lower) + lower 

163 else: 

164 # Checking that sample is within the bounds 

165 if not (np.all(sample >= lower) and np.all(sample <= upper)): 

166 raise ValueError('Sample is out of bounds') 

167 

168 return (sample - lower) / (upper - lower) 

169 

170 

171def discrepancy( 

172 sample: npt.ArrayLike, 

173 *, 

174 iterative: bool = False, 

175 method: Literal["CD", "WD", "MD", "L2-star"] = "CD", 

176 workers: IntNumber = 1) -> float: 

177 """Discrepancy of a given sample. 

178 

179 Parameters 

180 ---------- 

181 sample : array_like (n, d) 

182 The sample to compute the discrepancy from. 

183 iterative : bool, optional 

184 Must be False if not using it for updating the discrepancy. 

185 Default is False. Refer to the notes for more details. 

186 method : str, optional 

187 Type of discrepancy, can be ``CD``, ``WD``, ``MD`` or ``L2-star``. 

188 Refer to the notes for more details. Default is ``CD``. 

189 workers : int, optional 

190 Number of workers to use for parallel processing. If -1 is given all 

191 CPU threads are used. Default is 1. 

192 

193 Returns 

194 ------- 

195 discrepancy : float 

196 Discrepancy. 

197 

198 Notes 

199 ----- 

200 The discrepancy is a uniformity criterion used to assess the space filling 

201 of a number of samples in a hypercube. A discrepancy quantifies the 

202 distance between the continuous uniform distribution on a hypercube and the 

203 discrete uniform distribution on :math:`n` distinct sample points. 

204 

205 The lower the value is, the better the coverage of the parameter space is. 

206 

207 For a collection of subsets of the hypercube, the discrepancy is the 

208 difference between the fraction of sample points in one of those 

209 subsets and the volume of that subset. There are different definitions of 

210 discrepancy corresponding to different collections of subsets. Some 

211 versions take a root mean square difference over subsets instead of 

212 a maximum. 

213 

214 A measure of uniformity is reasonable if it satisfies the following 

215 criteria [1]_: 

216 

217 1. It is invariant under permuting factors and/or runs. 

218 2. It is invariant under rotation of the coordinates. 

219 3. It can measure not only uniformity of the sample over the hypercube, 

220 but also the projection uniformity of the sample over non-empty 

221 subset of lower dimension hypercubes. 

222 4. There is some reasonable geometric meaning. 

223 5. It is easy to compute. 

224 6. It satisfies the Koksma-Hlawka-like inequality. 

225 7. It is consistent with other criteria in experimental design. 

226 

227 Four methods are available: 

228 

229 * ``CD``: Centered Discrepancy - subspace involves a corner of the 

230 hypercube 

231 * ``WD``: Wrap-around Discrepancy - subspace can wrap around bounds 

232 * ``MD``: Mixture Discrepancy - mix between CD/WD covering more criteria 

233 * ``L2-star``: L2-star discrepancy - like CD BUT variant to rotation 

234 

235 See [2]_ for precise definitions of each method. 

236 

237 Lastly, using ``iterative=True``, it is possible to compute the 

238 discrepancy as if we had :math:`n+1` samples. This is useful if we want 

239 to add a point to a sampling and check the candidate which would give the 

240 lowest discrepancy. Then you could just update the discrepancy with 

241 each candidate using `update_discrepancy`. This method is faster than 

242 computing the discrepancy for a large number of candidates. 

243 

244 References 

245 ---------- 

246 .. [1] Fang et al. "Design and modeling for computer experiments". 

247 Computer Science and Data Analysis Series, 2006. 

248 .. [2] Zhou Y.-D. et al. "Mixture discrepancy for quasi-random point sets." 

249 Journal of Complexity, 29 (3-4) , pp. 283-301, 2013. 

250 .. [3] T. T. Warnock. "Computational investigations of low discrepancy 

251 point sets." Applications of Number Theory to Numerical 

252 Analysis, Academic Press, pp. 319-343, 1972. 

253 

254 Examples 

255 -------- 

256 Calculate the quality of the sample using the discrepancy: 

257 

258 >>> import numpy as np 

259 >>> from scipy.stats import qmc 

260 >>> space = np.array([[1, 3], [2, 6], [3, 2], [4, 5], [5, 1], [6, 4]]) 

261 >>> l_bounds = [0.5, 0.5] 

262 >>> u_bounds = [6.5, 6.5] 

263 >>> space = qmc.scale(space, l_bounds, u_bounds, reverse=True) 

264 >>> space 

265 array([[0.08333333, 0.41666667], 

266 [0.25 , 0.91666667], 

267 [0.41666667, 0.25 ], 

268 [0.58333333, 0.75 ], 

269 [0.75 , 0.08333333], 

270 [0.91666667, 0.58333333]]) 

271 >>> qmc.discrepancy(space) 

272 0.008142039609053464 

273 

274 We can also compute iteratively the ``CD`` discrepancy by using 

275 ``iterative=True``. 

276 

277 >>> disc_init = qmc.discrepancy(space[:-1], iterative=True) 

278 >>> disc_init 

279 0.04769081147119336 

280 >>> qmc.update_discrepancy(space[-1], space[:-1], disc_init) 

281 0.008142039609053513 

282 

283 """ 

284 sample = np.asarray(sample, dtype=np.float64, order="C") 

285 

286 # Checking that sample is within the hypercube and 2D 

287 if not sample.ndim == 2: 

288 raise ValueError("Sample is not a 2D array") 

289 

290 if (sample.max() > 1.) or (sample.min() < 0.): 

291 raise ValueError("Sample is not in unit hypercube") 

292 

293 workers = _validate_workers(workers) 

294 

295 methods = { 

296 "CD": _cy_wrapper_centered_discrepancy, 

297 "WD": _cy_wrapper_wrap_around_discrepancy, 

298 "MD": _cy_wrapper_mixture_discrepancy, 

299 "L2-star": _cy_wrapper_l2_star_discrepancy, 

300 } 

301 

302 if method in methods: 

303 return methods[method](sample, iterative, workers=workers) 

304 else: 

305 raise ValueError(f"{method!r} is not a valid method. It must be one of" 

306 f" {set(methods)!r}") 

307 

308 

309def update_discrepancy( 

310 x_new: npt.ArrayLike, 

311 sample: npt.ArrayLike, 

312 initial_disc: DecimalNumber) -> float: 

313 """Update the centered discrepancy with a new sample. 

314 

315 Parameters 

316 ---------- 

317 x_new : array_like (1, d) 

318 The new sample to add in `sample`. 

319 sample : array_like (n, d) 

320 The initial sample. 

321 initial_disc : float 

322 Centered discrepancy of the `sample`. 

323 

324 Returns 

325 ------- 

326 discrepancy : float 

327 Centered discrepancy of the sample composed of `x_new` and `sample`. 

328 

329 Examples 

330 -------- 

331 We can also compute iteratively the discrepancy by using 

332 ``iterative=True``. 

333 

334 >>> import numpy as np 

335 >>> from scipy.stats import qmc 

336 >>> space = np.array([[1, 3], [2, 6], [3, 2], [4, 5], [5, 1], [6, 4]]) 

337 >>> l_bounds = [0.5, 0.5] 

338 >>> u_bounds = [6.5, 6.5] 

339 >>> space = qmc.scale(space, l_bounds, u_bounds, reverse=True) 

340 >>> disc_init = qmc.discrepancy(space[:-1], iterative=True) 

341 >>> disc_init 

342 0.04769081147119336 

343 >>> qmc.update_discrepancy(space[-1], space[:-1], disc_init) 

344 0.008142039609053513 

345 

346 """ 

347 sample = np.asarray(sample, dtype=np.float64, order="C") 

348 x_new = np.asarray(x_new, dtype=np.float64, order="C") 

349 

350 # Checking that sample is within the hypercube and 2D 

351 if not sample.ndim == 2: 

352 raise ValueError('Sample is not a 2D array') 

353 

354 if (sample.max() > 1.) or (sample.min() < 0.): 

355 raise ValueError('Sample is not in unit hypercube') 

356 

357 # Checking that x_new is within the hypercube and 1D 

358 if not x_new.ndim == 1: 

359 raise ValueError('x_new is not a 1D array') 

360 

361 if not (np.all(x_new >= 0) and np.all(x_new <= 1)): 

362 raise ValueError('x_new is not in unit hypercube') 

363 

364 if x_new.shape[0] != sample.shape[1]: 

365 raise ValueError("x_new and sample must be broadcastable") 

366 

367 return _cy_wrapper_update_discrepancy(x_new, sample, initial_disc) 

368 

369 

370def _perturb_discrepancy(sample: np.ndarray, i1: int, i2: int, k: int, 

371 disc: float): 

372 """Centered discrepancy after an elementary perturbation of a LHS. 

373 

374 An elementary perturbation consists of an exchange of coordinates between 

375 two points: ``sample[i1, k] <-> sample[i2, k]``. By construction, 

376 this operation conserves the LHS properties. 

377 

378 Parameters 

379 ---------- 

380 sample : array_like (n, d) 

381 The sample (before permutation) to compute the discrepancy from. 

382 i1 : int 

383 The first line of the elementary permutation. 

384 i2 : int 

385 The second line of the elementary permutation. 

386 k : int 

387 The column of the elementary permutation. 

388 disc : float 

389 Centered discrepancy of the design before permutation. 

390 

391 Returns 

392 ------- 

393 discrepancy : float 

394 Centered discrepancy of the design after permutation. 

395 

396 References 

397 ---------- 

398 .. [1] Jin et al. "An efficient algorithm for constructing optimal design 

399 of computer experiments", Journal of Statistical Planning and 

400 Inference, 2005. 

401 

402 """ 

403 n = sample.shape[0] 

404 

405 z_ij = sample - 0.5 

406 

407 # Eq (19) 

408 c_i1j = (1. / n ** 2. 

409 * np.prod(0.5 * (2. + abs(z_ij[i1, :]) 

410 + abs(z_ij) - abs(z_ij[i1, :] - z_ij)), axis=1)) 

411 c_i2j = (1. / n ** 2. 

412 * np.prod(0.5 * (2. + abs(z_ij[i2, :]) 

413 + abs(z_ij) - abs(z_ij[i2, :] - z_ij)), axis=1)) 

414 

415 # Eq (20) 

416 c_i1i1 = (1. / n ** 2 * np.prod(1 + abs(z_ij[i1, :])) 

417 - 2. / n * np.prod(1. + 0.5 * abs(z_ij[i1, :]) 

418 - 0.5 * z_ij[i1, :] ** 2)) 

419 c_i2i2 = (1. / n ** 2 * np.prod(1 + abs(z_ij[i2, :])) 

420 - 2. / n * np.prod(1. + 0.5 * abs(z_ij[i2, :]) 

421 - 0.5 * z_ij[i2, :] ** 2)) 

422 

423 # Eq (22), typo in the article in the denominator i2 -> i1 

424 num = (2 + abs(z_ij[i2, k]) + abs(z_ij[:, k]) 

425 - abs(z_ij[i2, k] - z_ij[:, k])) 

426 denum = (2 + abs(z_ij[i1, k]) + abs(z_ij[:, k]) 

427 - abs(z_ij[i1, k] - z_ij[:, k])) 

428 gamma = num / denum 

429 

430 # Eq (23) 

431 c_p_i1j = gamma * c_i1j 

432 # Eq (24) 

433 c_p_i2j = c_i2j / gamma 

434 

435 alpha = (1 + abs(z_ij[i2, k])) / (1 + abs(z_ij[i1, k])) 

436 beta = (2 - abs(z_ij[i2, k])) / (2 - abs(z_ij[i1, k])) 

437 

438 g_i1 = np.prod(1. + abs(z_ij[i1, :])) 

439 g_i2 = np.prod(1. + abs(z_ij[i2, :])) 

440 h_i1 = np.prod(1. + 0.5 * abs(z_ij[i1, :]) - 0.5 * (z_ij[i1, :] ** 2)) 

441 h_i2 = np.prod(1. + 0.5 * abs(z_ij[i2, :]) - 0.5 * (z_ij[i2, :] ** 2)) 

442 

443 # Eq (25), typo in the article g is missing 

444 c_p_i1i1 = ((g_i1 * alpha) / (n ** 2) - 2. * alpha * beta * h_i1 / n) 

445 # Eq (26), typo in the article n ** 2 

446 c_p_i2i2 = ((g_i2 / ((n ** 2) * alpha)) - (2. * h_i2 / (n * alpha * beta))) 

447 

448 # Eq (26) 

449 sum_ = c_p_i1j - c_i1j + c_p_i2j - c_i2j 

450 

451 mask = np.ones(n, dtype=bool) 

452 mask[[i1, i2]] = False 

453 sum_ = sum(sum_[mask]) 

454 

455 disc_ep = (disc + c_p_i1i1 - c_i1i1 + c_p_i2i2 - c_i2i2 + 2 * sum_) 

456 

457 return disc_ep 

458 

459 

460def primes_from_2_to(n: int) -> np.ndarray: 

461 """Prime numbers from 2 to *n*. 

462 

463 Parameters 

464 ---------- 

465 n : int 

466 Sup bound with ``n >= 6``. 

467 

468 Returns 

469 ------- 

470 primes : list(int) 

471 Primes in ``2 <= p < n``. 

472 

473 Notes 

474 ----- 

475 Taken from [1]_ by P.T. Roy, written consent given on 23.04.2021 

476 by the original author, Bruno Astrolino, for free use in SciPy under 

477 the 3-clause BSD. 

478 

479 References 

480 ---------- 

481 .. [1] `StackOverflow <https://stackoverflow.com/questions/2068372>`_. 

482 

483 """ 

484 sieve = np.ones(n // 3 + (n % 6 == 2), dtype=bool) 

485 for i in range(1, int(n ** 0.5) // 3 + 1): 

486 k = 3 * i + 1 | 1 

487 sieve[k * k // 3::2 * k] = False 

488 sieve[k * (k - 2 * (i & 1) + 4) // 3::2 * k] = False 

489 return np.r_[2, 3, ((3 * np.nonzero(sieve)[0][1:] + 1) | 1)] 

490 

491 

492def n_primes(n: IntNumber) -> list[int]: 

493 """List of the n-first prime numbers. 

494 

495 Parameters 

496 ---------- 

497 n : int 

498 Number of prime numbers wanted. 

499 

500 Returns 

501 ------- 

502 primes : list(int) 

503 List of primes. 

504 

505 """ 

506 primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 

507 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 

508 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 

509 197, 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 

510 271, 277, 281, 283, 293, 307, 311, 313, 317, 331, 337, 347, 349, 

511 353, 359, 367, 373, 379, 383, 389, 397, 401, 409, 419, 421, 431, 

512 433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491, 499, 503, 

513 509, 521, 523, 541, 547, 557, 563, 569, 571, 577, 587, 593, 599, 

514 601, 607, 613, 617, 619, 631, 641, 643, 647, 653, 659, 661, 673, 

515 677, 683, 691, 701, 709, 719, 727, 733, 739, 743, 751, 757, 761, 

516 769, 773, 787, 797, 809, 811, 821, 823, 827, 829, 839, 853, 857, 

517 859, 863, 877, 881, 883, 887, 907, 911, 919, 929, 937, 941, 947, 

518 953, 967, 971, 977, 983, 991, 997][:n] # type: ignore[misc] 

519 

520 if len(primes) < n: 

521 big_number = 2000 

522 while 'Not enough primes': 

523 primes = primes_from_2_to(big_number)[:n] # type: ignore 

524 if len(primes) == n: 

525 break 

526 big_number += 1000 

527 

528 return primes 

529 

530 

531def _van_der_corput_permutations( 

532 base: IntNumber, *, random_state: SeedType = None 

533) -> np.ndarray: 

534 """Permutations for scrambling a Van der Corput sequence. 

535 

536 Parameters 

537 ---------- 

538 base : int 

539 Base of the sequence. 

540 random_state : {None, int, `numpy.random.Generator`}, optional 

541 If `seed` is an int or None, a new `numpy.random.Generator` is 

542 created using ``np.random.default_rng(seed)``. 

543 If `seed` is already a ``Generator`` instance, then the provided 

544 instance is used. 

545 

546 Returns 

547 ------- 

548 permutations : array_like 

549 Permutation indices. 

550 

551 Notes 

552 ----- 

553 In Algorithm 1 of Owen 2017, a permutation of `np.arange(base)` is 

554 created for each positive integer `k` such that `1 - base**-k < 1` 

555 using floating-point arithmetic. For double precision floats, the 

556 condition `1 - base**-k < 1` can also be written as `base**-k > 

557 2**-54`, which makes it more apparent how many permutations we need 

558 to create. 

559 """ 

560 rng = check_random_state(random_state) 

561 count = math.ceil(54 / math.log2(base)) - 1 

562 permutations = np.repeat(np.arange(base)[None], count, axis=0) 

563 for perm in permutations: 

564 rng.shuffle(perm) 

565 

566 return permutations 

567 

568 

569def van_der_corput( 

570 n: IntNumber, 

571 base: IntNumber = 2, 

572 *, 

573 start_index: IntNumber = 0, 

574 scramble: bool = False, 

575 permutations: npt.ArrayLike | None = None, 

576 seed: SeedType = None, 

577 workers: IntNumber = 1) -> np.ndarray: 

578 """Van der Corput sequence. 

579 

580 Pseudo-random number generator based on a b-adic expansion. 

581 

582 Scrambling uses permutations of the remainders (see [1]_). Multiple 

583 permutations are applied to construct a point. The sequence of 

584 permutations has to be the same for all points of the sequence. 

585 

586 Parameters 

587 ---------- 

588 n : int 

589 Number of element of the sequence. 

590 base : int, optional 

591 Base of the sequence. Default is 2. 

592 start_index : int, optional 

593 Index to start the sequence from. Default is 0. 

594 scramble : bool, optional 

595 If True, use Owen scrambling. Otherwise no scrambling is done. 

596 Default is True. 

597 permutations : array_like, optional 

598 Permutations used for scrambling. 

599 seed : {None, int, `numpy.random.Generator`}, optional 

600 If `seed` is an int or None, a new `numpy.random.Generator` is 

601 created using ``np.random.default_rng(seed)``. 

602 If `seed` is already a ``Generator`` instance, then the provided 

603 instance is used. 

604 workers : int, optional 

605 Number of workers to use for parallel processing. If -1 is 

606 given all CPU threads are used. Default is 1. 

607 

608 Returns 

609 ------- 

610 sequence : list (n,) 

611 Sequence of Van der Corput. 

612 

613 References 

614 ---------- 

615 .. [1] A. B. Owen. "A randomized Halton algorithm in R", 

616 :arxiv:`1706.02808`, 2017. 

617 

618 """ 

619 if base < 2: 

620 raise ValueError("'base' must be at least 2") 

621 

622 if scramble: 

623 if permutations is None: 

624 permutations = _van_der_corput_permutations( 

625 base=base, random_state=seed 

626 ) 

627 else: 

628 permutations = np.asarray(permutations) 

629 

630 return _cy_van_der_corput_scrambled(n, base, start_index, 

631 permutations, workers) 

632 

633 else: 

634 return _cy_van_der_corput(n, base, start_index, workers) 

635 

636 

637class QMCEngine(ABC): 

638 """A generic Quasi-Monte Carlo sampler class meant for subclassing. 

639 

640 QMCEngine is a base class to construct a specific Quasi-Monte Carlo 

641 sampler. It cannot be used directly as a sampler. 

642 

643 Parameters 

644 ---------- 

645 d : int 

646 Dimension of the parameter space. 

647 optimization : {None, "random-cd", "lloyd"}, optional 

648 Whether to use an optimization scheme to improve the quality after 

649 sampling. Note that this is a post-processing step that does not 

650 guarantee that all properties of the sample will be conserved. 

651 Default is None. 

652 

653 * ``random-cd``: random permutations of coordinates to lower the 

654 centered discrepancy. The best sample based on the centered 

655 discrepancy is constantly updated. Centered discrepancy-based 

656 sampling shows better space-filling robustness toward 2D and 3D 

657 subprojections compared to using other discrepancy measures. 

658 * ``lloyd``: Perturb samples using a modified Lloyd-Max algorithm. 

659 The process converges to equally spaced samples. 

660 

661 .. versionadded:: 1.10.0 

662 seed : {None, int, `numpy.random.Generator`}, optional 

663 If `seed` is an int or None, a new `numpy.random.Generator` is 

664 created using ``np.random.default_rng(seed)``. 

665 If `seed` is already a ``Generator`` instance, then the provided 

666 instance is used. 

667 

668 Notes 

669 ----- 

670 By convention samples are distributed over the half-open interval 

671 ``[0, 1)``. Instances of the class can access the attributes: ``d`` for 

672 the dimension; and ``rng`` for the random number generator (used for the 

673 ``seed``). 

674 

675 **Subclassing** 

676 

677 When subclassing `QMCEngine` to create a new sampler, ``__init__`` and 

678 ``random`` must be redefined. 

679 

680 * ``__init__(d, seed=None)``: at least fix the dimension. If the sampler 

681 does not take advantage of a ``seed`` (deterministic methods like 

682 Halton), this parameter can be omitted. 

683 * ``_random(n, *, workers=1)``: draw ``n`` from the engine. ``workers`` 

684 is used for parallelism. See `Halton` for example. 

685 

686 Optionally, two other methods can be overwritten by subclasses: 

687 

688 * ``reset``: Reset the engine to its original state. 

689 * ``fast_forward``: If the sequence is deterministic (like Halton 

690 sequence), then ``fast_forward(n)`` is skipping the ``n`` first draw. 

691 

692 Examples 

693 -------- 

694 To create a random sampler based on ``np.random.random``, we would do the 

695 following: 

696 

697 >>> from scipy.stats import qmc 

698 >>> class RandomEngine(qmc.QMCEngine): 

699 ... def __init__(self, d, seed=None): 

700 ... super().__init__(d=d, seed=seed) 

701 ... 

702 ... 

703 ... def _random(self, n=1, *, workers=1): 

704 ... return self.rng.random((n, self.d)) 

705 ... 

706 ... 

707 ... def reset(self): 

708 ... super().__init__(d=self.d, seed=self.rng_seed) 

709 ... return self 

710 ... 

711 ... 

712 ... def fast_forward(self, n): 

713 ... self.random(n) 

714 ... return self 

715 

716 After subclassing `QMCEngine` to define the sampling strategy we want to 

717 use, we can create an instance to sample from. 

718 

719 >>> engine = RandomEngine(2) 

720 >>> engine.random(5) 

721 array([[0.22733602, 0.31675834], # random 

722 [0.79736546, 0.67625467], 

723 [0.39110955, 0.33281393], 

724 [0.59830875, 0.18673419], 

725 [0.67275604, 0.94180287]]) 

726 

727 We can also reset the state of the generator and resample again. 

728 

729 >>> _ = engine.reset() 

730 >>> engine.random(5) 

731 array([[0.22733602, 0.31675834], # random 

732 [0.79736546, 0.67625467], 

733 [0.39110955, 0.33281393], 

734 [0.59830875, 0.18673419], 

735 [0.67275604, 0.94180287]]) 

736 

737 """ 

738 

739 @abstractmethod 

740 def __init__( 

741 self, 

742 d: IntNumber, 

743 *, 

744 optimization: Literal["random-cd", "lloyd"] | None = None, 

745 seed: SeedType = None 

746 ) -> None: 

747 if not np.issubdtype(type(d), np.integer) or d < 0: 

748 raise ValueError('d must be a non-negative integer value') 

749 

750 self.d = d 

751 

752 if isinstance(seed, np.random.Generator): 

753 # Spawn a Generator that we can own and reset. 

754 self.rng = _rng_spawn(seed, 1)[0] 

755 else: 

756 # Create our instance of Generator, does not need spawning 

757 # Also catch RandomState which cannot be spawned 

758 self.rng = check_random_state(seed) 

759 self.rng_seed = copy.deepcopy(self.rng) 

760 

761 self.num_generated = 0 

762 

763 config = { 

764 # random-cd 

765 "n_nochange": 100, 

766 "n_iters": 10_000, 

767 "rng": self.rng, 

768 

769 # lloyd 

770 "tol": 1e-5, 

771 "maxiter": 10, 

772 "qhull_options": None, 

773 } 

774 self.optimization_method = _select_optimizer(optimization, config) 

775 

776 @abstractmethod 

777 def _random( 

778 self, n: IntNumber = 1, *, workers: IntNumber = 1 

779 ) -> np.ndarray: 

780 ... 

781 

782 def random( 

783 self, n: IntNumber = 1, *, workers: IntNumber = 1 

784 ) -> np.ndarray: 

785 """Draw `n` in the half-open interval ``[0, 1)``. 

786 

787 Parameters 

788 ---------- 

789 n : int, optional 

790 Number of samples to generate in the parameter space. 

791 Default is 1. 

792 workers : int, optional 

793 Only supported with `Halton`. 

794 Number of workers to use for parallel processing. If -1 is 

795 given all CPU threads are used. Default is 1. It becomes faster 

796 than one worker for `n` greater than :math:`10^3`. 

797 

798 Returns 

799 ------- 

800 sample : array_like (n, d) 

801 QMC sample. 

802 

803 """ 

804 sample = self._random(n, workers=workers) 

805 if self.optimization_method is not None: 

806 sample = self.optimization_method(sample) 

807 

808 self.num_generated += n 

809 return sample 

810 

811 def integers( 

812 self, 

813 l_bounds: npt.ArrayLike, 

814 *, 

815 u_bounds: npt.ArrayLike | None = None, 

816 n: IntNumber = 1, 

817 endpoint: bool = False, 

818 workers: IntNumber = 1 

819 ) -> np.ndarray: 

820 r""" 

821 Draw `n` integers from `l_bounds` (inclusive) to `u_bounds` 

822 (exclusive), or if endpoint=True, `l_bounds` (inclusive) to 

823 `u_bounds` (inclusive). 

824 

825 Parameters 

826 ---------- 

827 l_bounds : int or array-like of ints 

828 Lowest (signed) integers to be drawn (unless ``u_bounds=None``, 

829 in which case this parameter is 0 and this value is used for 

830 `u_bounds`). 

831 u_bounds : int or array-like of ints, optional 

832 If provided, one above the largest (signed) integer to be drawn 

833 (see above for behavior if ``u_bounds=None``). 

834 If array-like, must contain integer values. 

835 n : int, optional 

836 Number of samples to generate in the parameter space. 

837 Default is 1. 

838 endpoint : bool, optional 

839 If true, sample from the interval ``[l_bounds, u_bounds]`` instead 

840 of the default ``[l_bounds, u_bounds)``. Defaults is False. 

841 workers : int, optional 

842 Number of workers to use for parallel processing. If -1 is 

843 given all CPU threads are used. Only supported when using `Halton` 

844 Default is 1. 

845 

846 Returns 

847 ------- 

848 sample : array_like (n, d) 

849 QMC sample. 

850 

851 Notes 

852 ----- 

853 It is safe to just use the same ``[0, 1)`` to integer mapping 

854 with QMC that you would use with MC. You still get unbiasedness, 

855 a strong law of large numbers, an asymptotically infinite variance 

856 reduction and a finite sample variance bound. 

857 

858 To convert a sample from :math:`[0, 1)` to :math:`[a, b), b>a`, 

859 with :math:`a` the lower bounds and :math:`b` the upper bounds, 

860 the following transformation is used: 

861 

862 .. math:: 

863 

864 \text{floor}((b - a) \cdot \text{sample} + a) 

865 

866 """ 

867 if u_bounds is None: 

868 u_bounds = l_bounds 

869 l_bounds = 0 

870 

871 u_bounds = np.atleast_1d(u_bounds) 

872 l_bounds = np.atleast_1d(l_bounds) 

873 

874 if endpoint: 

875 u_bounds = u_bounds + 1 

876 

877 if (not np.issubdtype(l_bounds.dtype, np.integer) or 

878 not np.issubdtype(u_bounds.dtype, np.integer)): 

879 message = ("'u_bounds' and 'l_bounds' must be integers or" 

880 " array-like of integers") 

881 raise ValueError(message) 

882 

883 if isinstance(self, Halton): 

884 sample = self.random(n=n, workers=workers) 

885 else: 

886 sample = self.random(n=n) 

887 

888 sample = scale(sample, l_bounds=l_bounds, u_bounds=u_bounds) 

889 sample = np.floor(sample).astype(np.int64) 

890 

891 return sample 

892 

893 def reset(self) -> QMCEngine: 

894 """Reset the engine to base state. 

895 

896 Returns 

897 ------- 

898 engine : QMCEngine 

899 Engine reset to its base state. 

900 

901 """ 

902 seed = copy.deepcopy(self.rng_seed) 

903 self.rng = check_random_state(seed) 

904 self.num_generated = 0 

905 return self 

906 

907 def fast_forward(self, n: IntNumber) -> QMCEngine: 

908 """Fast-forward the sequence by `n` positions. 

909 

910 Parameters 

911 ---------- 

912 n : int 

913 Number of points to skip in the sequence. 

914 

915 Returns 

916 ------- 

917 engine : QMCEngine 

918 Engine reset to its base state. 

919 

920 """ 

921 self.random(n=n) 

922 return self 

923 

924 

925class Halton(QMCEngine): 

926 """Halton sequence. 

927 

928 Pseudo-random number generator that generalize the Van der Corput sequence 

929 for multiple dimensions. The Halton sequence uses the base-two Van der 

930 Corput sequence for the first dimension, base-three for its second and 

931 base-:math:`n` for its n-dimension. 

932 

933 Parameters 

934 ---------- 

935 d : int 

936 Dimension of the parameter space. 

937 scramble : bool, optional 

938 If True, use Owen scrambling. Otherwise no scrambling is done. 

939 Default is True. 

940 optimization : {None, "random-cd", "lloyd"}, optional 

941 Whether to use an optimization scheme to improve the quality after 

942 sampling. Note that this is a post-processing step that does not 

943 guarantee that all properties of the sample will be conserved. 

944 Default is None. 

945 

946 * ``random-cd``: random permutations of coordinates to lower the 

947 centered discrepancy. The best sample based on the centered 

948 discrepancy is constantly updated. Centered discrepancy-based 

949 sampling shows better space-filling robustness toward 2D and 3D 

950 subprojections compared to using other discrepancy measures. 

951 * ``lloyd``: Perturb samples using a modified Lloyd-Max algorithm. 

952 The process converges to equally spaced samples. 

953 

954 .. versionadded:: 1.10.0 

955 seed : {None, int, `numpy.random.Generator`}, optional 

956 If `seed` is an int or None, a new `numpy.random.Generator` is 

957 created using ``np.random.default_rng(seed)``. 

958 If `seed` is already a ``Generator`` instance, then the provided 

959 instance is used. 

960 

961 Notes 

962 ----- 

963 The Halton sequence has severe striping artifacts for even modestly 

964 large dimensions. These can be ameliorated by scrambling. Scrambling 

965 also supports replication-based error estimates and extends 

966 applicabiltiy to unbounded integrands. 

967 

968 References 

969 ---------- 

970 .. [1] Halton, "On the efficiency of certain quasi-random sequences of 

971 points in evaluating multi-dimensional integrals", Numerische 

972 Mathematik, 1960. 

973 .. [2] A. B. Owen. "A randomized Halton algorithm in R", 

974 :arxiv:`1706.02808`, 2017. 

975 

976 Examples 

977 -------- 

978 Generate samples from a low discrepancy sequence of Halton. 

979 

980 >>> from scipy.stats import qmc 

981 >>> sampler = qmc.Halton(d=2, scramble=False) 

982 >>> sample = sampler.random(n=5) 

983 >>> sample 

984 array([[0. , 0. ], 

985 [0.5 , 0.33333333], 

986 [0.25 , 0.66666667], 

987 [0.75 , 0.11111111], 

988 [0.125 , 0.44444444]]) 

989 

990 Compute the quality of the sample using the discrepancy criterion. 

991 

992 >>> qmc.discrepancy(sample) 

993 0.088893711419753 

994 

995 If some wants to continue an existing design, extra points can be obtained 

996 by calling again `random`. Alternatively, you can skip some points like: 

997 

998 >>> _ = sampler.fast_forward(5) 

999 >>> sample_continued = sampler.random(n=5) 

1000 >>> sample_continued 

1001 array([[0.3125 , 0.37037037], 

1002 [0.8125 , 0.7037037 ], 

1003 [0.1875 , 0.14814815], 

1004 [0.6875 , 0.48148148], 

1005 [0.4375 , 0.81481481]]) 

1006 

1007 Finally, samples can be scaled to bounds. 

1008 

1009 >>> l_bounds = [0, 2] 

1010 >>> u_bounds = [10, 5] 

1011 >>> qmc.scale(sample_continued, l_bounds, u_bounds) 

1012 array([[3.125 , 3.11111111], 

1013 [8.125 , 4.11111111], 

1014 [1.875 , 2.44444444], 

1015 [6.875 , 3.44444444], 

1016 [4.375 , 4.44444444]]) 

1017 

1018 """ 

1019 

1020 def __init__( 

1021 self, d: IntNumber, *, scramble: bool = True, 

1022 optimization: Literal["random-cd", "lloyd"] | None = None, 

1023 seed: SeedType = None 

1024 ) -> None: 

1025 # Used in `scipy.integrate.qmc_quad` 

1026 self._init_quad = {'d': d, 'scramble': True, 

1027 'optimization': optimization} 

1028 super().__init__(d=d, optimization=optimization, seed=seed) 

1029 self.seed = seed 

1030 

1031 # important to have ``type(bdim) == int`` for performance reason 

1032 self.base = [int(bdim) for bdim in n_primes(d)] 

1033 self.scramble = scramble 

1034 

1035 self._initialize_permutations() 

1036 

1037 def _initialize_permutations(self) -> None: 

1038 """Initialize permutations for all Van der Corput sequences. 

1039 

1040 Permutations are only needed for scrambling. 

1041 """ 

1042 self._permutations: list = [None] * len(self.base) 

1043 if self.scramble: 

1044 for i, bdim in enumerate(self.base): 

1045 permutations = _van_der_corput_permutations( 

1046 base=bdim, random_state=self.rng 

1047 ) 

1048 

1049 self._permutations[i] = permutations 

1050 

1051 def _random( 

1052 self, n: IntNumber = 1, *, workers: IntNumber = 1 

1053 ) -> np.ndarray: 

1054 """Draw `n` in the half-open interval ``[0, 1)``. 

1055 

1056 Parameters 

1057 ---------- 

1058 n : int, optional 

1059 Number of samples to generate in the parameter space. Default is 1. 

1060 workers : int, optional 

1061 Number of workers to use for parallel processing. If -1 is 

1062 given all CPU threads are used. Default is 1. It becomes faster 

1063 than one worker for `n` greater than :math:`10^3`. 

1064 

1065 Returns 

1066 ------- 

1067 sample : array_like (n, d) 

1068 QMC sample. 

1069 

1070 """ 

1071 workers = _validate_workers(workers) 

1072 # Generate a sample using a Van der Corput sequence per dimension. 

1073 sample = [van_der_corput(n, bdim, start_index=self.num_generated, 

1074 scramble=self.scramble, 

1075 permutations=self._permutations[i], 

1076 workers=workers) 

1077 for i, bdim in enumerate(self.base)] 

1078 

1079 return np.array(sample).T.reshape(n, self.d) 

1080 

1081 

1082class LatinHypercube(QMCEngine): 

1083 r"""Latin hypercube sampling (LHS). 

1084 

1085 A Latin hypercube sample [1]_ generates :math:`n` points in 

1086 :math:`[0,1)^{d}`. Each univariate marginal distribution is stratified, 

1087 placing exactly one point in :math:`[j/n, (j+1)/n)` for 

1088 :math:`j=0,1,...,n-1`. They are still applicable when :math:`n << d`. 

1089 

1090 Parameters 

1091 ---------- 

1092 d : int 

1093 Dimension of the parameter space. 

1094 centered : bool, optional 

1095 Center samples within cells of a multi-dimensional grid. 

1096 Default is False. 

1097 

1098 .. deprecated:: 1.10.0 

1099 `centered` is deprecated as of SciPy 1.10.0 and will be removed in 

1100 1.12.0. Use `scramble` instead. ``centered=True`` corresponds to 

1101 ``scramble=False``. 

1102 

1103 scramble : bool, optional 

1104 When False, center samples within cells of a multi-dimensional grid. 

1105 Otherwise, samples are randomly placed within cells of the grid. 

1106 

1107 .. note:: 

1108 Setting ``scramble=False`` does not ensure deterministic output. 

1109 For that, use the `seed` parameter. 

1110 

1111 Default is True. 

1112 

1113 .. versionadded:: 1.10.0 

1114 

1115 optimization : {None, "random-cd", "lloyd"}, optional 

1116 Whether to use an optimization scheme to improve the quality after 

1117 sampling. Note that this is a post-processing step that does not 

1118 guarantee that all properties of the sample will be conserved. 

1119 Default is None. 

1120 

1121 * ``random-cd``: random permutations of coordinates to lower the 

1122 centered discrepancy. The best sample based on the centered 

1123 discrepancy is constantly updated. Centered discrepancy-based 

1124 sampling shows better space-filling robustness toward 2D and 3D 

1125 subprojections compared to using other discrepancy measures. 

1126 * ``lloyd``: Perturb samples using a modified Lloyd-Max algorithm. 

1127 The process converges to equally spaced samples. 

1128 

1129 .. versionadded:: 1.8.0 

1130 .. versionchanged:: 1.10.0 

1131 Add ``lloyd``. 

1132 

1133 strength : {1, 2}, optional 

1134 Strength of the LHS. ``strength=1`` produces a plain LHS while 

1135 ``strength=2`` produces an orthogonal array based LHS of strength 2 

1136 [7]_, [8]_. In that case, only ``n=p**2`` points can be sampled, 

1137 with ``p`` a prime number. It also constrains ``d <= p + 1``. 

1138 Default is 1. 

1139 

1140 .. versionadded:: 1.8.0 

1141 

1142 seed : {None, int, `numpy.random.Generator`}, optional 

1143 If `seed` is an int or None, a new `numpy.random.Generator` is 

1144 created using ``np.random.default_rng(seed)``. 

1145 If `seed` is already a ``Generator`` instance, then the provided 

1146 instance is used. 

1147 

1148 Notes 

1149 ----- 

1150 

1151 When LHS is used for integrating a function :math:`f` over :math:`n`, 

1152 LHS is extremely effective on integrands that are nearly additive [2]_. 

1153 With a LHS of :math:`n` points, the variance of the integral is always 

1154 lower than plain MC on :math:`n-1` points [3]_. There is a central limit 

1155 theorem for LHS on the mean and variance of the integral [4]_, but not 

1156 necessarily for optimized LHS due to the randomization. 

1157 

1158 :math:`A` is called an orthogonal array of strength :math:`t` if in each 

1159 n-row-by-t-column submatrix of :math:`A`: all :math:`p^t` possible 

1160 distinct rows occur the same number of times. The elements of :math:`A` 

1161 are in the set :math:`\{0, 1, ..., p-1\}`, also called symbols. 

1162 The constraint that :math:`p` must be a prime number is to allow modular 

1163 arithmetic. Increasing strength adds some symmetry to the sub-projections 

1164 of a sample. With strength 2, samples are symmetric along the diagonals of 

1165 2D sub-projections. This may be undesirable, but on the other hand, the 

1166 sample dispersion is improved. 

1167 

1168 Strength 1 (plain LHS) brings an advantage over strength 0 (MC) and 

1169 strength 2 is a useful increment over strength 1. Going to strength 3 is 

1170 a smaller increment and scrambled QMC like Sobol', Halton are more 

1171 performant [7]_. 

1172 

1173 To create a LHS of strength 2, the orthogonal array :math:`A` is 

1174 randomized by applying a random, bijective map of the set of symbols onto 

1175 itself. For example, in column 0, all 0s might become 2; in column 1, 

1176 all 0s might become 1, etc. 

1177 Then, for each column :math:`i` and symbol :math:`j`, we add a plain, 

1178 one-dimensional LHS of size :math:`p` to the subarray where 

1179 :math:`A^i = j`. The resulting matrix is finally divided by :math:`p`. 

1180 

1181 References 

1182 ---------- 

1183 .. [1] Mckay et al., "A Comparison of Three Methods for Selecting Values 

1184 of Input Variables in the Analysis of Output from a Computer Code." 

1185 Technometrics, 1979. 

1186 .. [2] M. Stein, "Large sample properties of simulations using Latin 

1187 hypercube sampling." Technometrics 29, no. 2: 143-151, 1987. 

1188 .. [3] A. B. Owen, "Monte Carlo variance of scrambled net quadrature." 

1189 SIAM Journal on Numerical Analysis 34, no. 5: 1884-1910, 1997 

1190 .. [4] Loh, W.-L. "On Latin hypercube sampling." The annals of statistics 

1191 24, no. 5: 2058-2080, 1996. 

1192 .. [5] Fang et al. "Design and modeling for computer experiments". 

1193 Computer Science and Data Analysis Series, 2006. 

1194 .. [6] Damblin et al., "Numerical studies of space filling designs: 

1195 optimization of Latin Hypercube Samples and subprojection properties." 

1196 Journal of Simulation, 2013. 

1197 .. [7] A. B. Owen , "Orthogonal arrays for computer experiments, 

1198 integration and visualization." Statistica Sinica, 1992. 

1199 .. [8] B. Tang, "Orthogonal Array-Based Latin Hypercubes." 

1200 Journal of the American Statistical Association, 1993. 

1201 .. [9] Susan K. Seaholm et al. "Latin hypercube sampling and the 

1202 sensitivity analysis of a Monte Carlo epidemic model". 

1203 Int J Biomed Comput, 23(1-2), 97-112, 

1204 :doi:`10.1016/0020-7101(88)90067-0`, 1988. 

1205 

1206 Examples 

1207 -------- 

1208 In [9]_, a Latin Hypercube sampling strategy was used to sample a 

1209 parameter space to study the importance of each parameter of an epidemic 

1210 model. Such analysis is also called a sensitivity analysis. 

1211 

1212 Since the dimensionality of the problem is high (6), it is computationally 

1213 expensive to cover the space. When numerical experiments are costly, 

1214 QMC enables analysis that may not be possible if using a grid. 

1215 

1216 The six parameters of the model represented the probability of illness, 

1217 the probability of withdrawal, and four contact probabilities, 

1218 The authors assumed uniform distributions for all parameters and generated 

1219 50 samples. 

1220 

1221 Using `scipy.stats.qmc.LatinHypercube` to replicate the protocol, the 

1222 first step is to create a sample in the unit hypercube: 

1223 

1224 >>> from scipy.stats import qmc 

1225 >>> sampler = qmc.LatinHypercube(d=6) 

1226 >>> sample = sampler.random(n=50) 

1227 

1228 Then the sample can be scaled to the appropriate bounds: 

1229 

1230 >>> l_bounds = [0.000125, 0.01, 0.0025, 0.05, 0.47, 0.7] 

1231 >>> u_bounds = [0.000375, 0.03, 0.0075, 0.15, 0.87, 0.9] 

1232 >>> sample_scaled = qmc.scale(sample, l_bounds, u_bounds) 

1233 

1234 Such a sample was used to run the model 50 times, and a polynomial 

1235 response surface was constructed. This allowed the authors to study the 

1236 relative importance of each parameter across the range of 

1237 possibilities of every other parameter. 

1238 In this computer experiment, they showed a 14-fold reduction in the number 

1239 of samples required to maintain an error below 2% on their response surface 

1240 when compared to a grid sampling. 

1241 

1242 Below are other examples showing alternative ways to construct LHS 

1243 with even better coverage of the space. 

1244 

1245 Using a base LHS as a baseline. 

1246 

1247 >>> sampler = qmc.LatinHypercube(d=2) 

1248 >>> sample = sampler.random(n=5) 

1249 >>> qmc.discrepancy(sample) 

1250 0.0196... # random 

1251 

1252 Use the `optimization` keyword argument to produce a LHS with 

1253 lower discrepancy at higher computational cost. 

1254 

1255 >>> sampler = qmc.LatinHypercube(d=2, optimization="random-cd") 

1256 >>> sample = sampler.random(n=5) 

1257 >>> qmc.discrepancy(sample) 

1258 0.0176... # random 

1259 

1260 Use the `strength` keyword argument to produce an orthogonal array based 

1261 LHS of strength 2. In this case, the number of sample points must be the 

1262 square of a prime number. 

1263 

1264 >>> sampler = qmc.LatinHypercube(d=2, strength=2) 

1265 >>> sample = sampler.random(n=9) 

1266 >>> qmc.discrepancy(sample) 

1267 0.00526... # random 

1268 

1269 Options could be combined to produce an optimized centered 

1270 orthogonal array based LHS. After optimization, the result would not 

1271 be guaranteed to be of strength 2. 

1272 

1273 """ 

1274 

1275 def __init__( 

1276 self, d: IntNumber, *, centered: bool = False, 

1277 scramble: bool = True, 

1278 strength: int = 1, 

1279 optimization: Literal["random-cd", "lloyd"] | None = None, 

1280 seed: SeedType = None 

1281 ) -> None: 

1282 if centered: 

1283 scramble = False 

1284 warnings.warn( 

1285 "'centered' is deprecated and will be removed in SciPy 1.12." 

1286 " Please use 'scramble' instead. 'centered=True' corresponds" 

1287 " to 'scramble=False'.", 

1288 stacklevel=2 

1289 ) 

1290 

1291 # Used in `scipy.integrate.qmc_quad` 

1292 self._init_quad = {'d': d, 'scramble': True, 'strength': strength, 

1293 'optimization': optimization} 

1294 super().__init__(d=d, seed=seed, optimization=optimization) 

1295 self.scramble = scramble 

1296 

1297 lhs_method_strength = { 

1298 1: self._random_lhs, 

1299 2: self._random_oa_lhs 

1300 } 

1301 

1302 try: 

1303 self.lhs_method: Callable = lhs_method_strength[strength] 

1304 except KeyError as exc: 

1305 message = (f"{strength!r} is not a valid strength. It must be one" 

1306 f" of {set(lhs_method_strength)!r}") 

1307 raise ValueError(message) from exc 

1308 

1309 def _random( 

1310 self, n: IntNumber = 1, *, workers: IntNumber = 1 

1311 ) -> np.ndarray: 

1312 lhs = self.lhs_method(n) 

1313 return lhs 

1314 

1315 def _random_lhs(self, n: IntNumber = 1) -> np.ndarray: 

1316 """Base LHS algorithm.""" 

1317 if not self.scramble: 

1318 samples: np.ndarray | float = 0.5 

1319 else: 

1320 samples = self.rng.uniform(size=(n, self.d)) 

1321 

1322 perms = np.tile(np.arange(1, n + 1), 

1323 (self.d, 1)) # type: ignore[arg-type] 

1324 for i in range(self.d): 

1325 self.rng.shuffle(perms[i, :]) 

1326 perms = perms.T 

1327 

1328 samples = (perms - samples) / n 

1329 return samples 

1330 

1331 def _random_oa_lhs(self, n: IntNumber = 4) -> np.ndarray: 

1332 """Orthogonal array based LHS of strength 2.""" 

1333 p = np.sqrt(n).astype(int) 

1334 n_row = p**2 

1335 n_col = p + 1 

1336 

1337 primes = primes_from_2_to(p + 1) 

1338 if p not in primes or n != n_row: 

1339 raise ValueError( 

1340 "n is not the square of a prime number. Close" 

1341 f" values are {primes[-2:]**2}" 

1342 ) 

1343 if self.d > p + 1: 

1344 raise ValueError("n is too small for d. Must be n > (d-1)**2") 

1345 

1346 oa_sample = np.zeros(shape=(n_row, n_col), dtype=int) 

1347 

1348 # OA of strength 2 

1349 arrays = np.tile(np.arange(p), (2, 1)) 

1350 oa_sample[:, :2] = np.stack(np.meshgrid(*arrays), 

1351 axis=-1).reshape(-1, 2) 

1352 for p_ in range(1, p): 

1353 oa_sample[:, 2+p_-1] = np.mod(oa_sample[:, 0] 

1354 + p_*oa_sample[:, 1], p) 

1355 

1356 # scramble the OA 

1357 oa_sample_ = np.empty(shape=(n_row, n_col), dtype=int) 

1358 for j in range(n_col): 

1359 perms = self.rng.permutation(p) 

1360 oa_sample_[:, j] = perms[oa_sample[:, j]] 

1361 

1362 # following is making a scrambled OA into an OA-LHS 

1363 oa_lhs_sample = np.zeros(shape=(n_row, n_col)) 

1364 lhs_engine = LatinHypercube(d=1, scramble=self.scramble, strength=1, 

1365 seed=self.rng) # type: QMCEngine 

1366 for j in range(n_col): 

1367 for k in range(p): 

1368 idx = oa_sample[:, j] == k 

1369 lhs = lhs_engine.random(p).flatten() 

1370 oa_lhs_sample[:, j][idx] = lhs + oa_sample[:, j][idx] 

1371 

1372 lhs_engine = lhs_engine.reset() 

1373 

1374 oa_lhs_sample /= p 

1375 

1376 return oa_lhs_sample[:, :self.d] # type: ignore 

1377 

1378 

1379class Sobol(QMCEngine): 

1380 """Engine for generating (scrambled) Sobol' sequences. 

1381 

1382 Sobol' sequences are low-discrepancy, quasi-random numbers. Points 

1383 can be drawn using two methods: 

1384 

1385 * `random_base2`: safely draw :math:`n=2^m` points. This method 

1386 guarantees the balance properties of the sequence. 

1387 * `random`: draw an arbitrary number of points from the 

1388 sequence. See warning below. 

1389 

1390 Parameters 

1391 ---------- 

1392 d : int 

1393 Dimensionality of the sequence. Max dimensionality is 21201. 

1394 scramble : bool, optional 

1395 If True, use LMS+shift scrambling. Otherwise, no scrambling is done. 

1396 Default is True. 

1397 bits : int, optional 

1398 Number of bits of the generator. Control the maximum number of points 

1399 that can be generated, which is ``2**bits``. Maximal value is 64. 

1400 It does not correspond to the return type, which is always 

1401 ``np.float64`` to prevent points from repeating themselves. 

1402 Default is None, which for backward compatibility, corresponds to 30. 

1403 

1404 .. versionadded:: 1.9.0 

1405 optimization : {None, "random-cd", "lloyd"}, optional 

1406 Whether to use an optimization scheme to improve the quality after 

1407 sampling. Note that this is a post-processing step that does not 

1408 guarantee that all properties of the sample will be conserved. 

1409 Default is None. 

1410 

1411 * ``random-cd``: random permutations of coordinates to lower the 

1412 centered discrepancy. The best sample based on the centered 

1413 discrepancy is constantly updated. Centered discrepancy-based 

1414 sampling shows better space-filling robustness toward 2D and 3D 

1415 subprojections compared to using other discrepancy measures. 

1416 * ``lloyd``: Perturb samples using a modified Lloyd-Max algorithm. 

1417 The process converges to equally spaced samples. 

1418 

1419 .. versionadded:: 1.10.0 

1420 seed : {None, int, `numpy.random.Generator`}, optional 

1421 If `seed` is an int or None, a new `numpy.random.Generator` is 

1422 created using ``np.random.default_rng(seed)``. 

1423 If `seed` is already a ``Generator`` instance, then the provided 

1424 instance is used. 

1425 

1426 Notes 

1427 ----- 

1428 Sobol' sequences [1]_ provide :math:`n=2^m` low discrepancy points in 

1429 :math:`[0,1)^{d}`. Scrambling them [3]_ makes them suitable for singular 

1430 integrands, provides a means of error estimation, and can improve their 

1431 rate of convergence. The scrambling strategy which is implemented is a 

1432 (left) linear matrix scramble (LMS) followed by a digital random shift 

1433 (LMS+shift) [2]_. 

1434 

1435 There are many versions of Sobol' sequences depending on their 

1436 'direction numbers'. This code uses direction numbers from [4]_. Hence, 

1437 the maximum number of dimension is 21201. The direction numbers have been 

1438 precomputed with search criterion 6 and can be retrieved at 

1439 https://web.maths.unsw.edu.au/~fkuo/sobol/. 

1440 

1441 .. warning:: 

1442 

1443 Sobol' sequences are a quadrature rule and they lose their balance 

1444 properties if one uses a sample size that is not a power of 2, or skips 

1445 the first point, or thins the sequence [5]_. 

1446 

1447 If :math:`n=2^m` points are not enough then one should take :math:`2^M` 

1448 points for :math:`M>m`. When scrambling, the number R of independent 

1449 replicates does not have to be a power of 2. 

1450 

1451 Sobol' sequences are generated to some number :math:`B` of bits. 

1452 After :math:`2^B` points have been generated, the sequence would 

1453 repeat. Hence, an error is raised. 

1454 The number of bits can be controlled with the parameter `bits`. 

1455 

1456 References 

1457 ---------- 

1458 .. [1] I. M. Sobol', "The distribution of points in a cube and the accurate 

1459 evaluation of integrals." Zh. Vychisl. Mat. i Mat. Phys., 7:784-802, 

1460 1967. 

1461 .. [2] J. Matousek, "On the L2-discrepancy for anchored boxes." 

1462 J. of Complexity 14, 527-556, 1998. 

1463 .. [3] Art B. Owen, "Scrambling Sobol and Niederreiter-Xing points." 

1464 Journal of Complexity, 14(4):466-489, December 1998. 

1465 .. [4] S. Joe and F. Y. Kuo, "Constructing sobol sequences with better 

1466 two-dimensional projections." SIAM Journal on Scientific Computing, 

1467 30(5):2635-2654, 2008. 

1468 .. [5] Art B. Owen, "On dropping the first Sobol' point." 

1469 :arxiv:`2008.08051`, 2020. 

1470 

1471 Examples 

1472 -------- 

1473 Generate samples from a low discrepancy sequence of Sobol'. 

1474 

1475 >>> from scipy.stats import qmc 

1476 >>> sampler = qmc.Sobol(d=2, scramble=False) 

1477 >>> sample = sampler.random_base2(m=3) 

1478 >>> sample 

1479 array([[0. , 0. ], 

1480 [0.5 , 0.5 ], 

1481 [0.75 , 0.25 ], 

1482 [0.25 , 0.75 ], 

1483 [0.375, 0.375], 

1484 [0.875, 0.875], 

1485 [0.625, 0.125], 

1486 [0.125, 0.625]]) 

1487 

1488 Compute the quality of the sample using the discrepancy criterion. 

1489 

1490 >>> qmc.discrepancy(sample) 

1491 0.013882107204860938 

1492 

1493 To continue an existing design, extra points can be obtained 

1494 by calling again `random_base2`. Alternatively, you can skip some 

1495 points like: 

1496 

1497 >>> _ = sampler.reset() 

1498 >>> _ = sampler.fast_forward(4) 

1499 >>> sample_continued = sampler.random_base2(m=2) 

1500 >>> sample_continued 

1501 array([[0.375, 0.375], 

1502 [0.875, 0.875], 

1503 [0.625, 0.125], 

1504 [0.125, 0.625]]) 

1505 

1506 Finally, samples can be scaled to bounds. 

1507 

1508 >>> l_bounds = [0, 2] 

1509 >>> u_bounds = [10, 5] 

1510 >>> qmc.scale(sample_continued, l_bounds, u_bounds) 

1511 array([[3.75 , 3.125], 

1512 [8.75 , 4.625], 

1513 [6.25 , 2.375], 

1514 [1.25 , 3.875]]) 

1515 

1516 """ 

1517 

1518 MAXDIM: ClassVar[int] = _MAXDIM 

1519 

1520 def __init__( 

1521 self, d: IntNumber, *, scramble: bool = True, 

1522 bits: IntNumber | None = None, seed: SeedType = None, 

1523 optimization: Literal["random-cd", "lloyd"] | None = None 

1524 ) -> None: 

1525 # Used in `scipy.integrate.qmc_quad` 

1526 self._init_quad = {'d': d, 'scramble': True, 'bits': bits, 

1527 'optimization': optimization} 

1528 

1529 super().__init__(d=d, optimization=optimization, seed=seed) 

1530 if d > self.MAXDIM: 

1531 raise ValueError( 

1532 f"Maximum supported dimensionality is {self.MAXDIM}." 

1533 ) 

1534 

1535 self.bits = bits 

1536 self.dtype_i: type 

1537 

1538 if self.bits is None: 

1539 self.bits = 30 

1540 

1541 if self.bits <= 32: 

1542 self.dtype_i = np.uint32 

1543 elif 32 < self.bits <= 64: 

1544 self.dtype_i = np.uint64 

1545 else: 

1546 raise ValueError("Maximum supported 'bits' is 64") 

1547 

1548 self.maxn = 2**self.bits 

1549 

1550 # v is d x maxbit matrix 

1551 self._sv: np.ndarray = np.zeros((d, self.bits), dtype=self.dtype_i) 

1552 _initialize_v(self._sv, dim=d, bits=self.bits) 

1553 

1554 if not scramble: 

1555 self._shift: np.ndarray = np.zeros(d, dtype=self.dtype_i) 

1556 else: 

1557 # scramble self._shift and self._sv 

1558 self._scramble() 

1559 

1560 self._quasi = self._shift.copy() 

1561 

1562 # normalization constant with the largest possible number 

1563 # calculate in Python to not overflow int with 2**64 

1564 self._scale = 1.0 / 2 ** self.bits 

1565 

1566 self._first_point = (self._quasi * self._scale).reshape(1, -1) 

1567 # explicit casting to float64 

1568 self._first_point = self._first_point.astype(np.float64) 

1569 

1570 def _scramble(self) -> None: 

1571 """Scramble the sequence using LMS+shift.""" 

1572 # Generate shift vector 

1573 self._shift = np.dot( 

1574 rng_integers(self.rng, 2, size=(self.d, self.bits), 

1575 dtype=self.dtype_i), 

1576 2 ** np.arange(self.bits, dtype=self.dtype_i), 

1577 ) 

1578 # Generate lower triangular matrices (stacked across dimensions) 

1579 ltm = np.tril(rng_integers(self.rng, 2, 

1580 size=(self.d, self.bits, self.bits), 

1581 dtype=self.dtype_i)) 

1582 _cscramble( 

1583 dim=self.d, bits=self.bits, # type: ignore[arg-type] 

1584 ltm=ltm, sv=self._sv 

1585 ) 

1586 

1587 def _random( 

1588 self, n: IntNumber = 1, *, workers: IntNumber = 1 

1589 ) -> np.ndarray: 

1590 """Draw next point(s) in the Sobol' sequence. 

1591 

1592 Parameters 

1593 ---------- 

1594 n : int, optional 

1595 Number of samples to generate in the parameter space. Default is 1. 

1596 

1597 Returns 

1598 ------- 

1599 sample : array_like (n, d) 

1600 Sobol' sample. 

1601 

1602 """ 

1603 sample: np.ndarray = np.empty((n, self.d), dtype=np.float64) 

1604 

1605 if n == 0: 

1606 return sample 

1607 

1608 total_n = self.num_generated + n 

1609 if total_n > self.maxn: 

1610 msg = ( 

1611 f"At most 2**{self.bits}={self.maxn} distinct points can be " 

1612 f"generated. {self.num_generated} points have been previously " 

1613 f"generated, then: n={self.num_generated}+{n}={total_n}. " 

1614 ) 

1615 if self.bits != 64: 

1616 msg += "Consider increasing `bits`." 

1617 raise ValueError(msg) 

1618 

1619 if self.num_generated == 0: 

1620 # verify n is 2**n 

1621 if not (n & (n - 1) == 0): 

1622 warnings.warn("The balance properties of Sobol' points require" 

1623 " n to be a power of 2.", stacklevel=2) 

1624 

1625 if n == 1: 

1626 sample = self._first_point 

1627 else: 

1628 _draw( 

1629 n=n - 1, num_gen=self.num_generated, dim=self.d, 

1630 scale=self._scale, sv=self._sv, quasi=self._quasi, 

1631 sample=sample 

1632 ) 

1633 sample = np.concatenate( 

1634 [self._first_point, sample] 

1635 )[:n] # type: ignore[misc] 

1636 else: 

1637 _draw( 

1638 n=n, num_gen=self.num_generated - 1, dim=self.d, 

1639 scale=self._scale, sv=self._sv, quasi=self._quasi, 

1640 sample=sample 

1641 ) 

1642 

1643 return sample 

1644 

1645 def random_base2(self, m: IntNumber) -> np.ndarray: 

1646 """Draw point(s) from the Sobol' sequence. 

1647 

1648 This function draws :math:`n=2^m` points in the parameter space 

1649 ensuring the balance properties of the sequence. 

1650 

1651 Parameters 

1652 ---------- 

1653 m : int 

1654 Logarithm in base 2 of the number of samples; i.e., n = 2^m. 

1655 

1656 Returns 

1657 ------- 

1658 sample : array_like (n, d) 

1659 Sobol' sample. 

1660 

1661 """ 

1662 n = 2 ** m 

1663 

1664 total_n = self.num_generated + n 

1665 if not (total_n & (total_n - 1) == 0): 

1666 raise ValueError("The balance properties of Sobol' points require " 

1667 "n to be a power of 2. {0} points have been " 

1668 "previously generated, then: n={0}+2**{1}={2}. " 

1669 "If you still want to do this, the function " 

1670 "'Sobol.random()' can be used." 

1671 .format(self.num_generated, m, total_n)) 

1672 

1673 return self.random(n) 

1674 

1675 def reset(self) -> Sobol: 

1676 """Reset the engine to base state. 

1677 

1678 Returns 

1679 ------- 

1680 engine : Sobol 

1681 Engine reset to its base state. 

1682 

1683 """ 

1684 super().reset() 

1685 self._quasi = self._shift.copy() 

1686 return self 

1687 

1688 def fast_forward(self, n: IntNumber) -> Sobol: 

1689 """Fast-forward the sequence by `n` positions. 

1690 

1691 Parameters 

1692 ---------- 

1693 n : int 

1694 Number of points to skip in the sequence. 

1695 

1696 Returns 

1697 ------- 

1698 engine : Sobol 

1699 The fast-forwarded engine. 

1700 

1701 """ 

1702 if self.num_generated == 0: 

1703 _fast_forward( 

1704 n=n - 1, num_gen=self.num_generated, dim=self.d, 

1705 sv=self._sv, quasi=self._quasi 

1706 ) 

1707 else: 

1708 _fast_forward( 

1709 n=n, num_gen=self.num_generated - 1, dim=self.d, 

1710 sv=self._sv, quasi=self._quasi 

1711 ) 

1712 self.num_generated += n 

1713 return self 

1714 

1715 

1716class PoissonDisk(QMCEngine): 

1717 """Poisson disk sampling. 

1718 

1719 Parameters 

1720 ---------- 

1721 d : int 

1722 Dimension of the parameter space. 

1723 radius : float 

1724 Minimal distance to keep between points when sampling new candidates. 

1725 hypersphere : {"volume", "surface"}, optional 

1726 Sampling strategy to generate potential candidates to be added in the 

1727 final sample. Default is "volume". 

1728 

1729 * ``volume``: original Bridson algorithm as described in [1]_. 

1730 New candidates are sampled *within* the hypersphere. 

1731 * ``surface``: only sample the surface of the hypersphere. 

1732 ncandidates : int 

1733 Number of candidates to sample per iteration. More candidates result 

1734 in a denser sampling as more candidates can be accepted per iteration. 

1735 optimization : {None, "random-cd", "lloyd"}, optional 

1736 Whether to use an optimization scheme to improve the quality after 

1737 sampling. Note that this is a post-processing step that does not 

1738 guarantee that all properties of the sample will be conserved. 

1739 Default is None. 

1740 

1741 * ``random-cd``: random permutations of coordinates to lower the 

1742 centered discrepancy. The best sample based on the centered 

1743 discrepancy is constantly updated. Centered discrepancy-based 

1744 sampling shows better space-filling robustness toward 2D and 3D 

1745 subprojections compared to using other discrepancy measures. 

1746 * ``lloyd``: Perturb samples using a modified Lloyd-Max algorithm. 

1747 The process converges to equally spaced samples. 

1748 

1749 .. versionadded:: 1.10.0 

1750 seed : {None, int, `numpy.random.Generator`}, optional 

1751 If `seed` is an int or None, a new `numpy.random.Generator` is 

1752 created using ``np.random.default_rng(seed)``. 

1753 If `seed` is already a ``Generator`` instance, then the provided 

1754 instance is used. 

1755 

1756 Notes 

1757 ----- 

1758 Poisson disk sampling is an iterative sampling strategy. Starting from 

1759 a seed sample, `ncandidates` are sampled in the hypersphere 

1760 surrounding the seed. Candidates bellow a certain `radius` or outside the 

1761 domain are rejected. New samples are added in a pool of sample seed. The 

1762 process stops when the pool is empty or when the number of required 

1763 samples is reached. 

1764 

1765 The maximum number of point that a sample can contain is directly linked 

1766 to the `radius`. As the dimension of the space increases, a higher radius 

1767 spreads the points further and help overcome the curse of dimensionality. 

1768 See the :ref:`quasi monte carlo tutorial <quasi-monte-carlo>` for more 

1769 details. 

1770 

1771 .. warning:: 

1772 

1773 The algorithm is more suitable for low dimensions and sampling size 

1774 due to its iterative nature and memory requirements. 

1775 Selecting a small radius with a high dimension would 

1776 mean that the space could contain more samples than using lower 

1777 dimension or a bigger radius. 

1778 

1779 Some code taken from [2]_, written consent given on 31.03.2021 

1780 by the original author, Shamis, for free use in SciPy under 

1781 the 3-clause BSD. 

1782 

1783 References 

1784 ---------- 

1785 .. [1] Robert Bridson, "Fast Poisson Disk Sampling in Arbitrary 

1786 Dimensions." SIGGRAPH, 2007. 

1787 .. [2] `StackOverflow <https://stackoverflow.com/questions/66047540>`__. 

1788 

1789 Examples 

1790 -------- 

1791 Generate a 2D sample using a `radius` of 0.2. 

1792 

1793 >>> import numpy as np 

1794 >>> import matplotlib.pyplot as plt 

1795 >>> from matplotlib.collections import PatchCollection 

1796 >>> from scipy.stats import qmc 

1797 >>> 

1798 >>> rng = np.random.default_rng() 

1799 >>> radius = 0.2 

1800 >>> engine = qmc.PoissonDisk(d=2, radius=radius, seed=rng) 

1801 >>> sample = engine.random(20) 

1802 

1803 Visualizing the 2D sample and showing that no points are closer than 

1804 `radius`. ``radius/2`` is used to visualize non-intersecting circles. 

1805 If two samples are exactly at `radius` from each other, then their circle 

1806 of radius ``radius/2`` will touch. 

1807 

1808 >>> fig, ax = plt.subplots() 

1809 >>> _ = ax.scatter(sample[:, 0], sample[:, 1]) 

1810 >>> circles = [plt.Circle((xi, yi), radius=radius/2, fill=False) 

1811 ... for xi, yi in sample] 

1812 >>> collection = PatchCollection(circles, match_original=True) 

1813 >>> ax.add_collection(collection) 

1814 >>> _ = ax.set(aspect='equal', xlabel=r'$x_1$', ylabel=r'$x_2$', 

1815 ... xlim=[0, 1], ylim=[0, 1]) 

1816 >>> plt.show() 

1817 

1818 Such visualization can be seen as circle packing: how many circle can 

1819 we put in the space. It is a np-hard problem. The method `fill_space` 

1820 can be used to add samples until no more samples can be added. This is 

1821 a hard problem and parameters may need to be adjusted manually. Beware of 

1822 the dimension: as the dimensionality increases, the number of samples 

1823 required to fill the space increases exponentially 

1824 (curse-of-dimensionality). 

1825 

1826 """ 

1827 

1828 def __init__( 

1829 self, 

1830 d: IntNumber, 

1831 *, 

1832 radius: DecimalNumber = 0.05, 

1833 hypersphere: Literal["volume", "surface"] = "volume", 

1834 ncandidates: IntNumber = 30, 

1835 optimization: Literal["random-cd", "lloyd"] | None = None, 

1836 seed: SeedType = None 

1837 ) -> None: 

1838 # Used in `scipy.integrate.qmc_quad` 

1839 self._init_quad = {'d': d, 'radius': radius, 

1840 'hypersphere': hypersphere, 

1841 'ncandidates': ncandidates, 

1842 'optimization': optimization} 

1843 super().__init__(d=d, optimization=optimization, seed=seed) 

1844 

1845 hypersphere_sample = { 

1846 "volume": self._hypersphere_volume_sample, 

1847 "surface": self._hypersphere_surface_sample 

1848 } 

1849 

1850 try: 

1851 self.hypersphere_method = hypersphere_sample[hypersphere] 

1852 except KeyError as exc: 

1853 message = ( 

1854 f"{hypersphere!r} is not a valid hypersphere sampling" 

1855 f" method. It must be one of {set(hypersphere_sample)!r}") 

1856 raise ValueError(message) from exc 

1857 

1858 # size of the sphere from which the samples are drawn relative to the 

1859 # size of a disk (radius) 

1860 # for the surface sampler, all new points are almost exactly 1 radius 

1861 # away from at least one existing sample +eps to avoid rejection 

1862 self.radius_factor = 2 if hypersphere == "volume" else 1.001 

1863 self.radius = radius 

1864 self.radius_squared = self.radius**2 

1865 

1866 # sample to generate per iteration in the hypersphere around center 

1867 self.ncandidates = ncandidates 

1868 

1869 with np.errstate(divide='ignore'): 

1870 self.cell_size = self.radius / np.sqrt(self.d) 

1871 self.grid_size = ( 

1872 np.ceil(np.ones(self.d) / self.cell_size) 

1873 ).astype(int) 

1874 

1875 self._initialize_grid_pool() 

1876 

1877 def _initialize_grid_pool(self): 

1878 """Sampling pool and sample grid.""" 

1879 self.sample_pool = [] 

1880 # Positions of cells 

1881 # n-dim value for each grid cell 

1882 self.sample_grid = np.empty( 

1883 np.append(self.grid_size, self.d), 

1884 dtype=np.float32 

1885 ) 

1886 # Initialise empty cells with NaNs 

1887 self.sample_grid.fill(np.nan) 

1888 

1889 def _random( 

1890 self, n: IntNumber = 1, *, workers: IntNumber = 1 

1891 ) -> np.ndarray: 

1892 """Draw `n` in the interval ``[0, 1]``. 

1893 

1894 Note that it can return fewer samples if the space is full. 

1895 See the note section of the class. 

1896 

1897 Parameters 

1898 ---------- 

1899 n : int, optional 

1900 Number of samples to generate in the parameter space. Default is 1. 

1901 

1902 Returns 

1903 ------- 

1904 sample : array_like (n, d) 

1905 QMC sample. 

1906 

1907 """ 

1908 if n == 0 or self.d == 0: 

1909 return np.empty((n, self.d)) 

1910 

1911 def in_limits(sample: np.ndarray) -> bool: 

1912 return (sample.max() <= 1.) and (sample.min() >= 0.) 

1913 

1914 def in_neighborhood(candidate: np.ndarray, n: int = 2) -> bool: 

1915 """ 

1916 Check if there are samples closer than ``radius_squared`` to the 

1917 `candidate` sample. 

1918 """ 

1919 indices = (candidate / self.cell_size).astype(int) 

1920 ind_min = np.maximum(indices - n, np.zeros(self.d, dtype=int)) 

1921 ind_max = np.minimum(indices + n + 1, self.grid_size) 

1922 

1923 # Check if the center cell is empty 

1924 if not np.isnan(self.sample_grid[tuple(indices)][0]): 

1925 return True 

1926 

1927 a = [slice(ind_min[i], ind_max[i]) for i in range(self.d)] 

1928 

1929 # guards against: invalid value encountered in less as we are 

1930 # comparing with nan and returns False. Which is wanted. 

1931 with np.errstate(invalid='ignore'): 

1932 if np.any( 

1933 np.sum( 

1934 np.square(candidate - self.sample_grid[tuple(a)]), 

1935 axis=self.d 

1936 ) < self.radius_squared 

1937 ): 

1938 return True 

1939 

1940 return False 

1941 

1942 def add_sample(candidate: np.ndarray) -> None: 

1943 self.sample_pool.append(candidate) 

1944 indices = (candidate / self.cell_size).astype(int) 

1945 self.sample_grid[tuple(indices)] = candidate 

1946 curr_sample.append(candidate) 

1947 

1948 curr_sample: list[np.ndarray] = [] 

1949 

1950 if len(self.sample_pool) == 0: 

1951 # the pool is being initialized with a single random sample 

1952 add_sample(self.rng.random(self.d)) 

1953 num_drawn = 1 

1954 else: 

1955 num_drawn = 0 

1956 

1957 # exhaust sample pool to have up to n sample 

1958 while len(self.sample_pool) and num_drawn < n: 

1959 # select a sample from the available pool 

1960 idx_center = rng_integers(self.rng, len(self.sample_pool)) 

1961 center = self.sample_pool[idx_center] 

1962 del self.sample_pool[idx_center] 

1963 

1964 # generate candidates around the center sample 

1965 candidates = self.hypersphere_method( 

1966 center, self.radius * self.radius_factor, self.ncandidates 

1967 ) 

1968 

1969 # keep candidates that satisfy some conditions 

1970 for candidate in candidates: 

1971 if in_limits(candidate) and not in_neighborhood(candidate): 

1972 add_sample(candidate) 

1973 

1974 num_drawn += 1 

1975 if num_drawn >= n: 

1976 break 

1977 

1978 self.num_generated += num_drawn 

1979 return np.array(curr_sample) 

1980 

1981 def fill_space(self) -> np.ndarray: 

1982 """Draw ``n`` samples in the interval ``[0, 1]``. 

1983 

1984 Unlike `random`, this method will try to add points until 

1985 the space is full. Depending on ``candidates`` (and to a lesser extent 

1986 other parameters), some empty areas can still be present in the sample. 

1987 

1988 .. warning:: 

1989 

1990 This can be extremely slow in high dimensions or if the 

1991 ``radius`` is very small-with respect to the dimensionality. 

1992 

1993 Returns 

1994 ------- 

1995 sample : array_like (n, d) 

1996 QMC sample. 

1997 

1998 """ 

1999 return self.random(np.inf) # type: ignore[arg-type] 

2000 

2001 def reset(self) -> PoissonDisk: 

2002 """Reset the engine to base state. 

2003 

2004 Returns 

2005 ------- 

2006 engine : PoissonDisk 

2007 Engine reset to its base state. 

2008 

2009 """ 

2010 super().reset() 

2011 self._initialize_grid_pool() 

2012 return self 

2013 

2014 def _hypersphere_volume_sample( 

2015 self, center: np.ndarray, radius: DecimalNumber, 

2016 candidates: IntNumber = 1 

2017 ) -> np.ndarray: 

2018 """Uniform sampling within hypersphere.""" 

2019 # should remove samples within r/2 

2020 x = self.rng.standard_normal(size=(candidates, self.d)) 

2021 ssq = np.sum(x**2, axis=1) 

2022 fr = radius * gammainc(self.d/2, ssq/2)**(1/self.d) / np.sqrt(ssq) 

2023 fr_tiled = np.tile( 

2024 fr.reshape(-1, 1), (1, self.d) # type: ignore[arg-type] 

2025 ) 

2026 p = center + np.multiply(x, fr_tiled) 

2027 return p 

2028 

2029 def _hypersphere_surface_sample( 

2030 self, center: np.ndarray, radius: DecimalNumber, 

2031 candidates: IntNumber = 1 

2032 ) -> np.ndarray: 

2033 """Uniform sampling on the hypersphere's surface.""" 

2034 vec = self.rng.standard_normal(size=(candidates, self.d)) 

2035 vec /= np.linalg.norm(vec, axis=1)[:, None] 

2036 p = center + np.multiply(vec, radius) 

2037 return p 

2038 

2039 

2040class MultivariateNormalQMC: 

2041 r"""QMC sampling from a multivariate Normal :math:`N(\mu, \Sigma)`. 

2042 

2043 Parameters 

2044 ---------- 

2045 mean : array_like (d,) 

2046 The mean vector. Where ``d`` is the dimension. 

2047 cov : array_like (d, d), optional 

2048 The covariance matrix. If omitted, use `cov_root` instead. 

2049 If both `cov` and `cov_root` are omitted, use the identity matrix. 

2050 cov_root : array_like (d, d'), optional 

2051 A root decomposition of the covariance matrix, where ``d'`` may be less 

2052 than ``d`` if the covariance is not full rank. If omitted, use `cov`. 

2053 inv_transform : bool, optional 

2054 If True, use inverse transform instead of Box-Muller. Default is True. 

2055 engine : QMCEngine, optional 

2056 Quasi-Monte Carlo engine sampler. If None, `Sobol` is used. 

2057 seed : {None, int, `numpy.random.Generator`}, optional 

2058 Used only if `engine` is None. 

2059 If `seed` is an int or None, a new `numpy.random.Generator` is 

2060 created using ``np.random.default_rng(seed)``. 

2061 If `seed` is already a ``Generator`` instance, then the provided 

2062 instance is used. 

2063 

2064 Examples 

2065 -------- 

2066 >>> import matplotlib.pyplot as plt 

2067 >>> from scipy.stats import qmc 

2068 >>> dist = qmc.MultivariateNormalQMC(mean=[0, 5], cov=[[1, 0], [0, 1]]) 

2069 >>> sample = dist.random(512) 

2070 >>> _ = plt.scatter(sample[:, 0], sample[:, 1]) 

2071 >>> plt.show() 

2072 

2073 """ 

2074 

2075 def __init__( 

2076 self, mean: npt.ArrayLike, cov: npt.ArrayLike | None = None, *, 

2077 cov_root: npt.ArrayLike | None = None, 

2078 inv_transform: bool = True, 

2079 engine: QMCEngine | None = None, 

2080 seed: SeedType = None 

2081 ) -> None: 

2082 mean = np.array(mean, copy=False, ndmin=1) 

2083 d = mean.shape[0] 

2084 if cov is not None: 

2085 # covariance matrix provided 

2086 cov = np.array(cov, copy=False, ndmin=2) 

2087 # check for square/symmetric cov matrix and mean vector has the 

2088 # same d 

2089 if not mean.shape[0] == cov.shape[0]: 

2090 raise ValueError("Dimension mismatch between mean and " 

2091 "covariance.") 

2092 if not np.allclose(cov, cov.transpose()): 

2093 raise ValueError("Covariance matrix is not symmetric.") 

2094 # compute Cholesky decomp; if it fails, do the eigen decomposition 

2095 try: 

2096 cov_root = np.linalg.cholesky(cov).transpose() 

2097 except np.linalg.LinAlgError: 

2098 eigval, eigvec = np.linalg.eigh(cov) 

2099 if not np.all(eigval >= -1.0e-8): 

2100 raise ValueError("Covariance matrix not PSD.") 

2101 eigval = np.clip(eigval, 0.0, None) 

2102 cov_root = (eigvec * np.sqrt(eigval)).transpose() 

2103 elif cov_root is not None: 

2104 # root decomposition provided 

2105 cov_root = np.atleast_2d(cov_root) 

2106 if not mean.shape[0] == cov_root.shape[0]: 

2107 raise ValueError("Dimension mismatch between mean and " 

2108 "covariance.") 

2109 else: 

2110 # corresponds to identity covariance matrix 

2111 cov_root = None 

2112 

2113 self._inv_transform = inv_transform 

2114 

2115 if not inv_transform: 

2116 # to apply Box-Muller, we need an even number of dimensions 

2117 engine_dim = 2 * math.ceil(d / 2) 

2118 else: 

2119 engine_dim = d 

2120 if engine is None: 

2121 self.engine = Sobol( 

2122 d=engine_dim, scramble=True, bits=30, seed=seed 

2123 ) # type: QMCEngine 

2124 elif isinstance(engine, QMCEngine): 

2125 if engine.d != engine_dim: 

2126 raise ValueError("Dimension of `engine` must be consistent" 

2127 " with dimensions of mean and covariance." 

2128 " If `inv_transform` is False, it must be" 

2129 " an even number.") 

2130 self.engine = engine 

2131 else: 

2132 raise ValueError("`engine` must be an instance of " 

2133 "`scipy.stats.qmc.QMCEngine` or `None`.") 

2134 

2135 self._mean = mean 

2136 self._corr_matrix = cov_root 

2137 

2138 self._d = d 

2139 

2140 def random(self, n: IntNumber = 1) -> np.ndarray: 

2141 """Draw `n` QMC samples from the multivariate Normal. 

2142 

2143 Parameters 

2144 ---------- 

2145 n : int, optional 

2146 Number of samples to generate in the parameter space. Default is 1. 

2147 

2148 Returns 

2149 ------- 

2150 sample : array_like (n, d) 

2151 Sample. 

2152 

2153 """ 

2154 base_samples = self._standard_normal_samples(n) 

2155 return self._correlate(base_samples) 

2156 

2157 def _correlate(self, base_samples: np.ndarray) -> np.ndarray: 

2158 if self._corr_matrix is not None: 

2159 return base_samples @ self._corr_matrix + self._mean 

2160 else: 

2161 # avoid multiplying with identity here 

2162 return base_samples + self._mean 

2163 

2164 def _standard_normal_samples(self, n: IntNumber = 1) -> np.ndarray: 

2165 """Draw `n` QMC samples from the standard Normal :math:`N(0, I_d)`. 

2166 

2167 Parameters 

2168 ---------- 

2169 n : int, optional 

2170 Number of samples to generate in the parameter space. Default is 1. 

2171 

2172 Returns 

2173 ------- 

2174 sample : array_like (n, d) 

2175 Sample. 

2176 

2177 """ 

2178 # get base samples 

2179 samples = self.engine.random(n) 

2180 if self._inv_transform: 

2181 # apply inverse transform 

2182 # (values to close to 0/1 result in inf values) 

2183 return stats.norm.ppf(0.5 + (1 - 1e-10) * (samples - 0.5)) # type: ignore[attr-defined] 

2184 else: 

2185 # apply Box-Muller transform (note: indexes starting from 1) 

2186 even = np.arange(0, samples.shape[-1], 2) 

2187 Rs = np.sqrt(-2 * np.log(samples[:, even])) 

2188 thetas = 2 * math.pi * samples[:, 1 + even] 

2189 cos = np.cos(thetas) 

2190 sin = np.sin(thetas) 

2191 transf_samples = np.stack([Rs * cos, Rs * sin], 

2192 -1).reshape(n, -1) 

2193 # make sure we only return the number of dimension requested 

2194 return transf_samples[:, : self._d] 

2195 

2196 

2197class MultinomialQMC: 

2198 r"""QMC sampling from a multinomial distribution. 

2199 

2200 Parameters 

2201 ---------- 

2202 pvals : array_like (k,) 

2203 Vector of probabilities of size ``k``, where ``k`` is the number 

2204 of categories. Elements must be non-negative and sum to 1. 

2205 n_trials : int 

2206 Number of trials. 

2207 engine : QMCEngine, optional 

2208 Quasi-Monte Carlo engine sampler. If None, `Sobol` is used. 

2209 seed : {None, int, `numpy.random.Generator`}, optional 

2210 Used only if `engine` is None. 

2211 If `seed` is an int or None, a new `numpy.random.Generator` is 

2212 created using ``np.random.default_rng(seed)``. 

2213 If `seed` is already a ``Generator`` instance, then the provided 

2214 instance is used. 

2215 

2216 Examples 

2217 -------- 

2218 Let's define 3 categories and for a given sample, the sum of the trials 

2219 of each category is 8. The number of trials per category is determined 

2220 by the `pvals` associated to each category. 

2221 Then, we sample this distribution 64 times. 

2222 

2223 >>> import matplotlib.pyplot as plt 

2224 >>> from scipy.stats import qmc 

2225 >>> dist = qmc.MultinomialQMC( 

2226 ... pvals=[0.2, 0.4, 0.4], n_trials=10, engine=qmc.Halton(d=1) 

2227 ... ) 

2228 >>> sample = dist.random(64) 

2229 

2230 We can plot the sample and verify that the median of number of trials 

2231 for each category is following the `pvals`. That would be 

2232 ``pvals * n_trials = [2, 4, 4]``. 

2233 

2234 >>> fig, ax = plt.subplots() 

2235 >>> ax.yaxis.get_major_locator().set_params(integer=True) 

2236 >>> _ = ax.boxplot(sample) 

2237 >>> ax.set(xlabel="Categories", ylabel="Trials") 

2238 >>> plt.show() 

2239 

2240 """ 

2241 

2242 def __init__( 

2243 self, pvals: npt.ArrayLike, n_trials: IntNumber, 

2244 *, engine: QMCEngine | None = None, 

2245 seed: SeedType = None 

2246 ) -> None: 

2247 self.pvals = np.array(pvals, copy=False, ndmin=1) 

2248 if np.min(pvals) < 0: 

2249 raise ValueError('Elements of pvals must be non-negative.') 

2250 if not np.isclose(np.sum(pvals), 1): 

2251 raise ValueError('Elements of pvals must sum to 1.') 

2252 self.n_trials = n_trials 

2253 if engine is None: 

2254 self.engine = Sobol( 

2255 d=1, scramble=True, bits=30, seed=seed 

2256 ) # type: QMCEngine 

2257 elif isinstance(engine, QMCEngine): 

2258 if engine.d != 1: 

2259 raise ValueError("Dimension of `engine` must be 1.") 

2260 self.engine = engine 

2261 else: 

2262 raise ValueError("`engine` must be an instance of " 

2263 "`scipy.stats.qmc.QMCEngine` or `None`.") 

2264 

2265 def random(self, n: IntNumber = 1) -> np.ndarray: 

2266 """Draw `n` QMC samples from the multinomial distribution. 

2267 

2268 Parameters 

2269 ---------- 

2270 n : int, optional 

2271 Number of samples to generate in the parameter space. Default is 1. 

2272 

2273 Returns 

2274 ------- 

2275 samples : array_like (n, pvals) 

2276 Sample. 

2277 

2278 """ 

2279 sample = np.empty((n, len(self.pvals))) 

2280 for i in range(n): 

2281 base_draws = self.engine.random(self.n_trials).ravel() 

2282 p_cumulative = np.empty_like(self.pvals, dtype=float) 

2283 _fill_p_cumulative(np.array(self.pvals, dtype=float), p_cumulative) 

2284 sample_ = np.zeros_like(self.pvals, dtype=int) 

2285 _categorize(base_draws, p_cumulative, sample_) 

2286 sample[i] = sample_ 

2287 return sample 

2288 

2289 

2290def _select_optimizer( 

2291 optimization: Literal["random-cd", "lloyd"] | None, config: dict 

2292) -> Callable | None: 

2293 """A factory for optimization methods.""" 

2294 optimization_method: dict[str, Callable] = { 

2295 "random-cd": _random_cd, 

2296 "lloyd": _lloyd_centroidal_voronoi_tessellation 

2297 } 

2298 

2299 optimizer: partial | None 

2300 if optimization is not None: 

2301 try: 

2302 optimization = optimization.lower() # type: ignore[assignment] 

2303 optimizer_ = optimization_method[optimization] 

2304 except KeyError as exc: 

2305 message = (f"{optimization!r} is not a valid optimization" 

2306 f" method. It must be one of" 

2307 f" {set(optimization_method)!r}") 

2308 raise ValueError(message) from exc 

2309 

2310 # config 

2311 optimizer = partial(optimizer_, **config) 

2312 else: 

2313 optimizer = None 

2314 

2315 return optimizer 

2316 

2317 

2318def _random_cd( 

2319 best_sample: np.ndarray, n_iters: int, n_nochange: int, rng: GeneratorType, 

2320 **kwargs: dict 

2321) -> np.ndarray: 

2322 """Optimal LHS on CD. 

2323 

2324 Create a base LHS and do random permutations of coordinates to 

2325 lower the centered discrepancy. 

2326 Because it starts with a normal LHS, it also works with the 

2327 `centered` keyword argument. 

2328 

2329 Two stopping criterion are used to stop the algorithm: at most, 

2330 `n_iters` iterations are performed; or if there is no improvement 

2331 for `n_nochange` consecutive iterations. 

2332 """ 

2333 del kwargs # only use keywords which are defined, needed by factory 

2334 

2335 n, d = best_sample.shape 

2336 

2337 if d == 0 or n == 0: 

2338 return np.empty((n, d)) 

2339 

2340 if d == 1 or n == 1: 

2341 # discrepancy measures are invariant under permuting factors and runs 

2342 return best_sample 

2343 

2344 best_disc = discrepancy(best_sample) 

2345 

2346 bounds = ([0, d - 1], 

2347 [0, n - 1], 

2348 [0, n - 1]) 

2349 

2350 n_nochange_ = 0 

2351 n_iters_ = 0 

2352 while n_nochange_ < n_nochange and n_iters_ < n_iters: 

2353 n_iters_ += 1 

2354 

2355 col = rng_integers(rng, *bounds[0], endpoint=True) # type: ignore[misc] 

2356 row_1 = rng_integers(rng, *bounds[1], endpoint=True) # type: ignore[misc] 

2357 row_2 = rng_integers(rng, *bounds[2], endpoint=True) # type: ignore[misc] 

2358 disc = _perturb_discrepancy(best_sample, 

2359 row_1, row_2, col, 

2360 best_disc) 

2361 if disc < best_disc: 

2362 best_sample[row_1, col], best_sample[row_2, col] = ( 

2363 best_sample[row_2, col], best_sample[row_1, col]) 

2364 

2365 best_disc = disc 

2366 n_nochange_ = 0 

2367 else: 

2368 n_nochange_ += 1 

2369 

2370 return best_sample 

2371 

2372 

2373def _l1_norm(sample: np.ndarray) -> float: 

2374 return distance.pdist(sample, 'cityblock').min() 

2375 

2376 

2377def _lloyd_iteration( 

2378 sample: np.ndarray, 

2379 decay: float, 

2380 qhull_options: str 

2381) -> np.ndarray: 

2382 """Lloyd-Max algorithm iteration. 

2383 

2384 Based on the implementation of Stéfan van der Walt: 

2385 

2386 https://github.com/stefanv/lloyd 

2387 

2388 which is: 

2389 

2390 Copyright (c) 2021-04-21 Stéfan van der Walt 

2391 https://github.com/stefanv/lloyd 

2392 MIT License 

2393 

2394 Parameters 

2395 ---------- 

2396 sample : array_like (n, d) 

2397 The sample to iterate on. 

2398 decay : float 

2399 Relaxation decay. A positive value would move the samples toward 

2400 their centroid, and negative value would move them away. 

2401 1 would move the samples to their centroid. 

2402 qhull_options : str 

2403 Additional options to pass to Qhull. See Qhull manual 

2404 for details. (Default: "Qbb Qc Qz Qj Qx" for ndim > 4 and 

2405 "Qbb Qc Qz Qj" otherwise.) 

2406 

2407 Returns 

2408 ------- 

2409 sample : array_like (n, d) 

2410 The sample after an iteration of Lloyd's algorithm. 

2411 

2412 """ 

2413 new_sample = np.empty_like(sample) 

2414 

2415 voronoi = Voronoi(sample, qhull_options=qhull_options) 

2416 

2417 for ii, idx in enumerate(voronoi.point_region): 

2418 # the region is a series of indices into self.voronoi.vertices 

2419 # remove samples at infinity, designated by index -1 

2420 region = [i for i in voronoi.regions[idx] if i != -1] 

2421 

2422 # get the vertices for this region 

2423 verts = voronoi.vertices[region] 

2424 

2425 # clipping would be wrong, we need to intersect 

2426 # verts = np.clip(verts, 0, 1) 

2427 

2428 # move samples towards centroids: 

2429 # Centroid in n-D is the mean for uniformly distributed nodes 

2430 # of a geometry. 

2431 centroid = np.mean(verts, axis=0) 

2432 new_sample[ii] = sample[ii] + (centroid - sample[ii]) * decay 

2433 

2434 # only update sample to centroid within the region 

2435 is_valid = np.all(np.logical_and(new_sample >= 0, new_sample <= 1), axis=1) 

2436 sample[is_valid] = new_sample[is_valid] 

2437 

2438 return sample 

2439 

2440 

2441def _lloyd_centroidal_voronoi_tessellation( 

2442 sample: npt.ArrayLike, 

2443 *, 

2444 tol: DecimalNumber = 1e-5, 

2445 maxiter: IntNumber = 10, 

2446 qhull_options: str | None = None, 

2447 **kwargs: dict 

2448) -> np.ndarray: 

2449 """Approximate Centroidal Voronoi Tessellation. 

2450 

2451 Perturb samples in N-dimensions using Lloyd-Max algorithm. 

2452 

2453 Parameters 

2454 ---------- 

2455 sample : array_like (n, d) 

2456 The sample to iterate on. With ``n`` the number of samples and ``d`` 

2457 the dimension. Samples must be in :math:`[0, 1]^d`, with ``d>=2``. 

2458 tol : float, optional 

2459 Tolerance for termination. If the min of the L1-norm over the samples 

2460 changes less than `tol`, it stops the algorithm. Default is 1e-5. 

2461 maxiter : int, optional 

2462 Maximum number of iterations. It will stop the algorithm even if 

2463 `tol` is above the threshold. 

2464 Too many iterations tend to cluster the samples as a hypersphere. 

2465 Default is 10. 

2466 qhull_options : str, optional 

2467 Additional options to pass to Qhull. See Qhull manual 

2468 for details. (Default: "Qbb Qc Qz Qj Qx" for ndim > 4 and 

2469 "Qbb Qc Qz Qj" otherwise.) 

2470 

2471 Returns 

2472 ------- 

2473 sample : array_like (n, d) 

2474 The sample after being processed by Lloyd-Max algorithm. 

2475 

2476 Notes 

2477 ----- 

2478 Lloyd-Max algorithm is an iterative process with the purpose of improving 

2479 the dispersion of samples. For given sample: (i) compute a Voronoi 

2480 Tessellation; (ii) find the centroid of each Voronoi cell; (iii) move the 

2481 samples toward the centroid of their respective cell. See [1]_, [2]_. 

2482 

2483 A relaxation factor is used to control how fast samples can move at each 

2484 iteration. This factor is starting at 2 and ending at 1 after `maxiter` 

2485 following an exponential decay. 

2486 

2487 The process converges to equally spaced samples. It implies that measures 

2488 like the discrepancy could suffer from too many iterations. On the other 

2489 hand, L1 and L2 distances should improve. This is especially true with 

2490 QMC methods which tend to favor the discrepancy over other criteria. 

2491 

2492 .. note:: 

2493 

2494 The current implementation does not intersect the Voronoi Tessellation 

2495 with the boundaries. This implies that for a low number of samples, 

2496 empirically below 20, no Voronoi cell is touching the boundaries. 

2497 Hence, samples cannot be moved close to the boundaries. 

2498 

2499 Further improvements could consider the samples at infinity so that 

2500 all boundaries are segments of some Voronoi cells. This would fix 

2501 the computation of the centroid position. 

2502 

2503 .. warning:: 

2504 

2505 The Voronoi Tessellation step is expensive and quickly becomes 

2506 intractable with dimensions as low as 10 even for a sample 

2507 of size as low as 1000. 

2508 

2509 .. versionadded:: 1.9.0 

2510 

2511 References 

2512 ---------- 

2513 .. [1] Lloyd. "Least Squares Quantization in PCM". 

2514 IEEE Transactions on Information Theory, 1982. 

2515 .. [2] Max J. "Quantizing for minimum distortion". 

2516 IEEE Transactions on Information Theory, 1960. 

2517 

2518 Examples 

2519 -------- 

2520 >>> import numpy as np 

2521 >>> from scipy.spatial import distance 

2522 >>> rng = np.random.default_rng() 

2523 >>> sample = rng.random((128, 2)) 

2524 

2525 .. note:: 

2526 

2527 The samples need to be in :math:`[0, 1]^d`. `scipy.stats.qmc.scale` 

2528 can be used to scale the samples from their 

2529 original bounds to :math:`[0, 1]^d`. And back to their original bounds. 

2530 

2531 Compute the quality of the sample using the L1 criterion. 

2532 

2533 >>> def l1_norm(sample): 

2534 ... return distance.pdist(sample, 'cityblock').min() 

2535 

2536 >>> l1_norm(sample) 

2537 0.00161... # random 

2538 

2539 Now process the sample using Lloyd's algorithm and check the improvement 

2540 on the L1. The value should increase. 

2541 

2542 >>> sample = _lloyd_centroidal_voronoi_tessellation(sample) 

2543 >>> l1_norm(sample) 

2544 0.0278... # random 

2545 

2546 """ 

2547 del kwargs # only use keywords which are defined, needed by factory 

2548 

2549 sample = np.asarray(sample).copy() 

2550 

2551 if not sample.ndim == 2: 

2552 raise ValueError('`sample` is not a 2D array') 

2553 

2554 if not sample.shape[1] >= 2: 

2555 raise ValueError('`sample` dimension is not >= 2') 

2556 

2557 # Checking that sample is within the hypercube 

2558 if (sample.max() > 1.) or (sample.min() < 0.): 

2559 raise ValueError('`sample` is not in unit hypercube') 

2560 

2561 if qhull_options is None: 

2562 qhull_options = 'Qbb Qc Qz QJ' 

2563 

2564 if sample.shape[1] >= 5: 

2565 qhull_options += ' Qx' 

2566 

2567 # Fit an exponential to be 2 at 0 and 1 at `maxiter`. 

2568 # The decay is used for relaxation. 

2569 # analytical solution for y=exp(-maxiter/x) - 0.1 

2570 root = -maxiter / np.log(0.1) 

2571 decay = [np.exp(-x / root)+0.9 for x in range(maxiter)] 

2572 

2573 l1_old = _l1_norm(sample=sample) 

2574 for i in range(maxiter): 

2575 sample = _lloyd_iteration( 

2576 sample=sample, decay=decay[i], 

2577 qhull_options=qhull_options, 

2578 ) 

2579 

2580 l1_new = _l1_norm(sample=sample) 

2581 

2582 if abs(l1_new - l1_old) < tol: 

2583 break 

2584 else: 

2585 l1_old = l1_new 

2586 

2587 return sample 

2588 

2589 

2590def _validate_workers(workers: IntNumber = 1) -> IntNumber: 

2591 """Validate `workers` based on platform and value. 

2592 

2593 Parameters 

2594 ---------- 

2595 workers : int, optional 

2596 Number of workers to use for parallel processing. If -1 is 

2597 given all CPU threads are used. Default is 1. 

2598 

2599 Returns 

2600 ------- 

2601 Workers : int 

2602 Number of CPU used by the algorithm 

2603 

2604 """ 

2605 workers = int(workers) 

2606 if workers == -1: 

2607 workers = os.cpu_count() # type: ignore[assignment] 

2608 if workers is None: 

2609 raise NotImplementedError( 

2610 "Cannot determine the number of cpus using os.cpu_count(), " 

2611 "cannot use -1 for the number of workers" 

2612 ) 

2613 elif workers <= 0: 

2614 raise ValueError(f"Invalid number of workers: {workers}, must be -1 " 

2615 "or > 0") 

2616 

2617 return workers 

2618 

2619 

2620def _validate_bounds( 

2621 l_bounds: npt.ArrayLike, u_bounds: npt.ArrayLike, d: int 

2622) -> tuple[np.ndarray, ...]: 

2623 """Bounds input validation. 

2624 

2625 Parameters 

2626 ---------- 

2627 l_bounds, u_bounds : array_like (d,) 

2628 Lower and upper bounds. 

2629 d : int 

2630 Dimension to use for broadcasting. 

2631 

2632 Returns 

2633 ------- 

2634 l_bounds, u_bounds : array_like (d,) 

2635 Lower and upper bounds. 

2636 

2637 """ 

2638 try: 

2639 lower = np.broadcast_to(l_bounds, d) 

2640 upper = np.broadcast_to(u_bounds, d) 

2641 except ValueError as exc: 

2642 msg = ("'l_bounds' and 'u_bounds' must be broadcastable and respect" 

2643 " the sample dimension") 

2644 raise ValueError(msg) from exc 

2645 

2646 if not np.all(lower < upper): 

2647 raise ValueError("Bounds are not consistent 'l_bounds' < 'u_bounds'") 

2648 

2649 return lower, upper