Skip to main content

scir_optimize/
lib.rs

1//! Optimization routines for SciR.
2#![deny(missing_docs)]
3
4use ndarray::{Array1, Array2, Axis};
5
6/// Nelder-Mead simplex algorithm.
7pub fn nelder_mead<F>(f: F, start: Array1<f64>, step: f64, max_iter: usize, tol: f64) -> Array1<f64>
8where
9    F: Fn(&Array1<f64>) -> f64,
10{
11    let n = start.len();
12    let mut simplex: Vec<(Array1<f64>, f64)> = Vec::with_capacity(n + 1);
13    simplex.push((start.clone(), f(&start)));
14    for i in 0..n {
15        let mut p = start.clone();
16        p[i] += step;
17        simplex.push((p.clone(), f(&p)));
18    }
19    for _ in 0..max_iter {
20        simplex.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
21        let best = simplex[0].1;
22        let worst = simplex[n].1;
23        if (worst - best).abs() < tol {
24            break;
25        }
26        // centroid of all but worst
27        let mut centroid = Array1::<f64>::zeros(n);
28        for (p, _) in simplex.iter().take(n) {
29            centroid += p;
30        }
31        centroid /= n as f64;
32        // reflection
33        let reflection = &centroid + (&centroid - &simplex[n].0);
34        let f_ref = f(&reflection);
35        if f_ref < simplex[0].1 {
36            let expansion = &centroid + 2.0 * (&reflection - &centroid);
37            let f_exp = f(&expansion);
38            if f_exp < f_ref {
39                simplex[n] = (expansion, f_exp);
40            } else {
41                simplex[n] = (reflection, f_ref);
42            }
43        } else if f_ref < simplex[n - 1].1 {
44            simplex[n] = (reflection, f_ref);
45        } else {
46            let contraction = &centroid + 0.5 * (&simplex[n].0 - &centroid);
47            let f_con = f(&contraction);
48            if f_con < simplex[n].1 {
49                simplex[n] = (contraction, f_con);
50            } else {
51                // shrink
52                let best_point = simplex[0].0.clone();
53                for (p, val) in simplex.iter_mut().take(n + 1).skip(1) {
54                    *p = &best_point + 0.5 * (&*p - &best_point);
55                    *val = f(p);
56                }
57            }
58        }
59    }
60    simplex.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
61    simplex[0].0.clone()
62}
63
64/// BFGS optimization with provided gradient.
65pub fn bfgs<F, G>(f: F, grad: G, start: Array1<f64>, max_iter: usize, tol: f64) -> Array1<f64>
66where
67    F: Fn(&Array1<f64>) -> f64,
68    G: Fn(&Array1<f64>) -> Array1<f64>,
69{
70    let n = start.len();
71    let mut x = start;
72    let mut h = Array2::<f64>::eye(n);
73    for _ in 0..max_iter {
74        let g = grad(&x);
75        if g.iter().map(|v| v * v).sum::<f64>().sqrt() < tol {
76            break;
77        }
78        let p = -h.dot(&g);
79        // backtracking line search
80        let mut alpha = 1.0;
81        let fx = f(&x);
82        let mut x_new = &x + &(alpha * &p);
83        let mut f_new = f(&x_new);
84        while f_new > fx && alpha > 1e-8 {
85            alpha *= 0.5;
86            x_new = &x + &(alpha * &p);
87            f_new = f(&x_new);
88        }
89        let s = &x_new - &x;
90        let g_new = grad(&x_new);
91        let y = &g_new - &g;
92        let ys = y.dot(&s);
93        if ys.abs() < 1e-12 {
94            break;
95        }
96        let rho = 1.0 / ys;
97        let i = Array2::<f64>::eye(n);
98        let sy = s
99            .view()
100            .insert_axis(Axis(1))
101            .dot(&y.view().insert_axis(Axis(0)));
102        let ys_mat = y
103            .view()
104            .insert_axis(Axis(1))
105            .dot(&s.view().insert_axis(Axis(0)));
106        h = (i.clone() - rho * sy).dot(&h).dot(&(i - rho * ys_mat))
107            + rho
108                * s.view()
109                    .insert_axis(Axis(1))
110                    .dot(&s.view().insert_axis(Axis(0)));
111        x = x_new;
112    }
113    x
114}
115
116/// Limited-memory BFGS optimization.
117pub fn lbfgs<F, G>(
118    f: F,
119    grad: G,
120    start: Array1<f64>,
121    max_iter: usize,
122    tol: f64,
123    m: usize,
124) -> Array1<f64>
125where
126    F: Fn(&Array1<f64>) -> f64,
127    G: Fn(&Array1<f64>) -> Array1<f64>,
128{
129    let mut x = start;
130    let mut s_list: Vec<Array1<f64>> = Vec::new();
131    let mut y_list: Vec<Array1<f64>> = Vec::new();
132    let mut rho: Vec<f64> = Vec::new();
133    for _ in 0..max_iter {
134        let g = grad(&x);
135        let g_norm = g.iter().map(|v| v * v).sum::<f64>().sqrt();
136        if g_norm < tol {
137            break;
138        }
139        let mut q = g.clone();
140        let mut alpha = Vec::new();
141        for i in (0..s_list.len()).rev() {
142            let a = rho[i] * s_list[i].dot(&q);
143            alpha.push(a);
144            q = &q - &(a * &y_list[i]);
145        }
146        let mut r = q; // initial Hessian approx is identity
147        for (i, s) in s_list.iter().enumerate() {
148            let beta = rho[i] * y_list[i].dot(&r);
149            let a = alpha[s_list.len() - 1 - i];
150            r = &r + &(s * (a - beta));
151        }
152        let p = -r;
153        // backtracking line search
154        let mut step = 1.0;
155        let fx = f(&x);
156        let mut x_new = &x + &(step * &p);
157        let mut f_new = f(&x_new);
158        while f_new > fx && step > 1e-8 {
159            step *= 0.5;
160            x_new = &x + &(step * &p);
161            f_new = f(&x_new);
162        }
163        let s = &x_new - &x;
164        let g_new = grad(&x_new);
165        let y = &g_new - &g;
166        let ys = y.dot(&s);
167        if ys.abs() < 1e-12 {
168            break;
169        }
170        if s_list.len() == m {
171            s_list.remove(0);
172            y_list.remove(0);
173            rho.remove(0);
174        }
175        s_list.push(s.clone());
176        y_list.push(y.clone());
177        rho.push(1.0 / ys);
178        x = x_new;
179    }
180    x
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use ndarray::{array, Array1};
187    use ndarray_npy::ReadNpyExt;
188    use scir_core::assert_close;
189    use std::{fs::File, path::PathBuf};
190
191    fn fixture_path(name: &str) -> Option<PathBuf> {
192        let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
193        let path = base.join(name);
194        if path.exists() {
195            Some(path)
196        } else {
197            None
198        }
199    }
200
201    fn fixture_array(name: &str, test_name: &str) -> Option<Array1<f64>> {
202        let Some(path) = fixture_path(name) else {
203            eprintln!("[scir-optimize] fixture {name} missing; skipping {test_name}");
204            return None;
205        };
206
207        let Ok(file) = File::open(&path) else {
208            eprintln!("[scir-optimize] fixture {name} could not be opened; skipping {test_name}");
209            return None;
210        };
211
212        match ReadNpyExt::read_npy(file) {
213            Ok(values) => Some(values),
214            Err(err) => {
215                eprintln!(
216                    "[scir-optimize] fixture {name} read failed ({err}); skipping {test_name}"
217                );
218                None
219            }
220        }
221    }
222
223    fn rosenbrock(x: &Array1<f64>) -> f64 {
224        (1.0 - x[0]).powi(2) + 100.0 * (x[1] - x[0].powi(2)).powi(2)
225    }
226
227    fn rosenbrock_grad(x: &Array1<f64>) -> Array1<f64> {
228        array![
229            -2.0 * (1.0 - x[0]) - 400.0 * x[0] * (x[1] - x[0].powi(2)),
230            200.0 * (x[1] - x[0].powi(2))
231        ]
232    }
233
234    fn himmelblau(x: &Array1<f64>) -> f64 {
235        (x[0].powi(2) + x[1] - 11.0).powi(2) + (x[0] + x[1].powi(2) - 7.0).powi(2)
236    }
237
238    fn himmelblau_grad(x: &Array1<f64>) -> Array1<f64> {
239        array![
240            4.0 * x[0] * (x[0].powi(2) + x[1] - 11.0) + 2.0 * (x[0] + x[1].powi(2) - 7.0),
241            2.0 * (x[0].powi(2) + x[1] - 11.0) + 4.0 * x[1] * (x[0] + x[1].powi(2) - 7.0)
242        ]
243    }
244
245    #[test]
246    fn rosenbrock_nelder_mead_matches_fixture() {
247        let Some(expected) = fixture_array(
248            "rosenbrock_nelder.npy",
249            "rosenbrock_nelder_mead_matches_fixture",
250        ) else {
251            return;
252        };
253        let result = nelder_mead(rosenbrock, array![-1.2, 1.0], 1.0, 2000, 1e-8);
254        assert_close!(&result, &expected, array, atol = 1e-5, rtol = 1e-5);
255    }
256
257    #[test]
258    fn rosenbrock_bfgs_matches_fixture() {
259        let Some(expected) =
260            fixture_array("rosenbrock_bfgs.npy", "rosenbrock_bfgs_matches_fixture")
261        else {
262            return;
263        };
264        let result = bfgs(rosenbrock, rosenbrock_grad, array![-1.2, 1.0], 2000, 1e-8);
265        assert_close!(&result, &expected, array, atol = 1e-5, rtol = 1e-5);
266    }
267
268    #[test]
269    fn himmelblau_nelder_mead_matches_fixture() {
270        let Some(expected) = fixture_array(
271            "himmelblau_nelder.npy",
272            "himmelblau_nelder_mead_matches_fixture",
273        ) else {
274            return;
275        };
276        let result = nelder_mead(himmelblau, array![0.0, 0.0], 0.5, 2000, 1e-8);
277        assert_close!(&result, &expected, array, atol = 1e-4, rtol = 1e-5);
278    }
279
280    #[test]
281    fn himmelblau_bfgs_matches_fixture() {
282        let Some(expected) =
283            fixture_array("himmelblau_bfgs.npy", "himmelblau_bfgs_matches_fixture")
284        else {
285            return;
286        };
287        let result = bfgs(himmelblau, himmelblau_grad, array![0.0, 0.0], 2000, 1e-8);
288        assert_close!(&result, &expected, array, atol = 1e-5, rtol = 1e-5);
289    }
290
291    #[test]
292    fn rosenbrock_lbfgs_matches_fixture() {
293        let Some(expected) =
294            fixture_array("rosenbrock_lbfgs.npy", "rosenbrock_lbfgs_matches_fixture")
295        else {
296            return;
297        };
298        let result = lbfgs(
299            rosenbrock,
300            rosenbrock_grad,
301            array![-1.2, 1.0],
302            2000,
303            1e-8,
304            5,
305        );
306        assert_close!(&result, &expected, array, atol = 1e-5, rtol = 1e-5);
307    }
308
309    #[test]
310    fn himmelblau_lbfgs_matches_fixture() {
311        let Some(expected) =
312            fixture_array("himmelblau_lbfgs.npy", "himmelblau_lbfgs_matches_fixture")
313        else {
314            return;
315        };
316        let result = lbfgs(himmelblau, himmelblau_grad, array![0.0, 0.0], 2000, 1e-8, 5);
317        assert_close!(&result, &expected, array, atol = 1e-5, rtol = 1e-5);
318    }
319}