Skip to main content

forge_core/algo/
lsrtde.rs

1//! L-SRTDE — Success Rate-based adaptive Differential Evolution
2//! (Stanovov & Semenkin 2024, CEC 2024 bound-constrained competition **winner**).
3//!
4//! L-SRTDE marks the shift in adaptive DE from *fitness-magnitude* / success-
5//! history adaptation toward **success-rate** adaptation: both control knobs
6//! are driven by the fraction of trials that improved in the previous
7//! generation (`SR`). The published rules are
8//!
9//! ```text
10//! mF = 0.4 + 0.25 · tanh(5 · SR)          F  ~ Normal(mF, 0.02), resampled to [0, 1]
11//! pb = max(2, ⌊0.7 · exp(−7 · SR) · N⌋)   (elite pool for the p-best donor)
12//! ```
13//!
14//! so a high success rate raises `F` (bolder steps) while *shrinking* the
15//! elite pool (more selective pressure); a low success rate lowers `F` and
16//! widens the pool. Structurally L-SRTDE inherits **L-NTADE**'s two-population
17//! scheme (Stanovov & Semenkin 2022) rather than L-SHADE's single population:
18//!
19//! - a **front** population that trials compete against (a random front member
20//!   is the target each step) and into which successful trials are inserted
21//!   circularly, and
22//! - a **newest** population that grows with successful trials during the
23//!   generation and is truncated back to the best `N` at the end.
24//!
25//! The mutant is `front[k] + F·(newest[pbest] − front[k]) + F·(front[r1] −
26//! newest[r2])` with `r1` drawn from the front by a rank-based exponential
27//! distribution (`weight ∝ exp(−3·rank/N)`) and `r2` uniform from the newest
28//! population. There is **no external archive**. `CR` uses a small
29//! success-history memory (size 5, init 1.0) sampled `Normal(m_CR, 0.05)`,
30//! stores the *repaired* CR (realized crossover fraction), and updates by a
31//! smoothed fitness-delta-weighted Lehmer mean. Out-of-bounds components are
32//! re-drawn uniformly in the box. Population size follows LPSR down to 4.
33//!
34//! Fidelity note: constants and structure follow the author's public
35//! reference implementation (github.com/VladimirStanovov/L-SRTDE_CEC-2024,
36//! reimplemented from the algorithm description — no code copied). Known
37//! deviations: the budget is enforced per evaluation (the crate contract)
38//! instead of per generation, and the circular front-insertion index is
39//! wrapped after LPSR shrinks the front. Implements [`Optimizer`];
40//! deterministic for a given seed.
41
42use super::Optimizer;
43use crate::problem::Problem;
44use crate::rng::Rng;
45use crate::solution::{Report, Solution, StopReason};
46use crate::termination::Termination;
47
48/// L-SRTDE configuration.
49#[derive(Debug, Clone, Copy)]
50pub struct LSrtde {
51    /// Initial population size `N_init`; `None` uses the paper's `20 · dim`.
52    pub init_pop: Option<usize>,
53    /// Success-history memory size `H` for the crossover rate.
54    pub memory: usize,
55    /// RNG seed; same seed + same problem + same budget ⇒ same result.
56    pub seed: u64,
57}
58
59impl Default for LSrtde {
60    fn default() -> Self {
61        LSrtde {
62            init_pop: None,
63            memory: 5,
64            seed: 42,
65        }
66    }
67}
68
69const N_MIN: usize = 4;
70
71impl Optimizer for LSrtde {
72    fn with_seed(&self, seed: u64) -> Self {
73        LSrtde { seed, ..*self }
74    }
75
76    fn optimize(&self, problem: &dyn Problem, term: &Termination) -> Report {
77        crate::problem::validate(problem)
78            .unwrap_or_else(|e| panic!("LSrtde: invalid problem: {e}"));
79        let bounds = problem.bounds();
80        let dim = bounds.len();
81        let mut rng = Rng::new(self.seed);
82
83        let n_init = self.init_pop.unwrap_or(20 * dim).max(N_MIN);
84        let h = self.memory.max(1);
85        let max_nfe = term.max_evaluations.max(1);
86
87        let eval = |x: &[f64], problem: &dyn Problem| -> f64 {
88            let v = problem.objective(x);
89            if v.is_finite() {
90                v
91            } else {
92                f64::INFINITY
93            }
94        };
95
96        // CR success-history memory, init 1.0 (F is success-rate driven).
97        let mut m_cr = vec![1.0f64; h];
98        let mut k_pos = 0usize;
99
100        // Newest population (grows with successes, truncated per generation).
101        let mut pop: Vec<Vec<f64>> = Vec::with_capacity(2 * n_init);
102        let mut fit: Vec<f64> = Vec::with_capacity(2 * n_init);
103        let mut best = Solution {
104            x: vec![0.0; dim],
105            value: f64::INFINITY,
106        };
107        let mut nfe = 0usize;
108        for _ in 0..n_init {
109            if term.reason(nfe, best.value).is_some() {
110                break;
111            }
112            let x: Vec<f64> = bounds
113                .iter()
114                .map(|&(lo, hi)| rng.uniform_in(lo, hi))
115                .collect();
116            let f = eval(&x, problem);
117            nfe += 1;
118            if f < best.value {
119                best = Solution {
120                    x: x.clone(),
121                    value: f,
122                };
123            }
124            pop.push(x);
125            fit.push(f);
126        }
127
128        // Front population: the initial individuals sorted best-first.
129        let mut order: Vec<usize> = (0..pop.len()).collect();
130        order.sort_by(|&a, &b| {
131            fit[a]
132                .partial_cmp(&fit[b])
133                .unwrap_or(std::cmp::Ordering::Equal)
134        });
135        let mut front: Vec<Vec<f64>> = order.iter().map(|&i| pop[i].clone()).collect();
136        let mut front_fit: Vec<f64> = order.iter().map(|&i| fit[i]).collect();
137        // Keep the newest population sorted too (matches the reference's
138        // post-truncation state at the top of each generation).
139        pop = front.clone();
140        fit = front_fit.clone();
141
142        let mut n = front.len(); // current front size (LPSR shrinks it)
143        let n_init_actual = n.max(1);
144        let mut pf_index = 0usize; // circular front-insertion cursor
145        let mut success_rate: f64 = 0.5;
146        let mut trial = vec![0.0; dim];
147
148        'outer: while n >= N_MIN && term.reason(nfe, best.value).is_none() {
149            let m_f = 0.4 + 0.25 * (5.0 * success_rate).tanh();
150            // Elite-pool size shrinks as the success rate rises.
151            let p_num = ((0.7 * (-7.0 * success_rate).exp() * n as f64) as usize).clamp(2, n);
152
153            // Rank orders: `ranked` over the newest population, `ranked_f`
154            // over the front (best first).
155            let mut ranked: Vec<usize> = (0..n).collect();
156            ranked.sort_by(|&a, &b| {
157                fit[a]
158                    .partial_cmp(&fit[b])
159                    .unwrap_or(std::cmp::Ordering::Equal)
160            });
161            let mut ranked_f: Vec<usize> = (0..n).collect();
162            ranked_f.sort_by(|&a, &b| {
163                front_fit[a]
164                    .partial_cmp(&front_fit[b])
165                    .unwrap_or(std::cmp::Ordering::Equal)
166            });
167            // Rank-based exponential weights for the front donor: cumulative
168            // distribution over exp(-3·rank/n).
169            let cum: Vec<f64> = {
170                let mut acc = 0.0;
171                let w: Vec<f64> = (0..n).map(|i| (-3.0 * i as f64 / n as f64).exp()).collect();
172                let total: f64 = w.iter().sum();
173                w.iter()
174                    .map(|v| {
175                        acc += v / total;
176                        acc
177                    })
178                    .collect()
179            };
180            let rank_sample = |rng: &mut Rng| -> usize {
181                let u = rng.uniform();
182                match cum.iter().position(|&c| u < c) {
183                    Some(r) => r,
184                    None => n - 1,
185                }
186            };
187
188            let mut succ_cr: Vec<f64> = Vec::new();
189            let mut delta: Vec<f64> = Vec::new();
190            let mut successes = 0usize;
191
192            for _ in 0..n {
193                if term.reason(nfe, best.value).is_some() {
194                    break 'outer;
195                }
196                // Random front member is the target this step.
197                let chosen = rng.index(n);
198                let cell = rng.index(h);
199                let prand = loop {
200                    let z = ranked[rng.index(p_num)];
201                    if z != chosen {
202                        break z;
203                    }
204                };
205                let r1 = loop {
206                    let z = ranked_f[rank_sample(&mut rng)];
207                    if z != prand {
208                        break z;
209                    }
210                };
211                let r2 = loop {
212                    let z = ranked[rng.index(n)];
213                    if z != prand && z != r1 {
214                        break z;
215                    }
216                };
217
218                // F ~ Normal(mF, 0.02) resampled into [0, 1].
219                let scale = loop {
220                    let v = m_f + 0.02 * rng.normal();
221                    if (0.0..=1.0).contains(&v) {
222                        break v;
223                    }
224                };
225                let cr = (m_cr[cell] + 0.05 * rng.normal()).clamp(0.0, 1.0);
226
227                let jrand = rng.index(dim);
228                let mut from_mutant = 0usize;
229                for j in 0..dim {
230                    let (lo, hi) = bounds[j];
231                    if rng.uniform() < cr || j == jrand {
232                        let mut v = front[chosen][j]
233                            + scale * (pop[prand][j] - front[chosen][j])
234                            + scale * (front[r1][j] - pop[r2][j]);
235                        if v < lo || v > hi {
236                            // Reference rule: re-draw uniformly in the box.
237                            v = rng.uniform_in(lo, hi);
238                        }
239                        trial[j] = v;
240                        from_mutant += 1;
241                    } else {
242                        trial[j] = front[chosen][j];
243                    }
244                }
245
246                let tf = eval(&trial, problem);
247                nfe += 1;
248                if tf < best.value {
249                    best = Solution {
250                        x: trial.clone(),
251                        value: tf,
252                    };
253                }
254                // Ties count as successes (reference: `<=`).
255                if tf <= front_fit[chosen] {
256                    // Repaired CR: the realized crossover fraction.
257                    succ_cr.push(from_mutant as f64 / dim as f64);
258                    let d = (front_fit[chosen] - tf).abs();
259                    delta.push(if d.is_finite() { d } else { f64::MAX });
260                    successes += 1;
261                    // Grow the newest population; insert circularly into the front.
262                    pop.push(trial.clone());
263                    fit.push(tf);
264                    front[pf_index].copy_from_slice(&trial);
265                    front_fit[pf_index] = tf;
266                    pf_index = (pf_index + 1) % n;
267                }
268            }
269
270            success_rate = successes as f64 / n as f64;
271
272            // CR memory: smoothed weighted Lehmer mean (fallback 1.0), only
273            // advancing the cursor when the generation produced successes.
274            if !succ_cr.is_empty() {
275                let total: f64 = delta.iter().sum();
276                let w: Vec<f64> = if total.is_finite() && total > 0.0 {
277                    delta.iter().map(|d| d / total).collect()
278                } else {
279                    vec![1.0 / succ_cr.len() as f64; succ_cr.len()]
280                };
281                let num: f64 = w.iter().zip(&succ_cr).map(|(wk, c)| wk * c * c).sum();
282                let den: f64 = w.iter().zip(&succ_cr).map(|(wk, c)| wk * c).sum();
283                let lehmer = if den.abs() > 1e-8 { num / den } else { 1.0 };
284                m_cr[k_pos] = 0.5 * (lehmer + m_cr[k_pos]);
285                k_pos = (k_pos + 1) % h;
286            }
287
288            // LPSR on the front (truncation toward N_MIN = 4), dropping the
289            // worst front members; the newest population is truncated to the
290            // best `n` of (previous newest + this generation's successes).
291            let target = ((N_MIN as f64 - n_init_actual as f64) / max_nfe as f64) * nfe as f64
292                + n_init_actual as f64;
293            let n_new = (target as usize).clamp(N_MIN, n);
294            if n_new < n {
295                // Remove the worst front members, preserving insertion order.
296                let mut keep: Vec<usize> = (0..n).collect();
297                keep.sort_by(|&a, &b| {
298                    front_fit[a]
299                        .partial_cmp(&front_fit[b])
300                        .unwrap_or(std::cmp::Ordering::Equal)
301                });
302                keep.truncate(n_new);
303                keep.sort_unstable();
304                front = keep
305                    .iter()
306                    .map(|&i| std::mem::take(&mut front[i]))
307                    .collect();
308                front_fit = keep.iter().map(|&i| front_fit[i]).collect();
309                n = n_new;
310                if pf_index >= n {
311                    pf_index = 0;
312                }
313            }
314            // Truncate the newest population to the best `n`.
315            if pop.len() > n {
316                let mut order: Vec<usize> = (0..pop.len()).collect();
317                order.sort_by(|&a, &b| {
318                    fit[a]
319                        .partial_cmp(&fit[b])
320                        .unwrap_or(std::cmp::Ordering::Equal)
321                });
322                order.truncate(n);
323                pop = order.iter().map(|&i| std::mem::take(&mut pop[i])).collect();
324                fit = order.iter().map(|&i| fit[i]).collect();
325            }
326        }
327
328        let stop = term
329            .reason(nfe, best.value)
330            .unwrap_or(StopReason::BudgetExhausted);
331        Report {
332            solution: best,
333            stop,
334            evaluations: nfe,
335        }
336    }
337}