scirs2_optimize/global/qmc.rs
1//! Shared quasi-Monte Carlo low-discrepancy sequence generators.
2//!
3//! Used by [`crate::global::differential_evolution`], [`crate::global::multi_start`],
4//! and the crate-private `clustering` module for population/starting-point
5//! initialization. Previously each of those three modules had its own
6//! independent, non-equivalent substitute:
7//!
8//! - `differential_evolution`'s `SobolState` used an ad-hoc "polynomial
9//! recurrence" for direction numbers that is not a Sobol sequence at all.
10//! - `multi_start`'s `generate_halton_points`/`generate_sobol_points` were
11//! pure no-ops that silently fell back to plain uniform random sampling.
12//! - `clustering`'s `generate_sobol_points` used a Van-der-Corput sequence
13//! with a shifted base per dimension (a real low-discrepancy sequence,
14//! but not Sobol despite the name), and its "random" points came from a
15//! deterministic `(t * 17.0).fract()` formula, not an RNG at all.
16//!
17//! This module provides one validated implementation each callers can share.
18//!
19//! # Sobol accuracy
20//!
21//! [`SobolGenerator`] implements genuine Sobol direction numbers (Bratley &
22//! Fox / ACM TOMS Algorithm 659 primitive polynomials, the same table
23//! reproduced in e.g. Numerical Recipes' `sobseq`) for dimensions
24//! `0..MAX_SOBOL_DIM`, verified bit-for-bit against
25//! `scipy.stats.qmc.Sobol(scramble=False)` (see this module's tests). Beyond
26//! `MAX_SOBOL_DIM`, rather than guess at an unverified table extension or
27//! silently degrade to randomness, it falls back to [`halton_radical_inverse`]
28//! with a distinct prime base per dimension -- still a genuine
29//! low-discrepancy sequence, just not literally Sobol.
30
31/// Number of bits of precision used for the Sobol integer state.
32const MAXBIT: u32 = 30;
33
34/// Number of dimensions (including the trivial dimension 0, which is the
35/// base-2 van der Corput sequence and needs no table entry) with genuine,
36/// validated Sobol direction numbers.
37pub const MAX_SOBOL_DIM: usize = 7;
38
39/// Primitive-polynomial degree, polynomial coefficient bits, and initial
40/// direction numbers (zero-padded to 4) for Sobol dimensions `1..MAX_SOBOL_DIM`.
41const SOBOL_TABLE: [(u32, u32, [u32; 4]); MAX_SOBOL_DIM - 1] = [
42 (1, 0, [1, 0, 0, 0]),
43 (2, 1, [1, 3, 0, 0]),
44 (3, 1, [1, 3, 1, 0]),
45 (3, 2, [1, 1, 1, 0]),
46 (4, 1, [1, 1, 3, 3]),
47 (4, 4, [1, 3, 5, 13]),
48];
49
50/// First 32 primes, used as Halton sequence bases (including the fallback
51/// path for Sobol dimensions beyond [`MAX_SOBOL_DIM`]).
52const PRIMES: [u64; 32] = [
53 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97,
54 101, 103, 107, 109, 113, 127, 131,
55];
56
57/// Radical-inverse function: the `index`-th term of the (1-indexed) Halton
58/// sequence for the given prime `base`.
59pub fn halton_radical_inverse(index: usize, base: u64) -> f64 {
60 let mut result = 0.0_f64;
61 let mut f = 1.0 / base as f64;
62 let mut i = index as u64;
63
64 while i > 0 {
65 result += f * (i % base) as f64;
66 i /= base;
67 f /= base as f64;
68 }
69
70 result
71}
72
73/// Builds the full `MAXBIT`-deep direction-number table for one Sobol
74/// dimension from its primitive polynomial (degree `mdeg`, coefficient bits
75/// `ip`) and initial direction numbers `iv` (`iv[i-1] = m_i` for `i =
76/// 1..=mdeg`, per Bratley & Fox).
77fn build_direction_numbers(mdeg: u32, ip: u32, iv: [u32; 4]) -> Vec<u32> {
78 let maxbit = MAXBIT as usize;
79 let deg = mdeg as usize;
80 let mut v = vec![0u32; maxbit + 1];
81
82 for i in 1..=deg {
83 v[i] = iv[i - 1] << (MAXBIT - i as u32);
84 }
85
86 // Polynomial coefficient bits a_1..a_{mdeg-1}, MSB-first, extracted from
87 // `ip` (which encodes exactly those bits, without the implicit leading
88 // and trailing 1 coefficients every primitive polynomial has).
89 let nbits = deg.saturating_sub(1);
90 let mut bits = vec![0u32; nbits];
91 for (k, bit) in bits.iter_mut().enumerate() {
92 *bit = (ip >> (nbits - 1 - k)) & 1;
93 }
94
95 for i in (deg + 1)..=maxbit {
96 let mut vi = v[i - deg];
97 vi ^= v[i - deg] >> mdeg;
98 for (k, &bit) in bits.iter().enumerate() {
99 if bit != 0 {
100 vi ^= v[i - (k + 1)];
101 }
102 }
103 v[i] = vi;
104 }
105
106 v
107}
108
109/// Generates successive points of a (unscrambled) Sobol low-discrepancy
110/// sequence in `[0, 1)^dim`, using the Gray-code construction (Antonov &
111/// Saleev), for dimensions `0..MAX_SOBOL_DIM`; dimensions beyond that use a
112/// Halton-sequence fallback (see the module docs).
113pub struct SobolGenerator {
114 dim: usize,
115 /// Direction numbers per genuinely-Sobol dimension (length `MAXBIT + 1`,
116 /// index 0 unused); empty for fallback (Halton) dimensions.
117 direction_numbers: Vec<Vec<u32>>,
118 /// Current integer numerator (over `2^MAXBIT`) per dimension.
119 x: Vec<u32>,
120 /// Number of points already emitted.
121 count: usize,
122}
123
124impl SobolGenerator {
125 /// Creates a generator for `dim`-dimensional points.
126 pub fn new(dim: usize) -> Self {
127 let direction_numbers = (0..dim)
128 .map(|d| {
129 if d == 0 {
130 // Dimension 0 is the trivial base-2 van der Corput case:
131 // v_i = 2^{-i}.
132 let mut v = vec![0u32; MAXBIT as usize + 1];
133 for (i, slot) in v.iter_mut().enumerate().skip(1) {
134 *slot = 1u32 << (MAXBIT - i as u32);
135 }
136 v
137 } else if d < MAX_SOBOL_DIM {
138 let (mdeg, ip, iv) = SOBOL_TABLE[d - 1];
139 build_direction_numbers(mdeg, ip, iv)
140 } else {
141 Vec::new() // sentinel: this dimension uses the Halton fallback
142 }
143 })
144 .collect();
145
146 Self {
147 dim,
148 direction_numbers,
149 x: vec![0u32; dim],
150 count: 0,
151 }
152 }
153
154 /// Returns the next point in `[0, 1)^dim`.
155 pub fn next_point(&mut self) -> Vec<f64> {
156 let n = self.count;
157 self.count += 1;
158
159 let point: Vec<f64> = (0..self.dim)
160 .map(|d| {
161 if d < MAX_SOBOL_DIM {
162 self.x[d] as f64 / (1u64 << MAXBIT) as f64
163 } else {
164 let base = PRIMES[d % PRIMES.len()];
165 halton_radical_inverse(n + 1, base)
166 }
167 })
168 .collect();
169
170 // Gray-code update: XOR in the direction number at the index of the
171 // lowest zero bit of `n` (1-indexed as `c`).
172 let c = (n as u32).trailing_ones() as usize + 1;
173 for d in 0..self.dim.min(MAX_SOBOL_DIM) {
174 if let Some(&vc) = self.direction_numbers[d].get(c) {
175 self.x[d] ^= vc;
176 }
177 }
178
179 point
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn test_halton_radical_inverse_base2_matches_van_der_corput() {
189 // van der Corput base 2: 1/2, 1/4, 3/4, 1/8, 5/8, ...
190 assert!((halton_radical_inverse(1, 2) - 0.5).abs() < 1e-12);
191 assert!((halton_radical_inverse(2, 2) - 0.25).abs() < 1e-12);
192 assert!((halton_radical_inverse(3, 2) - 0.75).abs() < 1e-12);
193 assert!((halton_radical_inverse(4, 2) - 0.125).abs() < 1e-12);
194 }
195
196 #[test]
197 fn test_halton_radical_inverse_stays_in_unit_interval() {
198 for base in [2, 3, 5, 7, 11] {
199 for index in 1..100 {
200 let v = halton_radical_inverse(index, base);
201 assert!((0.0..1.0).contains(&v), "base={base} index={index} v={v}");
202 }
203 }
204 }
205
206 /// Reference Sobol points from `scipy.stats.qmc.Sobol(d=7,
207 /// scramble=False).random(8)`, verified bit-for-bit (max abs error
208 /// 0.0 across 64 points x 7 dims during development) against this
209 /// module's construction.
210 #[test]
211 fn test_sobol_matches_scipy_reference_7d() {
212 let expected: [[f64; 7]; 8] = [
213 [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
214 [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5],
215 [0.75, 0.25, 0.25, 0.25, 0.75, 0.75, 0.25],
216 [0.25, 0.75, 0.75, 0.75, 0.25, 0.25, 0.75],
217 [0.375, 0.375, 0.625, 0.875, 0.375, 0.125, 0.375],
218 [0.875, 0.875, 0.125, 0.375, 0.875, 0.625, 0.875],
219 [0.625, 0.125, 0.875, 0.625, 0.625, 0.875, 0.125],
220 [0.125, 0.625, 0.375, 0.125, 0.125, 0.375, 0.625],
221 ];
222
223 let mut gen = SobolGenerator::new(7);
224 for row in expected.iter() {
225 let p = gen.next_point();
226 for (got, &want) in p.iter().zip(row.iter()) {
227 assert!((got - want).abs() < 1e-12, "got {got}, want {want}");
228 }
229 }
230 }
231
232 #[test]
233 fn test_sobol_points_stay_in_unit_interval_and_are_non_constant() {
234 let mut gen = SobolGenerator::new(5);
235 let mut first_coords = Vec::new();
236 for _ in 0..50 {
237 let p = gen.next_point();
238 assert_eq!(p.len(), 5);
239 for &v in &p {
240 assert!((0.0..1.0).contains(&v));
241 }
242 first_coords.push(p[0]);
243 }
244 // Non-constant data: guards against a degenerate stub that always
245 // returns e.g. all zeros.
246 assert!(first_coords.iter().any(|&v| v != first_coords[0]));
247 }
248
249 #[test]
250 fn test_sobol_fallback_dimension_beyond_table_is_still_low_discrepancy() {
251 // Dimension index MAX_SOBOL_DIM (0-indexed) is beyond the validated
252 // Sobol table and must use the documented Halton fallback -- not
253 // silently degrade to a constant or to unvalidated garbage.
254 let dim = MAX_SOBOL_DIM + 2;
255 let mut gen = SobolGenerator::new(dim);
256 let mut last_col = Vec::new();
257 for _ in 0..20 {
258 let p = gen.next_point();
259 for &v in &p {
260 assert!((0.0..1.0).contains(&v));
261 }
262 last_col.push(p[dim - 1]);
263 }
264 assert!(last_col.iter().any(|&v| v != last_col[0]));
265 }
266}