Skip to main content

fcmaes_core/
da.rs

1//! Dual Annealing with optional bounded local search.
2//!
3//! The global phase is generalized simulated annealing:
4//! a distorted Cauchy-Lorentz visiting distribution, a Markov strategy chain
5//! with generalized accept/reject, re-annealing, and an optional local search.
6//! The local phase is a self-contained projected limited-memory quasi-Newton
7//! search on the normalized box, using finite-difference gradients.
8//!
9//! # Reference
10//!
11//! Y. Xiang, D. Y. Sun, W. Fan, and X. G. Gong, [“Generalized Simulated
12//! Annealing Algorithm and Its Application to the Thomson
13//! Model”](https://doi.org/10.1016/S0375-9601(97)00474-X), *Physics Letters A*
14//! 233, 216–220 (1997).
15//!
16//! # Example
17//!
18//! ```
19//! use fcmaes_core::{DaParams, optimize_da};
20//!
21//! let sphere = |x: &[f64]| x.iter().map(|v| v * v).sum::<f64>();
22//! let params = DaParams {
23//!     max_evaluations: 1_000,
24//!     seed: 5,
25//!     ..Default::default()
26//! };
27//! let result = optimize_da(
28//!     &sphere,
29//!     &[1.0; 3],
30//!     vec![-5.0; 3],
31//!     vec![5.0; 3],
32//!     &params,
33//! );
34//! assert!(result.y.is_finite());
35//! ```
36
37use crate::fitness::Objective;
38use crate::rng::Rng;
39
40/// Outcome of a Dual Annealing run.
41#[derive(Clone, Debug)]
42pub struct DaResult {
43    /// Best decoded decision vector found.
44    pub x: Vec<f64>,
45    /// Objective value at [`x`](Self::x).
46    pub y: f64,
47    /// Number of objective evaluations charged to the run.
48    pub evaluations: u64,
49    /// Reserved iteration counter; currently zero.
50    pub iterations: i32,
51    /// Termination code; `-1` indicates initialization failed.
52    pub stop: i32,
53}
54
55/// Tunable inputs for [`optimize_da`].
56#[derive(Clone, Debug)]
57pub struct DaParams {
58    /// Maximum number of global and local objective evaluations combined.
59    pub max_evaluations: u64,
60    /// Enable the projected limited-memory local-search phase.
61    pub use_local_search: bool,
62    /// Seed for the optimizer's independent random stream.
63    pub seed: u64,
64    /// Additional run identifier mixed into [`seed`](Self::seed).
65    pub runid: i64,
66}
67
68impl Default for DaParams {
69    fn default() -> Self {
70        Self {
71            max_evaluations: 100_000,
72            use_local_search: true,
73            seed: 0,
74            runid: 0,
75        }
76    }
77}
78
79const BIG_VALUE: f64 = 1e16;
80const TAIL_LIMIT: f64 = 1e8;
81const MIN_VISIT_BOUND: f64 = 1e-10;
82const MAX_REINIT_COUNT: i32 = 1000;
83
84const TEMPERATURE_START: f64 = 5230.0;
85const QV: f64 = 2.62;
86const QA: f64 = -5.0;
87const MAXSTEPS: i32 = 1000;
88const TEMPERATURE_RESTART: f64 = 0.1;
89
90struct Da<'a, O: Objective> {
91    obj: &'a O,
92    dim: usize,
93    has_bounds: bool,
94    lower: Vec<f64>,
95    scale: Vec<f64>,
96    max_evals: u64,
97    eval_counter: u64,
98    use_local_search: bool,
99    rng: Rng,
100
101    // fitness best (reset per local search)
102    fit_best_y: f64,
103    fit_best_x: Vec<f64>,
104
105    // visiting distribution factors
106    factor4_p: f64,
107    factor6: f64,
108
109    // energy state
110    ebest: f64,
111    xbest: Vec<f64>,
112    current_energy: f64,
113    current_location: Vec<f64>,
114
115    // strategy chain
116    emin: f64,
117    xmin: Vec<f64>,
118    not_improved_idx: i32,
119    not_improved_max_idx: i32,
120    temperature_step: f64,
121    k: f64,
122    state_improved: bool,
123
124    reinit_failed: bool,
125}
126
127impl<'a, O: Objective> Da<'a, O> {
128    fn new(
129        obj: &'a O,
130        dim: usize,
131        lower: Vec<f64>,
132        upper: Vec<f64>,
133        max_evals: u64,
134        use_local_search: bool,
135        seed: u64,
136    ) -> Self {
137        let has_bounds = !lower.is_empty();
138        let scale: Vec<f64> = if has_bounds {
139            upper.iter().zip(&lower).map(|(u, l)| u - l).collect()
140        } else {
141            vec![1.0; dim]
142        };
143        // visiting-distribution invariants
144        let factor2 = ((4.0 - QV) * (QV - 1.0).ln()).exp();
145        let factor3 = ((2.0 - QV) * 2.0_f64.ln() / (QV - 1.0)).exp();
146        let factor4_p = std::f64::consts::PI.sqrt() * factor2 / (factor3 * (3.0 - QV));
147        let factor5 = 1.0 / (QV - 1.0) - 0.5;
148        let d1 = 2.0 - factor5;
149        let factor6 = std::f64::consts::PI * (1.0 - factor5)
150            / (std::f64::consts::PI * (1.0 - factor5)).sin()
151            / libm::lgamma(d1).exp();
152        Da {
153            obj,
154            dim,
155            has_bounds,
156            lower,
157            scale,
158            max_evals,
159            eval_counter: 0,
160            use_local_search,
161            rng: Rng::new(seed),
162            fit_best_y: f64::MAX,
163            fit_best_x: vec![],
164            factor4_p,
165            factor6,
166            ebest: f64::MAX,
167            xbest: vec![],
168            current_energy: f64::MAX,
169            current_location: vec![],
170            emin: f64::MAX,
171            xmin: vec![],
172            not_improved_idx: 0,
173            not_improved_max_idx: 1000,
174            temperature_step: 0.0,
175            k: 100.0 * dim as f64,
176            state_improved: false,
177            reinit_failed: false,
178        }
179    }
180
181    fn closest_feasible(&self, x: &[f64]) -> Vec<f64> {
182        if self.has_bounds {
183            x.iter().map(|&v| v.clamp(-1.0, 1.0)).collect()
184        } else {
185            x.to_vec()
186        }
187    }
188
189    fn encode(&self, x: &[f64]) -> Vec<f64> {
190        if self.has_bounds {
191            (0..self.dim)
192                .map(|i| (x[i] - self.lower[i]) / self.scale[i])
193                .collect()
194        } else {
195            x.to_vec()
196        }
197    }
198
199    fn decode(&self, x: &[f64]) -> Vec<f64> {
200        if self.has_bounds {
201            (0..self.dim)
202                .map(|i| x[i] * self.scale[i] + self.lower[i])
203                .collect()
204        } else {
205            x.to_vec()
206        }
207    }
208
209    fn raw_eval(&mut self, x_decoded: &[f64]) -> f64 {
210        self.eval_counter += 1;
211        self.obj.eval_scalar(x_decoded)
212    }
213
214    /// Evaluate an *encoded* point, tracking the local-search best.
215    fn value(&mut self, x: &[f64]) -> f64 {
216        let res = if self.has_bounds {
217            let feas = self.closest_feasible(x);
218            let dec = self.decode(&feas);
219            self.raw_eval(&dec)
220        } else {
221            self.raw_eval(x)
222        };
223        if res < self.fit_best_y {
224            self.fit_best_y = res;
225            self.fit_best_x = x.to_vec();
226        }
227        res
228    }
229
230    fn max_eval_reached(&self) -> bool {
231        self.eval_counter >= self.max_evals
232    }
233
234    fn normal_vec(&mut self) -> Vec<f64> {
235        (0..self.dim).map(|_| self.rng.gaussian()).collect()
236    }
237    fn uniform_vec(&mut self) -> Vec<f64> {
238        (0..self.dim).map(|_| self.rng.uniform01()).collect()
239    }
240
241    // ---- visiting distribution ----
242
243    fn visit_fn(&mut self, temperature: f64, n: usize) -> Vec<f64> {
244        let x: Vec<f64> = (0..n).map(|_| self.rng.gaussian()).collect();
245        let y: Vec<f64> = (0..n).map(|_| self.rng.gaussian()).collect();
246        let factor1 = (temperature.ln() / (QV - 1.0)).exp();
247        let factor4 = self.factor4_p * factor1;
248        let sigmax = (-(QV - 1.0) * (self.factor6 / factor4).ln() / (3.0 - QV)).exp();
249        (0..n)
250            .map(|i| {
251                let xi = x[i] * sigmax;
252                let den = ((y[i].abs() * (QV - 1.0)).ln() / (3.0 - QV)).exp();
253                xi / den
254            })
255            .collect()
256    }
257
258    fn visiting(&mut self, x: &[f64], step: usize, temperature: f64) -> Vec<f64> {
259        if step < self.dim {
260            let upper_sample = self.rng.uniform01();
261            let lower_sample = self.rng.uniform01();
262            let mut visits = self.visit_fn(temperature, self.dim);
263            for v in visits.iter_mut() {
264                if *v > TAIL_LIMIT {
265                    *v = TAIL_LIMIT * upper_sample;
266                } else if *v < -TAIL_LIMIT {
267                    *v = -TAIL_LIMIT * lower_sample;
268                }
269            }
270            let mut x_visit: Vec<f64> = (0..self.dim).map(|i| visits[i] + x[i]).collect();
271            for xv in x_visit.iter_mut() {
272                let b = (*xv % 1.0) + 1.0;
273                *xv = b % 1.0;
274                if xv.abs() < MIN_VISIT_BOUND {
275                    *xv += 1e-10;
276                }
277            }
278            x_visit
279        } else {
280            let mut x_visit = x.to_vec();
281            let mut visit = self.visit_fn(temperature, 1)[0];
282            if visit > TAIL_LIMIT {
283                visit = TAIL_LIMIT * self.rng.uniform01();
284            } else if visit < -TAIL_LIMIT {
285                visit = -TAIL_LIMIT * self.rng.uniform01();
286            }
287            let index = step - self.dim;
288            x_visit[index] = visit + x[index];
289            let b = (x_visit[index] % 1.0) + 1.0;
290            x_visit[index] = b % 1.0;
291            if x_visit[index].abs() < MIN_VISIT_BOUND {
292                x_visit[index] += MIN_VISIT_BOUND;
293            }
294            x_visit
295        }
296    }
297
298    // ---- energy state ----
299
300    fn reset_energy(&mut self, x0: &[f64]) {
301        self.current_location = if x0.is_empty() {
302            self.normal_vec()
303        } else {
304            x0.to_vec()
305        };
306        let mut reinit_counter = 0;
307        loop {
308            self.current_energy = self.value(&self.current_location.clone());
309            if self.current_energy >= BIG_VALUE || self.current_energy.is_nan() {
310                if reinit_counter >= MAX_REINIT_COUNT {
311                    self.reinit_failed = true;
312                    return;
313                }
314                self.current_location = self.uniform_vec();
315                reinit_counter += 1;
316            } else {
317                if self.ebest == f64::MAX && self.xbest.is_empty() {
318                    self.ebest = self.current_energy;
319                    self.xbest = self.current_location.clone();
320                }
321                return;
322            }
323        }
324    }
325
326    // ---- strategy chain ----
327
328    fn accept_reject(&mut self, j: usize, e: f64, x_visit: &[f64]) {
329        let r = self.rng.uniform01();
330        let pqv_temp = (QA - 1.0) * (e - self.current_energy) / (self.temperature_step + 1.0);
331        let pqv = if pqv_temp < 0.0 {
332            0.0
333        } else {
334            (pqv_temp.ln() / (1.0 - QA)).exp()
335        };
336        if r <= pqv {
337            self.current_energy = e;
338            self.current_location = x_visit.to_vec();
339            self.xmin = self.current_location.clone();
340        }
341        if self.not_improved_idx >= self.not_improved_max_idx
342            && (j == 0 || self.current_energy < self.emin)
343        {
344            self.emin = self.current_energy;
345            self.xmin = self.current_location.clone();
346        }
347    }
348
349    fn run_chain(&mut self, step: usize, temperature: f64) {
350        self.temperature_step = temperature / (step as f64 + 1.0);
351        self.not_improved_idx += 1;
352        let iters = self.current_location.len() * 2;
353        for j in 0..iters {
354            if j == 0 {
355                self.state_improved = false;
356            }
357            if step == 0 && j == 0 {
358                self.state_improved = true;
359            }
360            let x_visit = self.visiting(&self.current_location.clone(), j, temperature);
361            let e = self.value(&x_visit);
362            if e < self.current_energy {
363                self.current_energy = e;
364                self.current_location = x_visit.clone();
365                if e < self.ebest {
366                    self.ebest = e;
367                    self.xbest = x_visit.clone();
368                    self.state_improved = true;
369                    self.not_improved_idx = 0;
370                }
371            } else {
372                self.accept_reject(j, e, &x_visit);
373            }
374            if self.max_eval_reached() {
375                return;
376            }
377        }
378    }
379
380    fn chain_local_search(&mut self) {
381        if self.state_improved {
382            let (e, x) = self.local_search(&self.xbest.clone());
383            if e < self.ebest {
384                self.not_improved_idx = 0;
385                self.ebest = e;
386                self.xbest = x.clone();
387                self.current_energy = e;
388                self.current_location = x;
389                if self.max_eval_reached() {
390                    return;
391                }
392            }
393        }
394        let mut do_ls = false;
395        if self.k < 90.0 * self.dim as f64 {
396            let pls = (self.k * (self.ebest - self.current_energy) / self.temperature_step).exp();
397            if pls >= self.rng.uniform01() {
398                do_ls = true;
399            }
400        }
401        if self.not_improved_idx >= self.not_improved_max_idx {
402            do_ls = true;
403        }
404        if do_ls {
405            let (e, x) = self.local_search(&self.xmin.clone());
406            self.xmin = x.clone();
407            self.emin = e;
408            self.not_improved_idx = 0;
409            self.not_improved_max_idx = self.current_location.len() as i32;
410            if e < self.ebest {
411                self.ebest = e;
412                self.xbest = x.clone();
413                self.current_energy = e;
414                self.current_location = x;
415            }
416        }
417    }
418
419    // ---- bounded L-BFGS local search on [0,1]^dim ----
420
421    /// Finite-difference gradient matching the C++ `LBFGSFunc` (per-coordinate
422    /// forward/backward difference with boundary handling); returns `(grad, f)`.
423    fn fd_grad(&mut self, arg: &[f64]) -> (Vec<f64>, f64) {
424        let eps = 1e-6;
425        let mut grad = vec![0.0; self.dim];
426        for i in 0..self.dim {
427            let mut x1 = arg.to_vec();
428            let mut x2 = arg.to_vec();
429            let mut e1 = eps;
430            let mut e2 = eps;
431            x1[i] += eps;
432            if x1[i] > 1.0 {
433                x1[i] = 1.0;
434                e1 = 1.0 - arg[i];
435            }
436            x2[i] -= eps;
437            if x2[i] < 0.0 {
438                x2[i] = 0.0;
439                e2 = arg[i];
440            }
441            let f1 = self.value(&x1);
442            let f2 = self.value(&x2);
443            grad[i] = (f1 - f2) / (e1 + e2);
444        }
445        let f = self.value(arg);
446        (grad, f)
447    }
448
449    fn local_search(&mut self, x0: &[f64]) -> (f64, Vec<f64>) {
450        // reset the fitness-best tracker; the best point seen during the search
451        // (through `value`) is the returned result — matching the C++ contract.
452        self.fit_best_y = f64::MAX;
453        let mut max_iter = (6 * self.dim) as i32;
454        max_iter = max_iter.clamp(100, 1000);
455
456        let clamp01 = |v: &[f64]| -> Vec<f64> { v.iter().map(|x| x.clamp(0.0, 1.0)).collect() };
457        let mut x = clamp01(&self.closest_feasible(x0));
458        let m = 6usize;
459        let mut s_hist: Vec<Vec<f64>> = Vec::new();
460        let mut y_hist: Vec<Vec<f64>> = Vec::new();
461        let mut rho: Vec<f64> = Vec::new();
462
463        let (mut g, mut f) = self.fd_grad(&x);
464        for _ in 0..max_iter {
465            // projected-gradient stopping test
466            let pg_norm: f64 = (0..self.dim)
467                .map(|i| {
468                    let step = (x[i] - g[i]).clamp(0.0, 1.0) - x[i];
469                    step * step
470                })
471                .sum::<f64>()
472                .sqrt();
473            if pg_norm < 1e-10 {
474                break;
475            }
476            // two-loop recursion for the direction d = -H g
477            let mut q = g.clone();
478            let kh = s_hist.len();
479            let mut alpha = vec![0.0; kh];
480            for i in (0..kh).rev() {
481                let a = rho[i] * dot(&s_hist[i], &q);
482                alpha[i] = a;
483                for j in 0..self.dim {
484                    q[j] -= a * y_hist[i][j];
485                }
486            }
487            let gamma = if kh > 0 {
488                let last = kh - 1;
489                dot(&s_hist[last], &y_hist[last]) / dot(&y_hist[last], &y_hist[last])
490            } else {
491                1.0
492            };
493            for qi in q.iter_mut() {
494                *qi *= gamma;
495            }
496            for i in 0..kh {
497                let beta = rho[i] * dot(&y_hist[i], &q);
498                for j in 0..self.dim {
499                    q[j] += (alpha[i] - beta) * s_hist[i][j];
500                }
501            }
502            let d: Vec<f64> = q.iter().map(|v| -v).collect();
503
504            // projected backtracking line search (Armijo)
505            let gd = dot(&g, &d);
506            let mut step = 1.0;
507            let mut x_new = x.clone();
508            let mut f_new = f;
509            let mut ok = false;
510            for _ in 0..20 {
511                let cand: Vec<f64> = (0..self.dim)
512                    .map(|i| (x[i] + step * d[i]).clamp(0.0, 1.0))
513                    .collect();
514                let fc = self.value(&cand);
515                if fc.is_finite() && fc <= f + 1e-4 * step * gd {
516                    x_new = cand;
517                    f_new = fc;
518                    ok = true;
519                    break;
520                }
521                step *= 0.5;
522                if self.max_eval_reached() {
523                    break;
524                }
525            }
526            if !ok || self.max_eval_reached() {
527                break;
528            }
529
530            let (g_new, _) = self.fd_grad(&x_new);
531            let s: Vec<f64> = (0..self.dim).map(|i| x_new[i] - x[i]).collect();
532            let yv: Vec<f64> = (0..self.dim).map(|i| g_new[i] - g[i]).collect();
533            let sy = dot(&s, &yv);
534            if sy > 1e-12 {
535                if s_hist.len() == m {
536                    s_hist.remove(0);
537                    y_hist.remove(0);
538                    rho.remove(0);
539                }
540                s_hist.push(s);
541                y_hist.push(yv);
542                rho.push(1.0 / sy);
543            }
544            x = x_new;
545            g = g_new;
546            f = f_new;
547            if self.max_eval_reached() {
548                break;
549            }
550        }
551        (self.fit_best_y, self.fit_best_x.clone())
552    }
553
554    // ---- main annealing loop ----
555
556    fn search(&mut self) {
557        let mut iter = 0i32;
558        let t1 = ((QV - 1.0) * 2.0_f64.ln()).exp() - 1.0;
559        loop {
560            for i in 0..MAXSTEPS {
561                let s = i as f64 + 2.0;
562                let t2 = ((QV - 1.0) * s.ln()).exp() - 1.0;
563                let temperature = TEMPERATURE_START * t1 / t2;
564                iter += 1;
565                if iter >= MAXSTEPS {
566                    return;
567                }
568                if temperature < TEMPERATURE_RESTART {
569                    self.reset_energy(&[]);
570                    if self.reinit_failed {
571                        return;
572                    }
573                    break;
574                }
575                self.run_chain(i as usize, temperature);
576                if self.max_eval_reached() {
577                    return;
578                }
579                if self.use_local_search {
580                    self.chain_local_search();
581                    if self.max_eval_reached() {
582                        return;
583                    }
584                }
585            }
586        }
587    }
588
589    fn optimize(&mut self, guess: &[f64]) -> DaResult {
590        let enc = self.encode(guess);
591        self.reset_energy(&enc);
592        self.emin = self.current_energy;
593        self.xmin = self.current_location.clone();
594        self.not_improved_max_idx = 1000;
595        if !self.reinit_failed {
596            self.search();
597        }
598        DaResult {
599            x: self.decode(&self.xbest),
600            y: self.ebest,
601            evaluations: self.eval_counter,
602            iterations: 0,
603            stop: if self.reinit_failed { -1 } else { 0 },
604        }
605    }
606}
607
608fn dot(a: &[f64], b: &[f64]) -> f64 {
609    a.iter().zip(b).map(|(x, y)| x * y).sum()
610}
611
612/// Run Dual Annealing. `lower`/`upper` empty ⇒ unbounded.
613pub fn optimize_da(
614    obj: &impl Objective,
615    guess: &[f64],
616    lower: Vec<f64>,
617    upper: Vec<f64>,
618    p: &DaParams,
619) -> DaResult {
620    let dim = guess.len();
621    let max_evals = if p.max_evaluations == 0 {
622        10_000_000
623    } else {
624        p.max_evaluations
625    };
626    let mut da = Da::new(
627        obj,
628        dim,
629        lower,
630        upper,
631        max_evals,
632        p.use_local_search,
633        p.seed.wrapping_add(p.runid as u64),
634    );
635    da.optimize(guess)
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    fn sphere(x: &[f64]) -> f64 {
643        x.iter().map(|v| v * v).sum()
644    }
645    fn rosen(x: &[f64]) -> f64 {
646        (0..x.len() - 1)
647            .map(|i| 100.0 * (x[i + 1] - x[i] * x[i]).powi(2) + (1.0 - x[i]).powi(2))
648            .sum()
649    }
650
651    fn run(obj: impl Objective, dim: usize, seed: u64, ls: bool) -> DaResult {
652        let params = DaParams {
653            max_evaluations: 40_000,
654            use_local_search: ls,
655            seed,
656            ..Default::default()
657        };
658        optimize_da(
659            &obj,
660            &vec![0.0; dim],
661            vec![-5.0; dim],
662            vec![5.0; dim],
663            &params,
664        )
665    }
666
667    #[test]
668    fn minimizes_sphere_with_local_search() {
669        let r = run(sphere as fn(&[f64]) -> f64, 4, 1, true);
670        assert!(r.y < 1e-6, "sphere not solved: {}", r.y);
671        assert!((sphere(&r.x) - r.y).abs() < 1e-6);
672    }
673
674    #[test]
675    fn minimizes_sphere_without_local_search() {
676        let r = run(sphere as fn(&[f64]) -> f64, 4, 2, false);
677        assert!(r.y < 1e-2, "sphere (no ls) too large: {}", r.y);
678    }
679
680    #[test]
681    fn minimizes_rosenbrock() {
682        let r = run(rosen as fn(&[f64]) -> f64, 3, 3, true);
683        assert!(r.y < 1e-2, "rosenbrock not solved: {}", r.y);
684    }
685
686    #[test]
687    fn unbounded_nonfinite_objective_reports_reinitialization_failure() {
688        let result = optimize_da(
689            &(|_: &[f64]| f64::NAN),
690            &[0.0],
691            Vec::new(),
692            Vec::new(),
693            &DaParams {
694                max_evaluations: 0,
695                use_local_search: false,
696                seed: 4,
697                runid: -1,
698            },
699        );
700        assert_eq!(result.stop, -1);
701        assert!(result.evaluations > 1_000);
702    }
703
704    #[test]
705    fn finite_difference_handles_both_box_boundaries() {
706        let objective = sphere as fn(&[f64]) -> f64;
707        let mut optimizer = Da::new(&objective, 2, vec![0.0; 2], vec![1.0; 2], 100, false, 5);
708        let (gradient, value) = optimizer.fd_grad(&[0.0, 1.0]);
709        assert!(gradient.iter().all(|component| component.is_finite()));
710        assert_eq!(value, 1.0);
711    }
712}