Skip to main content

u_numflow/
optimize.rs

1//! Local minimisation of a function of several variables without derivatives.
2//!
3//! # Algorithm
4//!
5//! The Nelder–Mead simplex method: a simplex of n + 1 points is reflected,
6//! expanded, contracted or shrunk towards its best vertex until its values (and
7//! its extent) fall below the tolerances. The coefficients adapt to the
8//! dimension (Gao & Han 2012 — reflection 1, expansion 1 + 2/n, contraction
9//! 0.75 − 1/(2n), shrink 1 − 1/n), which keeps the method from stalling in more
10//! than a few dimensions; in one or two they are the classical 1, 2, ½, ½. The
11//! initial simplex steps 5 % of each non-zero coordinate (0.00025 for a zero
12//! one), as SciPy's `minimize(method="Nelder-Mead")` does.
13//!
14//! A value that is not finite (NaN, ±∞) is treated as +∞, so an objective can
15//! refuse a point by returning NaN.
16//!
17//! # References
18//!
19//! Nelder, J.A. & Mead, R. (1965). "A simplex method for function
20//! minimization", *The Computer Journal* 7(4), 308-313.
21//! Lagarias, J.C., Reeds, J.A., Wright, M.H. & Wright, P.E. (1998).
22//! "Convergence properties of the Nelder–Mead simplex method in low
23//! dimensions", *SIAM Journal on Optimization* 9(1), 112-147.
24//! Gao, F. & Han, L. (2012). "Implementing the Nelder-Mead simplex algorithm
25//! with adaptive parameters", *Computational Optimization and Applications*
26//! 51(1), 259-277.
27
28/// Stopping rules of [`nelder_mead`]. The tolerances are relative, so the
29/// stopping point does not depend on the units of the variables or the values.
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct NelderMeadOptions {
32    /// Stop when every vertex's value is within `f_tolerance · (1 + |best|)`
33    /// of the best's (and the simplex within `x_tolerance`). Default 1e-10.
34    pub f_tolerance: f64,
35    /// Stop when every vertex is within `x_tolerance · (1 + |best|)` of the
36    /// best in every coordinate (and the values within `f_tolerance`).
37    /// Default 1e-8.
38    pub x_tolerance: f64,
39    /// Most objective evaluations. Default 1000 per variable, at least 2000.
40    pub max_evaluations: usize,
41}
42
43impl NelderMeadOptions {
44    /// The defaults for `n` variables.
45    pub fn for_dimension(n: usize) -> Self {
46        Self {
47            f_tolerance: 1e-10,
48            x_tolerance: 1e-8,
49            max_evaluations: (1000 * n).max(2000),
50        }
51    }
52}
53
54/// The best point [`nelder_mead`] found.
55#[derive(Debug, Clone, PartialEq)]
56pub struct Minimum {
57    /// The point.
58    pub x: Vec<f64>,
59    /// The objective there (+∞ when every point tried was refused).
60    pub value: f64,
61    /// Objective evaluations spent.
62    pub evaluations: usize,
63    /// Whether the tolerances were met (rather than the evaluation budget
64    /// spent).
65    pub converged: bool,
66}
67
68/// Minimises `f` from `x0` by the Nelder–Mead simplex method.
69///
70/// The search is deterministic: the same `f` and `x0` give the same point.
71/// It finds a local minimum; for a function with several, start it from more
72/// than one point.
73///
74/// # Panics
75///
76/// Never; an empty `x0` returns it with `f(&[])`.
77///
78/// # Examples
79///
80/// ```
81/// use u_numflow::optimize::{nelder_mead, NelderMeadOptions};
82///
83/// // Rosenbrock's valley: minimum 0 at (1, 1).
84/// let f = |x: &[f64]| (1.0 - x[0]).powi(2) + 100.0 * (x[1] - x[0] * x[0]).powi(2);
85/// let m = nelder_mead(f, &[-1.2, 1.0], NelderMeadOptions::for_dimension(2));
86/// assert!(m.converged);
87/// assert!((m.x[0] - 1.0).abs() < 1e-4 && (m.x[1] - 1.0).abs() < 1e-4);
88/// ```
89pub fn nelder_mead<F: FnMut(&[f64]) -> f64>(
90    mut f: F,
91    x0: &[f64],
92    options: NelderMeadOptions,
93) -> Minimum {
94    let n = x0.len();
95    let evaluations = std::cell::Cell::new(0usize);
96    let mut eval = |x: &[f64]| {
97        evaluations.set(evaluations.get() + 1);
98        let v = f(x);
99        if v.is_finite() {
100            v
101        } else {
102            f64::INFINITY
103        }
104    };
105    if n == 0 {
106        let value = eval(x0);
107        return Minimum {
108            x: Vec::new(),
109            value,
110            evaluations: evaluations.get(),
111            converged: true,
112        };
113    }
114
115    let nf = n as f64;
116    let (rho, chi, psi, sigma) = if n <= 2 {
117        (1.0, 2.0, 0.5, 0.5)
118    } else {
119        (1.0, 1.0 + 2.0 / nf, 0.75 - 1.0 / (2.0 * nf), 1.0 - 1.0 / nf)
120    };
121
122    let mut simplex: Vec<Vec<f64>> = Vec::with_capacity(n + 1);
123    simplex.push(x0.to_vec());
124    for i in 0..n {
125        let mut v = x0.to_vec();
126        v[i] = if v[i] != 0.0 { v[i] * 1.05 } else { 0.00025 };
127        simplex.push(v);
128    }
129    let mut values: Vec<f64> = simplex.iter().map(|v| eval(v)).collect();
130    let mut order: Vec<usize> = (0..=n).collect();
131    let mut converged = false;
132
133    loop {
134        order.sort_by(|&a, &b| values[a].total_cmp(&values[b]));
135        let best = order[0];
136        let worst = order[n];
137        let f_spread = order[1..]
138            .iter()
139            .map(|&i| (values[i] - values[best]).abs())
140            .fold(0.0, f64::max);
141        let x_close = order[1..].iter().all(|&i| {
142            simplex[i]
143                .iter()
144                .zip(&simplex[best])
145                .all(|(a, b)| (a - b).abs() <= options.x_tolerance * (1.0 + b.abs()))
146        });
147        // An infinite best leaves `f_spread` NaN: not converged.
148        if f_spread <= options.f_tolerance * (1.0 + values[best].abs()) && x_close {
149            converged = true;
150            break;
151        }
152        if evaluations.get() >= options.max_evaluations {
153            break;
154        }
155
156        // Centroid of every vertex but the worst.
157        let mut centroid = vec![0.0; n];
158        for &i in &order[..n] {
159            for (c, v) in centroid.iter_mut().zip(&simplex[i]) {
160                *c += v / nf;
161            }
162        }
163        let toward = |t: f64| -> Vec<f64> {
164            centroid
165                .iter()
166                .zip(&simplex[worst])
167                .map(|(c, w)| c + t * (c - w))
168                .collect()
169        };
170
171        let xr = toward(rho);
172        let fr = eval(&xr);
173        let second_worst = values[order[n - 1]];
174        if fr < values[best] {
175            let xe = toward(rho * chi);
176            let fe = eval(&xe);
177            if fe < fr {
178                simplex[worst] = xe;
179                values[worst] = fe;
180            } else {
181                simplex[worst] = xr;
182                values[worst] = fr;
183            }
184            continue;
185        }
186        if fr < second_worst {
187            simplex[worst] = xr;
188            values[worst] = fr;
189            continue;
190        }
191        // Contract: outside when the reflection beat the worst, else inside.
192        let (xc, fc, accept) = if fr < values[worst] {
193            let xc = toward(rho * psi);
194            let fc = eval(&xc);
195            let ok = fc <= fr;
196            (xc, fc, ok)
197        } else {
198            let xc = toward(-psi);
199            let fc = eval(&xc);
200            let ok = fc < values[worst];
201            (xc, fc, ok)
202        };
203        if accept {
204            simplex[worst] = xc;
205            values[worst] = fc;
206            continue;
207        }
208        // Shrink towards the best vertex.
209        let anchor = simplex[best].clone();
210        for &i in &order[1..] {
211            for (v, a) in simplex[i].iter_mut().zip(&anchor) {
212                *v = a + sigma * (*v - a);
213            }
214            values[i] = eval(&simplex[i]);
215        }
216    }
217
218    order.sort_by(|&a, &b| values[a].total_cmp(&values[b]));
219    Minimum {
220        x: simplex[order[0]].clone(),
221        value: values[order[0]],
222        evaluations: evaluations.get(),
223        converged,
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn a_quadratic_bowl_in_five_dimensions() {
233        let centre = [1.0, -2.0, 3.0, 0.5, -0.25];
234        let f = |x: &[f64]| {
235            x.iter()
236                .zip(&centre)
237                .enumerate()
238                .map(|(i, (a, c))| (i + 1) as f64 * (a - c).powi(2))
239                .sum::<f64>()
240        };
241        let m = nelder_mead(f, &[0.0; 5], NelderMeadOptions::for_dimension(5));
242        assert!(m.converged, "{m:?}");
243        for (a, c) in m.x.iter().zip(&centre) {
244            assert!((a - c).abs() < 1e-4, "{m:?}");
245        }
246    }
247
248    #[test]
249    fn the_same_start_gives_the_same_point() {
250        let f = |x: &[f64]| (x[0] - 3.0).powi(2) + (x[0] * x[1] - 1.0).powi(2);
251        let a = nelder_mead(f, &[0.5, 0.5], NelderMeadOptions::for_dimension(2));
252        let b = nelder_mead(f, &[0.5, 0.5], NelderMeadOptions::for_dimension(2));
253        assert_eq!(a, b);
254    }
255
256    #[test]
257    fn a_refused_point_is_avoided() {
258        // NaN outside the unit square: the search stays inside and reaches the
259        // corner minimum (1, 1) of the bowl centred outside it.
260        let f = |x: &[f64]| {
261            if x.iter().any(|v| !(0.0..=1.0).contains(v)) {
262                f64::NAN
263            } else {
264                (x[0] - 2.0).powi(2) + (x[1] - 2.0).powi(2)
265            }
266        };
267        let m = nelder_mead(f, &[0.5, 0.5], NelderMeadOptions::for_dimension(2));
268        assert!(m.value.is_finite());
269        assert!(m.x.iter().all(|v| (0.0..=1.0).contains(v)), "{m:?}");
270        assert!(m.value < 2.0 + 1e-4, "{m:?}");
271    }
272
273    #[test]
274    fn the_budget_is_kept() {
275        let f = |x: &[f64]| (1.0 - x[0]).powi(2) + 100.0 * (x[1] - x[0] * x[0]).powi(2);
276        let options = NelderMeadOptions {
277            max_evaluations: 30,
278            ..NelderMeadOptions::for_dimension(2)
279        };
280        let m = nelder_mead(f, &[-1.2, 1.0], options);
281        assert!(!m.converged);
282        // The last step may evaluate a few points past the check.
283        assert!(m.evaluations <= 30 + 2, "{}", m.evaluations);
284    }
285
286    #[test]
287    fn a_change_of_scale_moves_the_minimum_with_it() {
288        let f = |k: f64| {
289            move |x: &[f64]| 1e6 * k * (((x[0] - 2.0 * k) / k).powi(2) + ((x[1] + k) / k).powi(2))
290        };
291        for k in [1e-6, 1.0, 1e6] {
292            let m = nelder_mead(f(k), &[k, k], NelderMeadOptions::for_dimension(2));
293            assert!(m.converged, "{k}: {m:?}");
294            assert!(
295                (m.x[0] / k - 2.0).abs() < 1e-5 && (m.x[1] / k + 1.0).abs() < 1e-5,
296                "{k}: {m:?}"
297            );
298        }
299    }
300}