Skip to main content

secs_dsp/optimize/
de.rs

1//! Port of SciPy 1.18.1 `scipy/optimize/_differentialevolution.py`.
2//! Copyright SciPy Developers, BSD-3-Clause (see the SciPy notice in
3//! crates/secs-dsp/THIRD_PARTY_LICENSES.txt).
4//! Only SECS 2's unconstrained, serial, immediate best1bin path is supported.
5
6use crate::{DspError, invalid, numeric::sum, random::RandomState};
7
8/// Options used by SECS 2 bass alignment. Remaining SciPy options are fixed:
9/// best1bin, mutation=(0.5, 1), recombination=0.7, Latin hypercube, atol=0,
10/// immediate updating, one worker, no polishing/constraints/integrality.
11#[derive(Debug, Clone, Copy)]
12pub struct DifferentialEvolutionOptions {
13    /// Number of generations after the initial population evaluation.
14    pub maxiter: usize,
15    /// Population multiplier per non-fixed dimension (minimum population five).
16    pub popsize: usize,
17    /// Relative population-energy standard-deviation tolerance.
18    pub tol: f64,
19    /// Legacy RandomState integer seed, not a Generator seed.
20    pub seed: u32,
21}
22
23impl Default for DifferentialEvolutionOptions {
24    fn default() -> Self {
25        Self {
26            maxiter: 60,
27            popsize: 12,
28            tol: 1e-6,
29            seed: 1,
30        }
31    }
32}
33
34/// SciPy's unpolished differential-evolution result.
35#[derive(Debug, Clone)]
36pub struct DifferentialEvolutionResult {
37    pub x: Vec<f64>,
38    pub fun: f64,
39    pub nit: usize,
40    pub nfev: usize,
41    pub success: bool,
42    pub message: String,
43}
44
45/// Minimize a scalar cost with the exact random consumption of SciPy 1.18.1
46/// `differential_evolution(..., seed=integer, polish=False)` in the BA configuration.
47///
48/// Bounds must contain finite pairs and at least one dimension; equal and reversed
49/// pairs retain SciPy's scaling semantics. `x0`, when supplied, replaces the first
50/// LHS member *after* all initialization draws. Its length must match the bounds.
51/// Nonfinite objective values are not repaired: comparisons, NaN promotion, and
52/// all-infinite population reevaluation follow SciPy. Negative/NaN tolerances
53/// likewise simply prevent convergence. Allocation/count overflow is a programmer
54/// error, as with the other DSP array APIs.
55///
56/// Returns an error for malformed bounds or an out-of-bounds initial point.
57/// The closure is called serially in population order, including every trial.
58pub fn differential_evolution<F>(
59    mut cost: F,
60    bounds: &[(f64, f64)],
61    x0: Option<&[f64]>,
62    options: DifferentialEvolutionOptions,
63) -> Result<DifferentialEvolutionResult, DspError>
64where
65    F: FnMut(&[f64]) -> f64,
66{
67    let n = bounds.len();
68    if n == 0 || bounds.iter().any(|(a, b)| !a.is_finite() || !b.is_finite()) {
69        return Err(invalid("bounds must contain finite real valued pairs"));
70    }
71    if x0.is_some_and(|x| x.len() != n) {
72        return Err(invalid("x0 length must match bounds"));
73    }
74    let center: Vec<_> = bounds.iter().map(|(a, b)| 0.5 * (a + b)).collect();
75    let width: Vec<_> = bounds.iter().map(|(a, b)| (a - b).abs()).collect();
76    let varying = bounds.iter().filter(|(a, b)| a != b).count().max(1);
77    let members = options
78        .popsize
79        .checked_mul(varying)
80        .expect("population size overflow")
81        .max(5);
82    members.checked_mul(n).expect("population shape overflow");
83    let mut rng = RandomState::new(options.seed);
84    let segment = 1.0 / members as f64;
85    // np.linspace(endpoint=False) multiplies arange by the step (not i / members).
86    let samples: Vec<Vec<_>> = (0..members)
87        .map(|i| {
88            (0..n)
89                .map(|_| segment * rng.random_sample() + i as f64 * segment)
90                .collect()
91        })
92        .collect();
93    let mut population = vec![vec![0.0; n]; members];
94    for j in 0..n {
95        for (i, source) in rng.permutation(members).into_iter().enumerate() {
96            population[i][j] = samples[source][j];
97        }
98    }
99    if let Some(x0) = x0 {
100        for j in 0..n {
101            let reciprocal = 1.0 / width[j];
102            let reciprocal = if reciprocal.is_finite() {
103                reciprocal
104            } else {
105                0.0
106            };
107            let value = (x0[j] - center[j]) * reciprocal + 0.5;
108            if value > 1.0 || value < 0.0 {
109                return Err(invalid(
110                    "Some entries in x0 lay outside the specified bounds",
111                ));
112            }
113            population[0][j] = value;
114        }
115    }
116    let scale = |unit: &[f64]| -> Vec<f64> {
117        unit.iter()
118            .enumerate()
119            .map(|(j, x)| center[j] + (x - 0.5) * width[j])
120            .collect()
121    };
122    let mut energies: Vec<_> = population.iter().map(|x| cost(&scale(x))).collect();
123    let mut nfev = members;
124    promote(&mut population, &mut energies);
125    let mut indices: Vec<_> = (0..members).collect();
126    let mut nit = 0;
127    let mut success = false;
128    for generation in 1..=options.maxiter {
129        nit = generation;
130        // __next__ repeats initial evaluation whenever every energy is infinite.
131        if energies.iter().all(|x| x.is_infinite()) {
132            for (energy, x) in energies.iter_mut().zip(&population) {
133                *energy = cost(&scale(x));
134                nfev += 1;
135            }
136            promote(&mut population, &mut energies);
137        }
138        let mutation = rng.uniform(0.5, 1.0)?;
139        for candidate in 0..members {
140            let fill = rng.randint(n)?;
141            // _select_samples shuffles the SAME index array each time, and only
142            // then excludes candidate. best1 uses the first two of its five.
143            rng.shuffle(&mut indices);
144            let mut selected = indices.iter().copied().filter(|&i| i != candidate);
145            let r0 = selected.next().unwrap();
146            let r1 = selected.next().unwrap();
147            let mut trial = population[candidate].clone();
148            for j in 0..n {
149                let crossover = rng.random_sample() < 0.7;
150                if crossover || j == fill {
151                    trial[j] =
152                        population[0][j] + mutation * (population[r0][j] - population[r1][j]);
153                }
154            }
155            // Draw only after every crossover draw, and in ascending coordinate order.
156            for value in &mut trial {
157                if *value > 1.0 || *value < 0.0 {
158                    *value = rng.random_sample();
159                }
160            }
161            let energy = cost(&scale(&trial));
162            nfev += 1;
163            if energy <= energies[candidate] {
164                population[candidate] = trial;
165                energies[candidate] = energy;
166                if energy <= energies[0] {
167                    // argmin's first-tie rule matters for plateaus; do not just
168                    // swap the current candidate into the best slot.
169                    promote(&mut population, &mut energies);
170                }
171            }
172        }
173        if converged(&energies, options.tol) {
174            success = true;
175            break;
176        }
177    }
178    Ok(DifferentialEvolutionResult {
179        x: scale(&population[0]),
180        fun: energies[0],
181        nit,
182        nfev,
183        success,
184        message: if success {
185            "Optimization terminated successfully."
186        } else {
187            "Maximum number of iterations has been exceeded."
188        }
189        .to_owned(),
190    })
191}
192
193fn promote(population: &mut [Vec<f64>], energies: &mut [f64]) {
194    let mut best = 0;
195    for i in 1..energies.len() {
196        // np.argmin chooses the first NaN and otherwise the first minimum.
197        if energies[best].is_nan() {
198            break;
199        }
200        if energies[i].is_nan() || energies[i] < energies[best] {
201            best = i;
202        }
203    }
204    energies.swap(0, best);
205    population.swap(0, best);
206}
207
208fn converged(energies: &[f64], tol: f64) -> bool {
209    if energies.iter().any(|x| x.is_infinite()) {
210        return false;
211    }
212    let mean = sum(energies) / energies.len() as f64;
213    let squares: Vec<_> = energies
214        .iter()
215        .map(|e| {
216            let d = e - mean;
217            d * d
218        })
219        .collect();
220    (sum(&squares) / energies.len() as f64).sqrt() <= tol * mean.abs()
221}