Skip to main content

forge_core/algo/
lshade.rs

1//! L-SHADE — Success-History based Adaptive DE with Linear Population Size
2//! Reduction (Tanabe & Fukunaga 2014, CEC).
3//!
4//! The reference adaptive Differential Evolution: the SHADE/L-SHADE lineage won
5//! the CEC single-objective competitions repeatedly from 2014 on, and JADE /
6//! SaDE / SHADE / L-SHADE are the canonical baselines a continuous optimizer is
7//! expected to beat. It upgrades plain `rand/1/bin` ([`De`](super::De)) on three
8//! fronts:
9//!
10//! - **`current-to-pbest/1`** mutation with an **external archive** of replaced
11//!   parents (greedy yet diverse).
12//! - **Success-history adaptation** of the scaling factor `F` and crossover rate
13//!   `CR` from a memory of the values that produced improvements (weighted
14//!   Lehmer means), so the user never tunes them.
15//! - **Linear Population Size Reduction (LPSR)**: the population shrinks from
16//!   `N_init` to 4 over the evaluation budget, shifting explore → exploit.
17//!
18//! Implements [`Optimizer`] and is deterministic for a given seed.
19
20use super::Optimizer;
21use crate::problem::Problem;
22use crate::rng::Rng;
23use crate::solution::{Report, Solution, StopReason};
24use crate::termination::Termination;
25
26/// L-SHADE configuration.
27#[derive(Debug, Clone, Copy)]
28pub struct LShade {
29    /// Initial population size `N_init`; `None` uses the paper's `18 · dim`.
30    pub init_pop: Option<usize>,
31    /// Success-history memory size `H` (paper default 6).
32    pub memory: usize,
33    /// `p` for `current-to-pbest`: the top `p·N` individuals are pbest
34    /// candidates (paper default 0.11).
35    pub p_best: f64,
36    /// Archive size as a multiple of the current population (paper default 2.6).
37    pub archive_rate: f64,
38    /// RNG seed; same seed + same problem + same budget ⇒ same result.
39    pub seed: u64,
40}
41
42impl Default for LShade {
43    fn default() -> Self {
44        LShade {
45            init_pop: None,
46            memory: 6,
47            p_best: 0.11,
48            archive_rate: 2.6,
49            seed: 42,
50        }
51    }
52}
53
54/// Minimum population size L-SHADE shrinks to (the `current-to-pbest/1` donor
55/// count).
56const N_MIN: usize = 4;
57
58impl Optimizer for LShade {
59    fn with_seed(&self, seed: u64) -> Self {
60        LShade { seed, ..*self }
61    }
62
63    fn optimize(&self, problem: &dyn Problem, term: &Termination) -> Report {
64        crate::problem::validate(problem)
65            .unwrap_or_else(|e| panic!("LShade: invalid problem: {e}"));
66        let bounds = problem.bounds();
67        let dim = bounds.len();
68        let mut rng = Rng::new(self.seed);
69
70        let n_init = self.init_pop.unwrap_or(18 * dim).max(N_MIN);
71        let h = self.memory.max(1);
72        let max_nfe = term.max_evaluations.max(1);
73
74        let eval = |x: &[f64], problem: &dyn Problem| -> f64 {
75            let v = problem.objective(x);
76            if v.is_finite() {
77                v
78            } else {
79                f64::INFINITY
80            }
81        };
82
83        // Success-history memories. A NaN cell is the "terminal" CR (⊥), set
84        // when the only successful trials used CR = 0 (separable landscapes).
85        let mut m_cr = vec![0.5f64; h];
86        let mut m_f = vec![0.5f64; h];
87        let mut k_pos = 0usize;
88
89        // Initial population.
90        let mut pop: Vec<Vec<f64>> = Vec::with_capacity(n_init);
91        let mut fit: Vec<f64> = Vec::with_capacity(n_init);
92        let mut archive: Vec<Vec<f64>> = Vec::new();
93        let mut best = Solution {
94            x: vec![0.0; dim],
95            value: f64::INFINITY,
96        };
97        let mut nfe = 0usize;
98        for _ in 0..n_init {
99            // The budget applies during initialization too (large `N_init`
100            // with a small budget must not overspend).
101            if term.reason(nfe, best.value).is_some() {
102                break;
103            }
104            let x: Vec<f64> = bounds
105                .iter()
106                .map(|&(lo, hi)| rng.uniform_in(lo, hi))
107                .collect();
108            let f = eval(&x, problem);
109            nfe += 1;
110            if f < best.value {
111                best = Solution {
112                    x: x.clone(),
113                    value: f,
114                };
115            }
116            pop.push(x);
117            fit.push(f);
118        }
119        let mut n = pop.len();
120
121        let mut trial = vec![0.0; dim];
122        'outer: while n >= N_MIN && term.reason(nfe, best.value).is_none() {
123            // Rank for pbest selection.
124            let mut ranked: Vec<usize> = (0..n).collect();
125            ranked.sort_by(|&a, &b| {
126                fit[a]
127                    .partial_cmp(&fit[b])
128                    .unwrap_or(std::cmp::Ordering::Equal)
129            });
130            let p_num = ((self.p_best * n as f64).round() as usize).clamp(2, n);
131
132            let mut succ_cr: Vec<f64> = Vec::new();
133            let mut succ_f: Vec<f64> = Vec::new();
134            let mut delta: Vec<f64> = Vec::new();
135
136            for i in 0..n {
137                if term.reason(nfe, best.value).is_some() {
138                    break 'outer;
139                }
140                let r = rng.index(h);
141                let cr = if m_cr[r].is_nan() {
142                    0.0
143                } else {
144                    (m_cr[r] + 0.1 * rng.normal()).clamp(0.0, 1.0)
145                };
146                // F ~ Cauchy(M_F, 0.1), resampled while ≤ 0, truncated to 1.
147                let scale = loop {
148                    let v = m_f[r] + 0.1 * cauchy(&mut rng);
149                    if v > 0.0 {
150                        break v.min(1.0);
151                    }
152                };
153
154                let pbest = ranked[rng.index(p_num)];
155                let r1 = loop {
156                    let z = rng.index(n);
157                    if z != i {
158                        break z;
159                    }
160                };
161                let na = archive.len();
162                // r2 from population ∪ archive, distinct from i and r1.
163                let r2 = loop {
164                    let z = rng.index(n + na);
165                    if z >= n || (z != i && z != r1) {
166                        break z;
167                    }
168                };
169                let x_r2: &[f64] = if r2 < n { &pop[r2] } else { &archive[r2 - n] };
170
171                // current-to-pbest/1 mutation + binomial crossover, repaired.
172                let jrand = rng.index(dim);
173                for j in 0..dim {
174                    let (lo, hi) = bounds[j];
175                    if rng.uniform() <= cr || j == jrand {
176                        let mut v = pop[i][j]
177                            + scale * (pop[pbest][j] - pop[i][j])
178                            + scale * (pop[r1][j] - x_r2[j]);
179                        // L-SHADE bound repair: midpoint toward the parent.
180                        if v < lo {
181                            v = (lo + pop[i][j]) / 2.0;
182                        } else if v > hi {
183                            v = (hi + pop[i][j]) / 2.0;
184                        }
185                        trial[j] = v;
186                    } else {
187                        trial[j] = pop[i][j];
188                    }
189                }
190
191                let tf = eval(&trial, problem);
192                nfe += 1;
193                if tf < best.value {
194                    best = Solution {
195                        x: trial.clone(),
196                        value: tf,
197                    };
198                }
199                // Greedy selection; improvements feed the archive and history.
200                if tf <= fit[i] {
201                    if tf < fit[i] {
202                        succ_cr.push(cr);
203                        succ_f.push(scale);
204                        // An infinite parent fitness would give an infinite
205                        // improvement and NaN-poison the memory weights; cap it.
206                        let d = fit[i] - tf;
207                        delta.push(if d.is_finite() { d } else { f64::MAX });
208                        // Paper rule: a full archive replaces a random member
209                        // at insertion time (not a deferred batch trim).
210                        let arc_size = (self.archive_rate * n as f64).round() as usize;
211                        if archive.len() < arc_size.max(1) {
212                            archive.push(pop[i].clone());
213                        } else if arc_size > 0 {
214                            let idx = rng.index(archive.len());
215                            archive[idx] = pop[i].clone();
216                        }
217                    }
218                    pop[i].copy_from_slice(&trial);
219                    fit[i] = tf;
220                }
221            }
222
223            // Update one memory cell from this generation's successes.
224            let total: f64 = delta.iter().sum();
225            if !succ_f.is_empty() && total.is_finite() && total > 0.0 {
226                let w: Vec<f64> = delta.iter().map(|d| d / total).collect();
227                m_f[k_pos] = lehmer(&w, &succ_f);
228                let max_cr = succ_cr.iter().copied().fold(f64::NEG_INFINITY, f64::max);
229                m_cr[k_pos] = if m_cr[k_pos].is_nan() || max_cr == 0.0 {
230                    f64::NAN
231                } else {
232                    lehmer(&w, &succ_cr)
233                };
234                k_pos = (k_pos + 1) % h;
235            }
236
237            // Linear Population Size Reduction.
238            let target =
239                ((N_MIN as f64 - n_init as f64) / max_nfe as f64) * nfe as f64 + n_init as f64;
240            let n_new = (target.round() as usize).clamp(N_MIN, n);
241            if n_new < n {
242                let mut ranked2: Vec<usize> = (0..n).collect();
243                ranked2.sort_by(|&a, &b| {
244                    fit[a]
245                        .partial_cmp(&fit[b])
246                        .unwrap_or(std::cmp::Ordering::Equal)
247                });
248                ranked2.truncate(n_new);
249                let new_pop: Vec<Vec<f64>> = ranked2.iter().map(|&i| pop[i].clone()).collect();
250                let new_fit: Vec<f64> = ranked2.iter().map(|&i| fit[i]).collect();
251                pop = new_pop;
252                fit = new_fit;
253                n = n_new;
254                let arc2 = (self.archive_rate * n as f64).round() as usize;
255                while archive.len() > arc2 {
256                    let idx = rng.index(archive.len());
257                    archive.swap_remove(idx);
258                }
259            }
260        }
261
262        let stop = term
263            .reason(nfe, best.value)
264            .unwrap_or(StopReason::BudgetExhausted);
265        Report {
266            solution: best,
267            stop,
268            evaluations: nfe,
269        }
270    }
271}
272
273/// Weighted Lehmer mean `Σ wₖ sₖ² / Σ wₖ sₖ` (the SHADE memory update).
274fn lehmer(w: &[f64], s: &[f64]) -> f64 {
275    let num: f64 = w.iter().zip(s).map(|(wk, sk)| wk * sk * sk).sum();
276    let den: f64 = w.iter().zip(s).map(|(wk, sk)| wk * sk).sum();
277    if den != 0.0 {
278        num / den
279    } else {
280        0.5
281    }
282}
283
284/// A standard Cauchy(0, 1) sample.
285fn cauchy(rng: &mut Rng) -> f64 {
286    (std::f64::consts::PI * (rng.uniform() - 0.5)).tan()
287}