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}