forge_core/algo/sce.rs
1//! Shuffled Complex Evolution — SCE-UA (Duan, Sorooshian & Gupta 1992,
2//! Water Resources Research 28, 1015–1031).
3//!
4//! A robust global optimizer that partitions a random population into
5//! *complexes*, evolves each by Competitive Complex Evolution (a
6//! reflection/contraction simplex with triangular parent selection), then
7//! periodically shuffles the complexes back together. More expensive than DDS
8//! but stronger on multimodal surfaces. Migrated from the implementation
9//! validated in `rainflow-core`; minimizing here (the paper's sense), whereas
10//! rainflow wrapped it to maximize NSE/KGE.
11
12use super::{clamp, sample, Evaluator, Optimizer};
13use crate::problem::{Bound, Problem};
14use crate::rng::Rng;
15use crate::solution::{Report, Solution};
16use crate::termination::Termination;
17
18/// SCE-UA configuration. The complex geometry (points per complex, sub-complex
19/// size, evolution steps) follows the standard `m = 2n+1`, `q = n+1`, `β = m`
20/// of Duan et al. 1992, so only the number of complexes is exposed.
21#[derive(Debug, Clone, Copy)]
22pub struct Sce {
23 /// Number of complexes `p` (≥ 1). More complexes ⇒ more global, slower.
24 pub complexes: usize,
25 /// RNG seed; same seed + same problem + same budget ⇒ same result.
26 pub seed: u64,
27}
28
29impl Default for Sce {
30 fn default() -> Self {
31 Sce {
32 complexes: 4,
33 seed: 42,
34 }
35 }
36}
37
38impl Optimizer for Sce {
39 fn with_seed(&self, seed: u64) -> Self {
40 Sce { seed, ..*self }
41 }
42
43 /// Minimizes `problem` within its bounds using SCE-UA.
44 ///
45 /// Stops on the [`Termination`] budget/target only; the paper's own
46 /// convergence criteria (`kstop`/`pcento`/`peps`) are not implemented.
47 fn optimize(&self, problem: &dyn Problem, term: &Termination) -> Report {
48 crate::problem::validate(problem).unwrap_or_else(|e| panic!("Sce: invalid problem: {e}"));
49 let bounds = problem.bounds();
50 let n = bounds.len();
51 let complexes = self.complexes.max(1);
52
53 // Standard SCE-UA complex geometry.
54 let m = 2 * n + 1; // points per complex
55 let q = n + 1; // points per sub-complex (simplex)
56 let beta = m; // evolution steps per complex per shuffle
57 let pop_size = complexes * m;
58
59 let mut rng = Rng::new(self.seed);
60
61 // Seed the evaluator with the first sampled point, then fill the rest.
62 let first_x = sample(bounds, &mut rng);
63 let first_v = problem.objective(&first_x);
64 let mut ev = Evaluator::new(
65 problem,
66 term,
67 Solution {
68 x: first_x.clone(),
69 value: finite_or_worst(first_v),
70 },
71 );
72
73 let mut pop: Vec<(Vec<f64>, f64)> = Vec::with_capacity(pop_size);
74 pop.push((first_x, finite_or_worst(first_v)));
75 for _ in 1..pop_size {
76 if ev.done() {
77 break;
78 }
79 let x = sample(bounds, &mut rng);
80 let s = finite_or_worst(ev.eval(&x));
81 pop.push((x, s));
82 }
83
84 let cmp = |a: &(Vec<f64>, f64), b: &(Vec<f64>, f64)| {
85 a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
86 };
87
88 while !ev.done() && pop.len() == pop_size {
89 pop.sort_by(cmp);
90
91 // Partition the rank-sorted population into complexes round-robin.
92 for k in 0..complexes {
93 let mut complex: Vec<(Vec<f64>, f64)> =
94 (0..m).map(|j| pop[k + j * complexes].clone()).collect();
95
96 // Competitive Complex Evolution: evolve `beta` times.
97 for _ in 0..beta {
98 if ev.done() {
99 break;
100 }
101 complex.sort_by(cmp);
102 // Triangular selection biases parents toward better points.
103 let parents = triangular_subset(m, q, &mut rng);
104 let worst_idx = *parents.last().unwrap();
105
106 // Centroid of the q-1 best parents (excluding the worst).
107 let mut centroid = vec![0.0; n];
108 for &pi in &parents[..q - 1] {
109 for (c, &v) in centroid.iter_mut().zip(&complex[pi].0) {
110 *c += v;
111 }
112 }
113 let qm1 = (q - 1) as f64;
114 for c in &mut centroid {
115 *c /= qm1;
116 }
117
118 // Reflect the worst point through the centroid.
119 let worst = complex[worst_idx].0.clone();
120 let mut trial: Vec<f64> = (0..n)
121 .map(|d| centroid[d] + (centroid[d] - worst[d]))
122 .collect();
123 let in_bounds = trial
124 .iter()
125 .zip(bounds)
126 .all(|(&v, &(lo, hi))| v >= lo && v <= hi);
127
128 // One CCE step can cost up to 3 evaluations; the budget is
129 // re-checked before each so the contract is never exceeded.
130 let mut tf;
131 if in_bounds {
132 tf = finite_or_worst(ev.eval(&trial));
133 // Ties are accepted (paper: replace when not worse).
134 if tf > complex[worst_idx].1 {
135 if ev.done() {
136 break;
137 }
138 // Reflection failed: contract toward the centroid.
139 trial = (0..n).map(|d| 0.5 * (centroid[d] + worst[d])).collect();
140 tf = finite_or_worst(ev.eval(&trial));
141 if tf > complex[worst_idx].1 {
142 if ev.done() {
143 break;
144 }
145 // Contraction failed too: random mutation in hull.
146 trial = sample_in_hull(&complex, bounds, &mut rng);
147 tf = finite_or_worst(ev.eval(&trial));
148 }
149 }
150 } else {
151 // Out of bounds: random mutation in the complex's hull.
152 trial = sample_in_hull(&complex, bounds, &mut rng);
153 tf = finite_or_worst(ev.eval(&trial));
154 }
155
156 complex[worst_idx] = (trial, tf);
157 }
158
159 // Shuffle the evolved complex back into the population.
160 for (j, point) in complex.into_iter().enumerate() {
161 pop[k + j * complexes] = point;
162 }
163 }
164 }
165
166 ev.finish()
167 }
168}
169
170/// Non-finite scores are treated as the worst possible (`+∞`), so degenerate
171/// parameter combinations are rejected rather than poisoning the population.
172#[inline]
173fn finite_or_worst(v: f64) -> f64 {
174 if v.is_finite() {
175 v
176 } else {
177 f64::INFINITY
178 }
179}
180
181/// Picks `q` distinct indices from `0..m` with a triangular probability that
182/// favors lower (better, since the complex is rank-sorted) indices, returned
183/// sorted ascending (Duan et al. 1992, trapezoidal distribution).
184fn triangular_subset(m: usize, q: usize, rng: &mut Rng) -> Vec<usize> {
185 let mut chosen = Vec::with_capacity(q);
186 let mf = m as f64;
187 while chosen.len() < q {
188 // P(i) = 2(m - i)/(m(m+1)); inverse-CDF sampling on rank.
189 let u = rng.uniform();
190 let idx = ((mf + 0.5) - ((mf + 0.5).powi(2) - mf * (mf + 1.0) * u).sqrt()) as usize;
191 let idx = idx.min(m - 1);
192 if !chosen.contains(&idx) {
193 chosen.push(idx);
194 }
195 }
196 chosen.sort_unstable();
197 chosen
198}
199
200/// Uniform sample inside the smallest axis-aligned box containing the complex,
201/// clamped to the global bounds (SCE-UA's mutation step).
202fn sample_in_hull(complex: &[(Vec<f64>, f64)], bounds: &[Bound], rng: &mut Rng) -> Vec<f64> {
203 let n = bounds.len();
204 (0..n)
205 .map(|d| {
206 let mut lo = complex[0].0[d];
207 let mut hi = lo;
208 for point in complex {
209 lo = lo.min(point.0[d]);
210 hi = hi.max(point.0[d]);
211 }
212 let v = rng.uniform_in(lo, hi);
213 clamp(v, bounds[d].0, bounds[d].1)
214 })
215 .collect()
216}