Skip to main content

rlevo_evolution/algorithms/
de.rs

1//! Differential Evolution.
2//!
3//! Classical DE over `Tensor<B, 2>` populations with all common
4//! mutation/crossover variants enumerated in [`DeVariant`].
5//!
6//! # Variants
7//!
8//! | Variant | Mutation formula |
9//! |---|---|
10//! | [`DeVariant::Rand1Bin`], [`DeVariant::Rand1Exp`] | `v = x_{r1} + F · (x_{r2} − x_{r3})` |
11//! | [`DeVariant::Best1Bin`] | `v = x_{best} + F · (x_{r2} − x_{r3})` |
12//! | [`DeVariant::CurrentToBest1Bin`] | `v = x_i + F · (x_{best} − x_i) + F · (x_{r1} − x_{r2})` |
13//! | [`DeVariant::Rand2Bin`] | `v = x_{r1} + F · (x_{r2} − x_{r3}) + F · (x_{r4} − x_{r5})` |
14//!
15//! The suffix `Bin`/`Exp` selects between binomial and exponential
16//! crossover. All index draws reject repeated and self-referential
17//! indices.
18//!
19//! # Hot path
20//!
21//! A fused `CubeCL` kernel for trial-vector construction is tracked as
22//! follow-up work (see [`crate::ops::kernels`]). Until then this module
23//! uses host-sampled indices and composes the update from primitive
24//! tensor ops.
25//!
26//! # Reference
27//!
28//! - Storn & Price (1997), *Differential Evolution — A Simple and
29//!   Efficient Heuristic for Global Optimization over Continuous
30//!   Spaces*.
31
32use std::marker::PhantomData;
33
34use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
35use rand::{Rng, RngExt};
36
37use crate::rng::{SeedPurpose, seed_stream};
38use crate::strategy::{Strategy, StrategyMetrics};
39
40/// Mutation + crossover variant for differential evolution.
41///
42/// # Convergence caveats
43///
44/// Not every variant converges to machine precision on every landscape
45/// within the same budget. On unimodal landscapes like Sphere,
46/// [`Best1Bin`](DeVariant::Best1Bin) and
47/// [`CurrentToBest1Bin`](DeVariant::CurrentToBest1Bin) tend to
48/// **converge prematurely**: the population collapses around the
49/// current best before the differential search has fully explored, and
50/// the per-generation variance `F · (x_{r2} − x_{r3})` shrinks to zero.
51/// Classical DE literature documents this as the core trade-off of
52/// best-biased variants. The crate's integration tests therefore only
53/// require strong *reduction* from the random baseline for those
54/// variants, not optimality — see
55/// `algorithms::de::tests::all_variants_converge_on_sphere_d10` for the
56/// per-variant tolerance choice.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum DeVariant {
59    /// `x_{r1} + F · (x_{r2} − x_{r3})`, binomial crossover. Balanced
60    /// exploration / exploitation; reaches machine precision on Sphere
61    /// within a few hundred generations.
62    Rand1Bin,
63    /// `x_{best} + F · (x_{r2} − x_{r3})`, binomial crossover.
64    ///
65    /// Strong exploitation — the mutation base is always the current
66    /// best, so the population concentrates quickly. Prone to
67    /// **premature convergence** on landscapes where the current best
68    /// is far from the global optimum; on Sphere-D10 with 500 gens this
69    /// variant stalls around `best_fitness ≈ 1` while `Rand1Bin` reaches
70    /// `< 1e-20`.
71    Best1Bin,
72    /// `x_i + F · (x_{best} − x_i) + F · (x_{r1} − x_{r2})`, binomial.
73    ///
74    /// Hybrid of the current individual and the best-so-far. Still
75    /// **prone to premature convergence** because the
76    /// `F · (x_{best} − x_i)` term dominates once the population is
77    /// near the best. Useful on multimodal landscapes where pure-best
78    /// variants get stuck in local basins, less useful on Sphere.
79    CurrentToBest1Bin,
80    /// `x_{r1} + F · (x_{r2} − x_{r3}) + F · (x_{r4} − x_{r5})`,
81    /// binomial. Higher variance than `Rand1Bin` thanks to two
82    /// difference vectors; converges on Sphere but more slowly.
83    Rand2Bin,
84    /// `x_{r1} + F · (x_{r2} − x_{r3})`, exponential crossover.
85    /// Identical mutation to `Rand1Bin`, different crossover mask shape.
86    /// Performance comparable to `Rand1Bin` in practice.
87    Rand1Exp,
88}
89
90impl DeVariant {
91    /// Number of distinct random indices the variant needs (in
92    /// addition to the current individual `i`).
93    const fn random_indices(self) -> usize {
94        match self {
95            DeVariant::Rand1Bin | DeVariant::Rand1Exp => 3,
96            DeVariant::Best1Bin | DeVariant::CurrentToBest1Bin => 2,
97            DeVariant::Rand2Bin => 5,
98        }
99    }
100
101    /// Whether this variant uses exponential crossover.
102    const fn is_exponential(self) -> bool {
103        matches!(self, DeVariant::Rand1Exp)
104    }
105}
106
107/// Static configuration for a [`DifferentialEvolution`] run.
108#[derive(Debug, Clone)]
109pub struct DeConfig {
110    /// Population size (≥ 5 for `Rand2Bin`, ≥ 4 otherwise).
111    pub pop_size: usize,
112    /// Genome dimensionality.
113    pub genome_dim: usize,
114    /// Search-space bounds (initialization and clamping).
115    pub bounds: (f32, f32),
116    /// Differential weight (F). Typical range [0.4, 0.9].
117    pub f: f32,
118    /// Crossover probability (CR). Typical range [0.1, 0.9].
119    pub cr: f32,
120    /// Variant.
121    pub variant: DeVariant,
122}
123
124impl DeConfig {
125    /// Default configuration (`Rand1Bin`, F = 0.5, CR = 0.9) for a given
126    /// dimensionality.
127    #[must_use]
128    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
129        Self {
130            pop_size,
131            genome_dim,
132            bounds: (-5.12, 5.12),
133            f: 0.5,
134            cr: 0.9,
135            variant: DeVariant::Rand1Bin,
136        }
137    }
138}
139
140/// Generation state for [`DifferentialEvolution`].
141#[derive(Debug, Clone)]
142pub struct DeState<B: Backend> {
143    /// Current population, shape `(pop_size, D)`.
144    pub population: Tensor<B, 2>,
145    /// Current fitness (host-side cache).
146    pub fitness: Vec<f32>,
147    /// Index of the current best within `population`.
148    pub best_index: usize,
149    /// Best-so-far genome, shape `(1, D)`.
150    pub best_genome: Option<Tensor<B, 2>>,
151    /// Best-so-far fitness.
152    pub best_fitness: f32,
153    /// Generation counter.
154    pub generation: usize,
155}
156
157/// Classical DE/rand/1/bin (and friends).
158///
159/// # Example
160///
161/// ```no_run
162/// use burn::backend::NdArray;
163/// use rlevo_evolution::algorithms::de::{DeConfig, DeVariant, DifferentialEvolution};
164///
165/// let strategy = DifferentialEvolution::<NdArray>::new();
166/// let mut params = DeConfig::default_for(30, 10);
167/// params.variant = DeVariant::Rand1Bin;
168/// let _ = (strategy, params);
169/// ```
170#[derive(Debug, Clone, Copy, Default)]
171pub struct DifferentialEvolution<B: Backend> {
172    _backend: PhantomData<fn() -> B>,
173}
174
175impl<B: Backend> DifferentialEvolution<B> {
176    /// Builds a new (stateless) strategy object.
177    #[must_use]
178    pub fn new() -> Self {
179        Self {
180            _backend: PhantomData,
181        }
182    }
183
184    fn sample_initial_population(
185        params: &DeConfig,
186        rng: &mut dyn Rng,
187        device: &B::Device,
188    ) -> Tensor<B, 2> {
189        let (lo, hi) = params.bounds;
190        B::seed(device, rng.next_u64());
191        Tensor::<B, 2>::random(
192            [params.pop_size, params.genome_dim],
193            burn::tensor::Distribution::Uniform(f64::from(lo), f64::from(hi)),
194            device,
195        )
196    }
197
198    /// Samples `k` indices from `0..pop_size`, all distinct and all
199    /// different from `self_idx`.
200    ///
201    /// # Panics
202    ///
203    /// Panics if `pop_size <= k`, since the rejection loop cannot make
204    /// progress without enough candidates outside `self_idx`.
205    fn sample_distinct_excluding(
206        self_idx: usize,
207        pop_size: usize,
208        k: usize,
209        rng: &mut dyn Rng,
210    ) -> Vec<usize> {
211        assert!(
212            pop_size > k,
213            "DE: pop_size must exceed the number of distinct indices required"
214        );
215        let mut chosen = Vec::with_capacity(k);
216        while chosen.len() < k {
217            let candidate = rng.random_range(0..pop_size);
218            if candidate != self_idx && !chosen.contains(&candidate) {
219                chosen.push(candidate);
220            }
221        }
222        chosen
223    }
224}
225
226impl<B: Backend> Strategy<B> for DifferentialEvolution<B>
227where
228    B::Device: Clone,
229{
230    type Params = DeConfig;
231    type State = DeState<B>;
232    type Genome = Tensor<B, 2>;
233
234    fn init(&self, params: &DeConfig, rng: &mut dyn Rng, device: &B::Device) -> DeState<B> {
235        let population = Self::sample_initial_population(params, rng, device);
236        DeState {
237            population,
238            fitness: Vec::new(),
239            best_index: 0,
240            best_genome: None,
241            best_fitness: f32::INFINITY,
242            generation: 0,
243        }
244    }
245
246    #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
247    fn ask(
248        &self,
249        params: &DeConfig,
250        state: &DeState<B>,
251        rng: &mut dyn Rng,
252        device: &B::Device,
253    ) -> (Tensor<B, 2>, DeState<B>) {
254        // First call: evaluate the initial population.
255        if state.fitness.is_empty() {
256            return (state.population.clone(), state.clone());
257        }
258
259        let DeConfig {
260            pop_size,
261            genome_dim,
262            f,
263            cr,
264            variant,
265            ..
266        } = *params;
267
268        let mut trial_rng =
269            seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Trial);
270
271        // ------------------------------------------------------------------
272        // 1. Build the mutant vector v_i for every i, host-side gathers.
273        //    We assemble three index tensors (a, b, c [and d, e for rand2])
274        //    and do the arithmetic on-device in one sweep.
275        // ------------------------------------------------------------------
276        let k = variant.random_indices();
277        let mut rand_indices: Vec<Vec<usize>> =
278            (0..k).map(|_| Vec::with_capacity(pop_size)).collect();
279        for i in 0..pop_size {
280            let chosen = Self::sample_distinct_excluding(i, pop_size, k, &mut trial_rng);
281            for (j, idx) in chosen.into_iter().enumerate() {
282                rand_indices[j].push(idx);
283            }
284        }
285
286        let gather = |idxs: &[usize]| -> Tensor<B, 2> {
287            #[allow(clippy::cast_possible_wrap)]
288            let v: Vec<i64> = idxs.iter().map(|&i| i as i64).collect();
289            let t = Tensor::<B, 1, Int>::from_data(TensorData::new(v, [pop_size]), device);
290            state.population.clone().select(0, t)
291        };
292
293        let v = match variant {
294            DeVariant::Rand1Bin | DeVariant::Rand1Exp => {
295                let a = gather(&rand_indices[0]);
296                let b = gather(&rand_indices[1]);
297                let c = gather(&rand_indices[2]);
298                a + (b - c).mul_scalar(f)
299            }
300            DeVariant::Best1Bin => {
301                #[allow(clippy::single_range_in_vec_init)]
302                let best = state
303                    .population
304                    .clone()
305                    .slice([state.best_index..state.best_index + 1])
306                    .expand([pop_size, genome_dim]);
307                let b = gather(&rand_indices[0]);
308                let c = gather(&rand_indices[1]);
309                best + (b - c).mul_scalar(f)
310            }
311            DeVariant::CurrentToBest1Bin => {
312                #[allow(clippy::single_range_in_vec_init)]
313                let best = state
314                    .population
315                    .clone()
316                    .slice([state.best_index..state.best_index + 1])
317                    .expand([pop_size, genome_dim]);
318                let current = state.population.clone();
319                let a = gather(&rand_indices[0]);
320                let b = gather(&rand_indices[1]);
321                current.clone() + (best - current).mul_scalar(f) + (a - b).mul_scalar(f)
322            }
323            DeVariant::Rand2Bin => {
324                let a = gather(&rand_indices[0]);
325                let b = gather(&rand_indices[1]);
326                let c = gather(&rand_indices[2]);
327                let d = gather(&rand_indices[3]);
328                let e = gather(&rand_indices[4]);
329                a + (b - c).mul_scalar(f) + (d - e).mul_scalar(f)
330            }
331        };
332
333        // ------------------------------------------------------------------
334        // 2. Crossover: binomial or exponential. Always preserve at
335        //    least one mutant gene per row (j_rand).
336        // ------------------------------------------------------------------
337        let mut cross_rng = seed_stream(
338            rng.next_u64(),
339            state.generation as u64,
340            SeedPurpose::Crossover,
341        );
342        let mut cross_mask = vec![false; pop_size * genome_dim];
343        if variant.is_exponential() {
344            for row in 0..pop_size {
345                let start = cross_rng.random_range(0..genome_dim);
346                let mut len = 1;
347                while len < genome_dim && cross_rng.random::<f32>() < cr {
348                    len += 1;
349                }
350                for k in 0..len {
351                    let j = (start + k) % genome_dim;
352                    cross_mask[row * genome_dim + j] = true;
353                }
354            }
355        } else {
356            for row in 0..pop_size {
357                let j_rand = cross_rng.random_range(0..genome_dim);
358                for j in 0..genome_dim {
359                    if j == j_rand || cross_rng.random::<f32>() < cr {
360                        cross_mask[row * genome_dim + j] = true;
361                    }
362                }
363            }
364        }
365        #[allow(clippy::cast_possible_wrap)]
366        let mask_int: Vec<i64> = cross_mask.iter().map(|&b| i64::from(b)).collect();
367        let mask_tensor = Tensor::<B, 2, Int>::from_data(
368            TensorData::new(mask_int, [pop_size, genome_dim]),
369            device,
370        );
371        let mask_bool = mask_tensor.equal_elem(1);
372
373        // Where cross_mask == 1, take from v; otherwise from state.population.
374        let trial = state.population.clone().mask_where(mask_bool, v);
375        let (lo, hi) = params.bounds;
376        let trial = trial.clamp(lo, hi);
377
378        (trial, state.clone())
379    }
380
381    fn tell(
382        &self,
383        _params: &DeConfig,
384        trial: Tensor<B, 2>,
385        fitness: Tensor<B, 1>,
386        mut state: DeState<B>,
387        _rng: &mut dyn Rng,
388    ) -> (DeState<B>, StrategyMetrics) {
389        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
390
391        // First `tell`: stash fitness for the initial population.
392        if state.fitness.is_empty() {
393            state.fitness.clone_from(&fitness_host);
394            state.best_index = argmin(&fitness_host);
395            state.generation += 1;
396            update_best(&mut state, &trial, &fitness_host);
397            let m = StrategyMetrics::from_host_fitness(
398                state.generation,
399                &fitness_host,
400                state.best_fitness,
401            );
402            state.best_fitness = m.best_fitness_ever;
403            state.population = trial;
404            return (state, m);
405        }
406
407        // Greedy per-slot replacement: trial replaces current iff
408        // trial is at least as good.
409        let device = trial.device();
410        let pop_size = state.fitness.len();
411        let mut replace_mask = vec![0i64; pop_size];
412        let mut new_fit = state.fitness.clone();
413        for i in 0..pop_size {
414            if fitness_host[i] <= state.fitness[i] {
415                replace_mask[i] = 1;
416                new_fit[i] = fitness_host[i];
417            }
418        }
419
420        let mask_int =
421            Tensor::<B, 1, Int>::from_data(TensorData::new(replace_mask, [pop_size]), &device);
422        let mask_bool_row = mask_int.equal_elem(1);
423        let genome_dim = state.population.shape().dims[1];
424        let mask_bool = mask_bool_row
425            .unsqueeze_dim::<2>(1)
426            .expand([pop_size, genome_dim]);
427        let next_pop = state
428            .population
429            .clone()
430            .mask_where(mask_bool, trial.clone());
431
432        state.population = next_pop;
433        state.fitness.clone_from(&new_fit);
434        state.best_index = argmin(&new_fit);
435        state.generation += 1;
436        update_best(&mut state, &trial, &fitness_host);
437        let m = StrategyMetrics::from_host_fitness(state.generation, &new_fit, state.best_fitness);
438        state.best_fitness = m.best_fitness_ever;
439        (state, m)
440    }
441
442    fn best(&self, state: &DeState<B>) -> Option<(Tensor<B, 2>, f32)> {
443        state
444            .best_genome
445            .as_ref()
446            .map(|g| (g.clone(), state.best_fitness))
447    }
448}
449
450fn argmin(xs: &[f32]) -> usize {
451    let mut best_idx = 0usize;
452    let mut best = f32::INFINITY;
453    for (i, &v) in xs.iter().enumerate() {
454        if v < best {
455            best = v;
456            best_idx = i;
457        }
458    }
459    best_idx
460}
461
462fn update_best<B: Backend>(state: &mut DeState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
463    if fitness.is_empty() {
464        return;
465    }
466    let best_idx = argmin(fitness);
467    let best_f = fitness[best_idx];
468    if best_f < state.best_fitness {
469        let device = pop.device();
470        #[allow(clippy::cast_possible_wrap)]
471        let idx =
472            Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i64], [1]), &device);
473        state.best_genome = Some(pop.clone().select(0, idx));
474        state.best_fitness = best_f;
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::fitness::FromFitnessEvaluable;
482    use crate::strategy::EvolutionaryHarness;
483    use burn::backend::NdArray;
484    use rlevo_core::fitness::FitnessEvaluable;
485    type TestBackend = NdArray;
486
487    struct Sphere;
488    struct SphereFit;
489    impl FitnessEvaluable for SphereFit {
490        type Individual = Vec<f64>;
491        type Landscape = Sphere;
492        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
493            x.iter().map(|v| v * v).sum()
494        }
495    }
496
497    fn run_de(variant: DeVariant, dim: usize, gens: usize) -> f32 {
498        let device = Default::default();
499        let mut params = DeConfig::default_for(30, dim);
500        params.variant = variant;
501        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
502        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
503            DifferentialEvolution::<TestBackend>::new(),
504            params,
505            fitness_fn,
506            11,
507            device,
508            gens,
509        );
510        harness.reset();
511        loop {
512            if harness.step(()).done {
513                break;
514            }
515        }
516        harness.latest_metrics().unwrap().best_fitness_ever
517    }
518
519    /// All five DE variants converge on Sphere-D10 within budget.
520    ///
521    /// The Burn ndarray backend seeds its RNG through a process-wide
522    /// mutex, so separate `#[test]` functions that call `Tensor::random`
523    /// race on seeding and produce non-deterministic trajectories. This
524    /// single test runs the variants sequentially inside one function
525    /// so their seed state is not contended.
526    ///
527    /// Per-variant tolerance reflects classical characterizations:
528    /// `rand1`/`rand2` converge to optimum, `best1` / current-to-best
529    /// suffer from premature convergence on unimodal landscapes.
530    #[test]
531    fn all_variants_converge_on_sphere_d10() {
532        let rand1bin = run_de(DeVariant::Rand1Bin, 10, 500);
533        assert!(rand1bin < 1e-6, "DE/rand/1/bin best={rand1bin}");
534
535        let rand2bin = run_de(DeVariant::Rand2Bin, 10, 800);
536        assert!(rand2bin < 1e-6, "DE/rand/2/bin best={rand2bin}");
537
538        let rand1exp = run_de(DeVariant::Rand1Exp, 10, 500);
539        assert!(rand1exp < 1e-6, "DE/rand/1/exp best={rand1exp}");
540
541        let best1bin = run_de(DeVariant::Best1Bin, 10, 500);
542        assert!(best1bin < 1.0, "DE/best/1/bin best={best1bin}");
543
544        let c2b = run_de(DeVariant::CurrentToBest1Bin, 10, 500);
545        assert!(c2b < 2.0, "DE/current-to-best/1/bin best={c2b}");
546    }
547}