Skip to main content

fcmaes_core/
de.rs

1//! Differential Evolution (DE) for bounded, derivative-free optimization.
2//!
3//! This implementation uses DE/best/1 with fcmaes extensions: temporal locality
4//! (an extra
5//! improvement trial along the previous move), age-based reinitialization of
6//! stale individuals, oscillating `F`/`CR` between generations, optional
7//! normal-distributed sampling around a guess, and mixed-integer "modify"
8//! resampling. It is implemented entirely in Rust; historical implementations
9//! informed compatibility testing, not the public API.
10//!
11//! # Reference
12//!
13//! R. Storn and K. Price, [“Differential Evolution – A Simple and Efficient
14//! Heuristic for Global Optimization over Continuous
15//! Spaces”](https://doi.org/10.1023/A:1008202821328), *Journal of Global
16//! Optimization* 11, 341–359 (1997).
17//!
18//! # Example
19//!
20//! ```
21//! use fcmaes_core::{De, DeParams, Fitness};
22//!
23//! let sphere = |x: &[f64]| x.iter().map(|v| v * v).sum::<f64>();
24//! let fit = Fitness::bounded(3, 1, &[-5.0; 3], &[5.0; 3]);
25//! let params = DeParams {
26//!     max_evaluations: 2_000,
27//!     seed: 42,
28//!     ..Default::default()
29//! };
30//! let mut de = De::new(fit, &[], &[], None, &params);
31//! let result = de.optimize(&sphere);
32//! assert!(result.y.is_finite());
33//! ```
34
35use std::collections::VecDeque;
36
37use crate::fitness::{Fitness, Objective};
38use crate::rng::Rng;
39
40/// Outcome of a Differential Evolution run.
41#[derive(Clone, Debug)]
42pub struct DeResult {
43    /// Best decoded decision vector found.
44    pub x: Vec<f64>,
45    /// Objective value at [`x`](Self::x).
46    pub y: f64,
47    /// Number of objective evaluations charged to the run.
48    pub evaluations: u64,
49    /// Number of completed generations.
50    pub iterations: i32,
51    /// Termination code; `1` means `stop_fitness` was reached.
52    pub stop: i32,
53}
54
55/// Tunable inputs for [`De::new`]. Non-positive values select DE defaults.
56#[derive(Clone, Debug)]
57pub struct DeParams {
58    /// Population size; non-positive values select the dimension-dependent default.
59    pub popsize: i32,
60    /// Maximum number of objective evaluations.
61    pub max_evaluations: u64,
62    /// Expected lifetime controlling age-based population reinitialization.
63    pub keep: f64,
64    /// Stop after finding an objective value strictly below this threshold.
65    pub stop_fitness: f64,
66    /// Base differential weight `F`.
67    pub f: f64,
68    /// Base crossover probability `CR`.
69    pub cr: f64,
70    /// Minimum probability used by mixed-integer mutation.
71    pub min_mutate: f64,
72    /// Maximum probability used by mixed-integer mutation.
73    pub max_mutate: f64,
74    /// Lower limit for normal-sampling step sizes.
75    pub min_sigma: f64,
76    /// Seed for the optimizer's independent random stream.
77    pub seed: u64,
78    /// Additional run identifier mixed into [`seed`](Self::seed).
79    pub runid: i64,
80}
81
82impl Default for DeParams {
83    fn default() -> Self {
84        Self {
85            popsize: 31,
86            max_evaluations: 100_000,
87            keep: 200.0,
88            stop_fitness: f64::NEG_INFINITY,
89            f: 0.5,
90            cr: 0.9,
91            min_mutate: 0.1,
92            max_mutate: 0.5,
93            min_sigma: 0.0,
94            seed: 0,
95            runid: 0,
96        }
97    }
98}
99
100/// Stateful Differential Evolution optimizer.
101///
102/// Use [`optimize`](Self::optimize) for an in-process objective or
103/// [`ask`](Self::ask)/[`tell`](Self::tell) when another system evaluates a
104/// population.
105pub struct De {
106    fitfun: Fitness,
107    rng: Rng,
108    dim: usize,
109    popsize: usize,
110    max_evaluations: u64,
111    keep: f64,
112    stopfitness: f64,
113    f0: f64,
114    cr0: f64,
115    f: f64,
116    cr: f64,
117    min_mutate: f64,
118    max_mutate: f64,
119    is_int: Option<Vec<bool>>,
120
121    // normal-sampling around a guess
122    use_normal: bool,
123    mean: Vec<f64>,
124    sigma: Vec<f64>,
125    max_sigma: Vec<f64>,
126    min_sigma_vec: Vec<f64>,
127    min_sigma_val: f64,
128    mean_hist: Vec<Vec<f64>>, // 10 columns, each dim long
129    mean_hist_index: usize,
130
131    // population
132    pop_x: Vec<Vec<f64>>,
133    pop_x0: Vec<Vec<f64>>,
134    pop_y: Vec<f64>,
135    pop_iter: Vec<i32>,
136    best_i: usize,
137    best_x: Vec<f64>,
138    best_y: f64,
139
140    iterations: i32,
141    stop: i32,
142    pos: usize,
143
144    // ask/tell bookkeeping
145    improves_x: VecDeque<Vec<f64>>,
146    improves_p: VecDeque<usize>,
147    asked_x: Vec<Vec<f64>>,
148    asked_p: Vec<usize>,
149    external_evaluations: u64,
150}
151
152impl De {
153    /// Build a DE optimizer. `guess`/`sigma` empty ⇒ uniform sampling in the
154    /// box; non-empty ⇒ normal sampling around `guess`. `ints` marks discrete
155    /// coordinates (length `dim`) or is `None`.
156    pub fn new(
157        mut fitfun: Fitness,
158        guess: &[f64],
159        sigma: &[f64],
160        ints: Option<Vec<bool>>,
161        p: &DeParams,
162    ) -> Self {
163        let dim = fitfun.dim();
164        fitfun.reset_evaluations();
165        let popsize = if p.popsize > 0 {
166            p.popsize as usize
167        } else {
168            15 * dim
169        };
170        let keep = if p.keep > 0.0 { p.keep } else { 30.0 };
171        let f0 = if p.f > 0.0 { p.f } else { 0.5 };
172        let cr0 = if p.cr > 0.0 { p.cr } else { 0.9 };
173        let min_mutate = if p.min_mutate > 0.0 {
174            p.min_mutate
175        } else {
176            0.1
177        };
178        let max_mutate = if p.max_mutate > 0.0 {
179            p.max_mutate
180        } else {
181            0.5
182        };
183
184        let use_normal = !guess.is_empty();
185        let mean = guess.to_vec();
186        let sigma_v = sigma.to_vec();
187
188        let mut de = De {
189            dim,
190            popsize,
191            max_evaluations: if p.max_evaluations > 0 {
192                p.max_evaluations
193            } else {
194                50_000
195            },
196            keep,
197            stopfitness: p.stop_fitness,
198            f0,
199            cr0,
200            f: f0,
201            cr: cr0,
202            min_mutate,
203            max_mutate,
204            is_int: ints,
205            use_normal,
206            mean,
207            sigma: sigma_v,
208            max_sigma: vec![],
209            min_sigma_vec: vec![],
210            min_sigma_val: p.min_sigma,
211            mean_hist: vec![],
212            mean_hist_index: 0,
213            pop_x: vec![],
214            pop_x0: vec![],
215            pop_y: vec![],
216            pop_iter: vec![],
217            best_i: 0,
218            best_x: vec![],
219            best_y: f64::MAX,
220            iterations: 0,
221            stop: 0,
222            pos: 0,
223            improves_x: VecDeque::new(),
224            improves_p: VecDeque::new(),
225            asked_x: vec![],
226            asked_p: vec![],
227            external_evaluations: 0,
228            rng: Rng::new(p.seed.wrapping_add(p.runid as u64)),
229            fitfun,
230        };
231        de.init();
232        de
233    }
234
235    fn init(&mut self) {
236        let dim = self.dim;
237        self.mean_hist = (0..10).map(|_| self.mean.clone()).collect();
238        self.mean_hist_index = 0;
239        if self.use_normal {
240            self.max_sigma = self
241                .sigma
242                .iter()
243                .map(|s| s / (0.1 + self.min_sigma_val))
244                .collect();
245            self.min_sigma_vec = self.sigma.iter().map(|s| self.min_sigma_val * s).collect();
246        }
247        self.pop_x = Vec::with_capacity(self.popsize);
248        self.pop_x0 = Vec::with_capacity(self.popsize);
249        self.pop_y = vec![f64::MAX; self.popsize];
250        for _ in 0..self.popsize {
251            let s = self.sample();
252            self.pop_x0.push(s.clone());
253            self.pop_x.push(s);
254        }
255        self.best_i = 0;
256        self.best_x = self.pop_x[0].clone();
257        self.pop_iter = vec![0; self.popsize];
258        self.asked_x = vec![vec![0.0; dim]; self.popsize];
259        self.asked_p = vec![0; self.popsize];
260    }
261
262    /// Number of decision variables.
263    pub fn dim(&self) -> usize {
264        self.dim
265    }
266    /// Number of individuals evaluated per generation.
267    pub fn popsize(&self) -> usize {
268        self.popsize
269    }
270    /// Current termination code, or zero while the optimizer can continue.
271    pub fn stop(&self) -> i32 {
272        self.stop
273    }
274
275    fn sample(&mut self) -> Vec<f64> {
276        if self.use_normal {
277            let raw: Vec<f64> = (0..self.dim)
278                .map(|i| self.mean[i] + self.rng.gaussian() * self.sigma[i])
279                .collect();
280            self.fitfun.closest_feasible(&raw)
281        } else {
282            self.fitfun.sample(&mut self.rng)
283        }
284    }
285
286    fn sample_i(&mut self, i: usize) -> f64 {
287        if self.use_normal {
288            let v = self.rng.normreal(self.mean[i], self.sigma[i]);
289            self.fitfun.closest_feasible_i(i, v)
290        } else {
291            self.fitfun.sample_i(i, &mut self.rng)
292        }
293    }
294
295    fn update_mean(&mut self) {
296        if !self.use_normal {
297            return;
298        }
299        self.mean_hist[self.mean_hist_index] = self.pop_x[self.best_i].clone();
300        self.mean_hist_index = (self.mean_hist_index + 1) % self.mean_hist.len();
301        // delta = rowwise (max - min) over the history columns; clamped per
302        // coordinate against parallel max/min-sigma arrays.
303        let mut sigma_new = vec![0.0; self.dim];
304        for (i, sn) in sigma_new.iter_mut().enumerate() {
305            let mut lo = f64::MAX;
306            let mut hi = f64::MIN;
307            for col in &self.mean_hist {
308                lo = lo.min(col[i]);
309                hi = hi.max(col[i]);
310            }
311            *sn = (hi - lo).min(self.max_sigma[i]).max(self.min_sigma_vec[i]);
312        }
313        let mean_new: f64 = sigma_new.iter().sum::<f64>() / self.dim as f64;
314        let mean_old: f64 = self.sigma.iter().sum::<f64>() / self.dim as f64;
315        if mean_new > mean_old {
316            self.sigma = sigma_new;
317        } else {
318            for (s, sn) in self.sigma.iter_mut().zip(&sigma_new) {
319                *s = 0.9 * *s + 0.1 * sn;
320            }
321        }
322        let best = self.pop_x[self.best_i].clone();
323        for (m, b) in self.mean.iter_mut().zip(&best) {
324            *m = 0.9 * *m + 0.1 * b;
325        }
326    }
327
328    fn modify(&mut self, x: &mut [f64]) {
329        let Some(is_int) = self.is_int.clone() else {
330            return;
331        };
332        let n_ints = is_int.iter().filter(|&&b| b).count() as f64;
333        if n_ints == 0.0 {
334            return;
335        }
336        let to_mutate =
337            self.min_mutate + self.rng.uniform01() * (self.max_mutate - self.min_mutate);
338        for i in 0..self.dim {
339            if is_int[i] && self.rng.uniform01() < to_mutate / n_ints {
340                x[i] = self.sample_i(i).trunc();
341            }
342        }
343    }
344
345    fn next_improve(&mut self, xb: &[f64], x: &[f64], xi: &[f64]) -> Vec<f64> {
346        let raw: Vec<f64> = (0..self.dim)
347            .map(|j| xb[j] + (x[j] - xi[j]) * self.f0)
348            .collect();
349        let mut nextx = self.fitfun.closest_feasible(&raw);
350        self.modify(&mut nextx);
351        nextx
352    }
353
354    fn oscillate(&mut self) {
355        self.cr = if self.iterations % 2 == 0 {
356            0.5 * self.cr0
357        } else {
358            self.cr0
359        };
360        self.f = if self.iterations % 2 == 0 {
361            0.5 * self.f0
362        } else {
363            self.f0
364        };
365    }
366
367    fn pick_two(&mut self, p: usize) -> (usize, usize) {
368        let mut r1;
369        loop {
370            r1 = self.rng.int_below(self.popsize as i64) as usize;
371            if r1 != p && r1 != self.best_i {
372                break;
373            }
374        }
375        let mut r2;
376        loop {
377            r2 = self.rng.int_below(self.popsize as i64) as usize;
378            if r2 != p && r2 != self.best_i && r2 != r1 {
379                break;
380            }
381        }
382        (r1, r2)
383    }
384
385    /// Ask-path donor construction (the C++ `nextX`).
386    fn next_x(&mut self, p: usize, xp: &[f64], xb: &[f64]) -> Vec<f64> {
387        if p == 0 {
388            self.iterations += 1;
389            self.oscillate();
390            if self.iterations > 2 {
391                self.update_mean();
392            }
393        }
394        let (r1, r2) = self.pick_two(p);
395        let x1 = self.pop_x[r1].clone();
396        let x2 = self.pop_x[r2].clone();
397        let mut x: Vec<f64> = (0..self.dim)
398            .map(|j| xb[j] + (x1[j] - x2[j]) * self.f)
399            .collect();
400        let r = self.rng.int_below(self.dim as i64) as usize;
401        for j in 0..self.dim {
402            if j != r && self.rng.uniform01() > self.cr {
403                x[j] = xp[j];
404            }
405        }
406        let mut nextx = self.fitfun.closest_feasible(&x);
407        self.modify(&mut nextx);
408        nextx
409    }
410
411    fn ask_one(&mut self) -> (usize, Vec<f64>) {
412        if self.improves_x.is_empty() {
413            let p = self.pos;
414            let xp = self.pop_x[p].clone();
415            let xb = self.pop_x[self.best_i].clone();
416            let x = self.next_x(p, &xp, &xb);
417            self.pos = (self.pos + 1) % self.popsize;
418            (p, x)
419        } else {
420            let p = self.improves_p.pop_front().unwrap();
421            let x = self.improves_x.pop_front().unwrap();
422            (p, x)
423        }
424    }
425
426    fn tell_one(&mut self, y: f64, x: &[f64], p: usize) -> i32 {
427        if y.is_finite() && y < self.pop_y[p] {
428            if self.iterations > 1 {
429                let xb = self.pop_x[self.best_i].clone();
430                let xi = self.pop_x0[p].clone();
431                let improved = self.next_improve(&xb, x, &xi);
432                self.improves_p.push_back(p);
433                self.improves_x.push_back(improved);
434            }
435            self.pop_x0[p] = self.pop_x[p].clone();
436            self.pop_x[p] = x.to_vec();
437            self.pop_y[p] = y;
438            self.pop_iter[p] = self.iterations;
439            if y < self.pop_y[self.best_i] {
440                self.best_i = p;
441                if y < self.best_y {
442                    self.best_y = y;
443                    self.best_x = x.to_vec();
444                    if self.stopfitness.is_finite() && self.best_y < self.stopfitness {
445                        self.stop = 1;
446                    }
447                }
448            }
449        } else if self.keep * self.rng.uniform01() < (self.iterations - self.pop_iter[p]) as f64 {
450            self.pop_x[p] = self.sample();
451            self.pop_y[p] = f64::MAX;
452        }
453        self.stop
454    }
455
456    /// Run the serial generational loop against an in-process objective.
457    pub fn optimize(&mut self, obj: &impl Objective) -> DeResult {
458        self.iterations = 1;
459        self.fitfun.reset_evaluations();
460        while self.fitfun.evaluations() < self.max_evaluations && !self.fitfun.terminate() {
461            if self.iterations > 2 {
462                self.update_mean();
463            }
464            self.oscillate();
465            for p in 0..self.popsize {
466                let xp = self.pop_x[p].clone();
467                let xb = self.pop_x[self.best_i].clone();
468                let (r1, r2) = self.pick_two(p);
469                let x1 = self.pop_x[r1].clone();
470                let x2 = self.pop_x[r2].clone();
471                let r = self.rng.int_below(self.dim as i64) as usize;
472                let mut x = xp.clone();
473                for j in 0..self.dim {
474                    if j == r || self.rng.uniform01() < self.cr {
475                        x[j] = xb[j] + self.f * (x1[j] - x2[j]);
476                        if !self.fitfun.feasible_i(j, x[j]) {
477                            x[j] = self.sample_i(j);
478                        }
479                    }
480                }
481                self.modify(&mut x);
482                let mut y = self.fitfun.eval_encoded_scalar(&x, obj);
483                if y.is_finite() && y < self.pop_y[p] {
484                    // temporal locality: an extra trial along the last move
485                    let x2t = self.next_improve(&xb, &x, &xp);
486                    let y2 = self.fitfun.eval_encoded_scalar(&x2t, obj);
487                    if y2.is_finite() && y2 < y {
488                        y = y2;
489                        x = x2t;
490                    }
491                    self.pop_x[p] = x.clone();
492                    self.pop_y[p] = y;
493                    self.pop_iter[p] = self.iterations;
494                    if y < self.pop_y[self.best_i] {
495                        self.best_i = p;
496                        if y < self.best_y {
497                            self.best_y = y;
498                            self.best_x = x.clone();
499                            if self.stopfitness.is_finite() && self.best_y < self.stopfitness {
500                                self.stop = 1;
501                                return self.make_result(self.fitfun.evaluations());
502                            }
503                        }
504                    }
505                } else if self.keep * self.rng.uniform01()
506                    < (self.iterations - self.pop_iter[p]) as f64
507                {
508                    self.pop_x[p] = self.sample();
509                    self.pop_y[p] = f64::MAX;
510                }
511            }
512            self.iterations += 1;
513        }
514        self.make_result(self.fitfun.evaluations())
515    }
516
517    fn make_result(&self, evaluations: u64) -> DeResult {
518        DeResult {
519            x: self.best_x.clone(),
520            y: self.best_y,
521            evaluations,
522            iterations: self.iterations,
523            stop: self.stop,
524        }
525    }
526
527    // ---- ask/tell interface (mirrors DeState::Impl) ----
528
529    /// Ask for a full population of candidate rows.
530    pub fn ask(&mut self) -> Vec<Vec<f64>> {
531        for i in 0..self.popsize {
532            let (p, x) = self.ask_one();
533            self.asked_p[i] = p;
534            self.asked_x[i] = x;
535        }
536        self.asked_x.clone()
537    }
538
539    /// Tell fitness values for the population returned by [`ask`](De::ask).
540    pub fn tell(&mut self, ys: &[f64]) -> i32 {
541        for (i, &y) in ys.iter().enumerate() {
542            let x = self.asked_x[i].clone();
543            self.tell_one(y, &x, self.asked_p[i]);
544        }
545        self.external_evaluations += ys.len() as u64;
546        self.stop
547    }
548
549    /// Return a decoded snapshot of the current population.
550    pub fn population(&self) -> Vec<Vec<f64>> {
551        self.pop_x.clone()
552    }
553
554    /// Return the current best result for an ask/tell run.
555    pub fn result(&self) -> DeResult {
556        self.make_result(self.external_evaluations)
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    fn sphere(x: &[f64]) -> f64 {
565        x.iter().map(|v| v * v).sum()
566    }
567    fn rosen(x: &[f64]) -> f64 {
568        (0..x.len() - 1)
569            .map(|i| 100.0 * (x[i + 1] - x[i] * x[i]).powi(2) + (1.0 - x[i]).powi(2))
570            .sum()
571    }
572
573    fn optimize(obj: impl Objective, dim: usize, seed: u64, evals: u64) -> f64 {
574        let fit = Fitness::bounded(dim, 1, &vec![-5.0; dim], &vec![5.0; dim]);
575        let params = DeParams {
576            popsize: 31,
577            max_evaluations: evals,
578            seed,
579            ..Default::default()
580        };
581        let mut de = De::new(fit, &[], &[], None, &params);
582        de.optimize(&obj).y
583    }
584
585    #[test]
586    fn minimizes_sphere() {
587        assert!(optimize(sphere as fn(&[f64]) -> f64, 5, 1, 8000) < 1e-6);
588    }
589
590    #[test]
591    fn minimizes_rosenbrock() {
592        let mut v: Vec<f64> = (0..5)
593            .map(|s| optimize(rosen as fn(&[f64]) -> f64, 5, s, 12000))
594            .collect();
595        v.sort_by(|a, b| a.partial_cmp(b).unwrap());
596        assert!(v[2] < 1.0, "rosen median too large: {v:?}");
597    }
598
599    #[test]
600    fn ask_tell_converges() {
601        let fit = Fitness::bounded(5, 1, &[-5.0; 5], &[5.0; 5]);
602        let params = DeParams {
603            popsize: 24,
604            max_evaluations: 8000,
605            seed: 3,
606            ..Default::default()
607        };
608        let mut de = De::new(fit, &[], &[], None, &params);
609        for _ in 0..400 {
610            let pop = de.ask();
611            let ys: Vec<f64> = pop.iter().map(|x| sphere(x)).collect();
612            if de.tell(&ys) != 0 {
613                break;
614            }
615        }
616        assert!(de.result().y < 1e-3, "ask/tell did not converge");
617    }
618
619    #[test]
620    fn integer_modify_runs() {
621        let fit = Fitness::bounded(4, 1, &[-5.0; 4], &[5.0; 4]);
622        let params = DeParams {
623            popsize: 20,
624            max_evaluations: 4000,
625            seed: 5,
626            ..Default::default()
627        };
628        let ints = Some(vec![true, false, true, false]);
629        let mut de = De::new(fit, &[], &[], ints, &params);
630        let r = de.optimize(&(sphere as fn(&[f64]) -> f64));
631        assert!(r.evaluations > 0);
632    }
633
634    #[test]
635    fn normal_sampling_default_parameters_getters_and_early_stop() {
636        let fit = Fitness::bounded(2, 1, &[-1.0; 2], &[1.0; 2]);
637        let params = DeParams {
638            popsize: 0,
639            max_evaluations: 10,
640            keep: 0.0,
641            stop_fitness: 1.0,
642            f: 0.0,
643            cr: 0.0,
644            min_mutate: 0.0,
645            max_mutate: 0.0,
646            min_sigma: 0.1,
647            seed: 11,
648            runid: -2,
649        };
650        let mut optimizer = De::new(fit, &[0.0, 0.0], &[0.2, 0.2], None, &params);
651        assert_eq!(optimizer.dim(), 2);
652        assert_eq!(optimizer.popsize(), 30);
653        assert_eq!(optimizer.population().len(), 30);
654        assert_eq!(optimizer.stop(), 0);
655        let result = optimizer.optimize(&(sphere as fn(&[f64]) -> f64));
656        assert_eq!(result.stop, 1);
657        assert!(result.evaluations > 0);
658    }
659}