Skip to main content

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}