Skip to main content

prospicio_math/
optimize.rs

1//! Optimization: one-variable searches and the Nelder–Mead simplex.
2
3/// Minimizes `f` on the open interval `(lo, hi)`: a scan of 64 equal cells
4/// picks the best interior grid point, then golden-section search runs
5/// between its neighbours to full precision.
6///
7/// The scan guards against local minima a pure golden-section search
8/// would settle in; a minimum narrower than a cell can still be missed.
9/// The result never is an end point.
10///
11/// ```
12/// use prospicio_math::optimize::minimize;
13///
14/// let x = minimize(0.0, 4.0, |x| (x - 1.3).powi(2) + 0.1 * (8.0 * x).cos());
15/// let f = |x: f64| (x - 1.3).powi(2) + 0.1 * (8.0 * x).cos();
16/// assert!(f(x) <= f(x + 1e-6) && f(x) <= f(x - 1e-6));
17/// ```
18pub fn minimize(lo: f64, hi: f64, mut f: impl FnMut(f64) -> f64) -> f64 {
19    const CELLS: usize = 64;
20    let h = (hi - lo) / CELLS as f64;
21    let x = |j: usize| lo + h * j as f64;
22    let mut best = 1;
23    let mut f_best = f(x(1));
24    for j in 2..CELLS {
25        let v = f(x(j));
26        if v.total_cmp(&f_best).is_lt() {
27            (best, f_best) = (j, v);
28        }
29    }
30    golden_section(x(best - 1), x(best + 1), f)
31}
32
33/// Minimizes a unimodal `f` on `[a, b]` by golden-section search, to the
34/// width of `1e-15` relative to `b`.
35///
36/// ```
37/// use prospicio_math::optimize::golden_section;
38///
39/// let x = golden_section(0.0, 3.0, |x| (x - 2.0).powi(2));
40/// assert!((x - 2.0).abs() < 1e-7);
41/// ```
42pub fn golden_section(a: f64, b: f64, mut f: impl FnMut(f64) -> f64) -> f64 {
43    let (mut a, mut b) = (a, b);
44    let g = 0.5 * (5f64.sqrt() - 1.0);
45    let (mut c, mut d) = (b - g * (b - a), a + g * (b - a));
46    let (mut fc, mut fd) = (f(c), f(d));
47    for _ in 0..200 {
48        if b - a <= 1e-15 * b.abs() {
49            break;
50        }
51        if fc < fd {
52            (b, d, fd) = (d, c, fc);
53            c = b - g * (b - a);
54            fc = f(c);
55        } else {
56            (a, c, fc) = (c, d, fd);
57            d = a + g * (b - a);
58            fd = f(d);
59        }
60    }
61    0.5 * (a + b)
62}
63
64/// Minimizes `f` on `[a, b]` by Brent's method: parabolic interpolation
65/// through the three best points when it lands inside the bracket and
66/// shrinks it, golden-section steps otherwise (Brent, *Algorithms for
67/// Minimization without Derivatives*, 1973, ch. 5). Stops when the bracket
68/// is within `tol` (relative, plus a tiny absolute floor) of the best
69/// point, and returns `(x, f(x), evaluations)`. Near a minimum `f` is flat
70/// to second order, so `x` is only determined to about `√ε` relative, and
71/// `tol` below that is raised to it.
72///
73/// On a smooth unimodal `f` it needs far fewer evaluations than
74/// [`golden_section`], which matters when each one is expensive (a model
75/// refit).
76///
77/// ```
78/// use prospicio_math::optimize::brent;
79///
80/// let (x, fx, n) = brent(0.0, 3.0, 1e-10, |x| (x - 2.0).powi(2) + 1.0);
81/// assert!((x - 2.0).abs() < 1e-7 && (fx - 1.0).abs() < 1e-14 && n < 20);
82/// ```
83pub fn brent(a: f64, b: f64, tol: f64, mut f: impl FnMut(f64) -> f64) -> (f64, f64, usize) {
84    const GOLD: f64 = 0.381_966_011_250_105_1; // (3 - √5) / 2
85    // Below √ε the steps only chase rounding.
86    let tol = tol.max(f64::EPSILON.sqrt());
87    let (mut a, mut b) = (a.min(b), a.max(b));
88    let mut x = a + GOLD * (b - a);
89    let (mut w, mut v) = (x, x);
90    let mut fx = f(x);
91    let (mut fw, mut fv) = (fx, fx);
92    let mut evaluations = 1;
93    let (mut d, mut e) = (0.0f64, 0.0f64);
94    for _ in 0..500 {
95        let m = 0.5 * (a + b);
96        let tol1 = tol * x.abs() + 1e-12 * tol;
97        let tol2 = 2.0 * tol1;
98        if (x - m).abs() <= tol2 - 0.5 * (b - a) {
99            break;
100        }
101        let mut golden = true;
102        if e.abs() > tol1 {
103            // Parabola through (v, fv), (w, fw), (x, fx).
104            let r = (x - w) * (fx - fv);
105            let q0 = (x - v) * (fx - fw);
106            let mut p = (x - v) * q0 - (x - w) * r;
107            let mut q = 2.0 * (q0 - r);
108            if q > 0.0 {
109                p = -p;
110            }
111            q = q.abs();
112            if p.abs() < (0.5 * q * e).abs() && p > q * (a - x) && p < q * (b - x) {
113                e = d;
114                d = p / q;
115                let u = x + d;
116                if u - a < tol2 || b - u < tol2 {
117                    d = if x < m { tol1 } else { -tol1 };
118                }
119                golden = false;
120            }
121        }
122        if golden {
123            e = if x < m { b - x } else { a - x };
124            d = GOLD * e;
125        }
126        let u = if d.abs() >= tol1 {
127            x + d
128        } else if d > 0.0 {
129            x + tol1
130        } else {
131            x - tol1
132        };
133        let fu = f(u);
134        evaluations += 1;
135        if fu <= fx {
136            if u < x {
137                b = x;
138            } else {
139                a = x;
140            }
141            (v, fv, w, fw, x, fx) = (w, fw, x, fx, u, fu);
142        } else {
143            if u < x {
144                a = u;
145            } else {
146                b = u;
147            }
148            if fu <= fw || w == x {
149                (v, fv, w, fw) = (w, fw, u, fu);
150            } else if fu <= fv || v == x || v == w {
151                (v, fv) = (u, fu);
152            }
153        }
154    }
155    (x, fx, evaluations)
156}
157
158/// Options for [`nelder_mead`].
159///
160/// `initial_step` sizes the starting simplex: each coordinate of the
161/// start point is moved by `initial_step` times its absolute value (a
162/// relative step), or by `initial_step` itself when that absolute value is
163/// below 1, so a start at or near zero still gets a simplex of useful
164/// size. `initial_step` must be finite and larger than `tolerance`, or the
165/// starting simplex could already pass the convergence test; otherwise
166/// [`nelder_mead`] does not search. The search has converged when the spread of the
167/// simplex's values is within `tolerance` times `max(1, |best value|)` and
168/// every vertex is within `tolerance` times `max(1, |best x|)` of the best
169/// one in each coordinate. `max_iterations` counts simplex moves over both
170/// passes (see [`nelder_mead`]).
171///
172/// ```
173/// use prospicio_math::optimize::NelderMead;
174///
175/// let options = NelderMead { tolerance: 1e-12, ..NelderMead::default() };
176/// assert_eq!(options.initial_step, 0.1);
177/// ```
178#[derive(Debug, Clone, Copy, PartialEq)]
179pub struct NelderMead {
180    /// Size of the starting simplex, relative to each coordinate of
181    /// absolute value 1 or more.
182    pub initial_step: f64,
183    /// Convergence tolerance on both the values and the simplex diameter.
184    pub tolerance: f64,
185    /// Most simplex moves allowed in total.
186    pub max_iterations: usize,
187}
188
189impl Default for NelderMead {
190    /// A 10% starting step, tolerance `1e-10` and 10 000 iterations.
191    fn default() -> Self {
192        NelderMead {
193            initial_step: 0.1,
194            tolerance: 1e-10,
195            max_iterations: 10_000,
196        }
197    }
198}
199
200/// The result of [`nelder_mead`]: the best point found, its value, the
201/// number of simplex moves made and whether the tolerance was met.
202///
203/// ```
204/// use prospicio_math::optimize::{nelder_mead, NelderMead};
205///
206/// let m = nelder_mead(|x| (x[0] - 3.0).powi(2), &[0.0], &NelderMead::default());
207/// assert!(m.converged && (m.x[0] - 3.0).abs() < 1e-8 && m.value < 1e-16);
208/// ```
209#[derive(Debug, Clone, PartialEq)]
210pub struct Minimum {
211    /// The best point found.
212    pub x: Vec<f64>,
213    /// `f(x)`, or `+inf` if no feasible point was found.
214    pub value: f64,
215    /// Simplex moves made.
216    pub iterations: usize,
217    /// Whether the last pass met the tolerance within `max_iterations`.
218    pub converged: bool,
219}
220
221/// Minimizes `f` from the start point `x0` by the Nelder–Mead simplex
222/// method, with the standard moves: reflection 1, expansion 2, contraction
223/// 1/2 (outside or inside) and shrink 1/2 toward the best vertex (Nelder
224/// and Mead, 1965; Lagarias et al., 1998).
225///
226/// A NaN or infinite value of `f` counts as `+inf`, so `f` can reject an
227/// infeasible point by returning NaN or infinity and the simplex moves
228/// away from it. Once the tolerance is met the search restarts once from
229/// the best point with a fresh simplex, which recovers from a simplex that
230/// collapsed onto a subspace; the result reports that second pass. No
231/// derivatives are used, so `f` need not be smooth, but the method only
232/// finds a local minimum. If `initial_step` is not finite or not larger
233/// than `tolerance`, the start is returned unsearched with `converged`
234/// false.
235///
236/// ```
237/// use prospicio_math::optimize::{nelder_mead, NelderMead};
238///
239/// // Rosenbrock's banana-shaped valley, minimum 0 at (1, 1).
240/// let rosenbrock = |x: &[f64]| 100.0 * (x[1] - x[0] * x[0]).powi(2) + (1.0 - x[0]).powi(2);
241/// let m = nelder_mead(rosenbrock, &[-1.2, 1.0], &NelderMead::default());
242/// assert!(m.converged);
243/// assert!((m.x[0] - 1.0).abs() < 1e-6 && (m.x[1] - 1.0).abs() < 1e-6);
244/// ```
245pub fn nelder_mead(mut f: impl FnMut(&[f64]) -> f64, x0: &[f64], options: &NelderMead) -> Minimum {
246    let mut eval = |x: &[f64]| {
247        let v = f(x);
248        if v.is_finite() { v } else { f64::INFINITY }
249    };
250    let mut x = x0.to_vec();
251    let mut value = eval(&x);
252    let mut iterations = 0;
253    let mut converged = x0.is_empty();
254    let valid = options.initial_step.is_finite() && options.initial_step > options.tolerance;
255    if !converged && valid {
256        for _pass in 0..2 {
257            (x, value, converged) = simplex_pass(&mut eval, x, value, options, &mut iterations);
258            if !converged {
259                break;
260            }
261        }
262    }
263    Minimum {
264        x,
265        value,
266        iterations,
267        converged,
268    }
269}
270
271/// One Nelder–Mead run from a fresh simplex around `start`. Returns the
272/// best vertex, its value and whether the tolerance was met.
273fn simplex_pass(
274    f: &mut impl FnMut(&[f64]) -> f64,
275    start: Vec<f64>,
276    f_start: f64,
277    options: &NelderMead,
278    iterations: &mut usize,
279) -> (Vec<f64>, f64, bool) {
280    let n = start.len();
281    let tol = options.tolerance;
282    let mut simplex = vec![(start.clone(), f_start)];
283    for i in 0..n {
284        let mut v = start.clone();
285        // At least `initial_step` times `max(1, |x|)`, the scale of the
286        // diameter test, so the starting simplex never passes it.
287        v[i] += options.initial_step * v[i].abs().max(1.0);
288        let fv = f(&v);
289        simplex.push((v, fv));
290    }
291    // The point `c + t (p - c)` on the line through `c` and `p`.
292    let along = |c: &[f64], p: &[f64], t: f64| -> Vec<f64> {
293        c.iter().zip(p).map(|(c, p)| c + t * (p - c)).collect()
294    };
295    loop {
296        simplex.sort_by(|a, b| a.1.total_cmp(&b.1));
297        let (x_best, f_best) = (&simplex[0].0, simplex[0].1);
298        let spread = simplex[n].1 - f_best;
299        let scale = x_best.iter().fold(1.0f64, |m, x| m.max(x.abs()));
300        let diameter = simplex[1..]
301            .iter()
302            .flat_map(|(v, _)| v.iter().zip(x_best).map(|(a, b)| (a - b).abs()))
303            .fold(0.0f64, f64::max);
304        // `spread` is NaN, so never small, while every value is infinite.
305        if spread <= tol * f_best.abs().max(1.0) && diameter <= tol * scale {
306            return (x_best.clone(), f_best, true);
307        }
308        if *iterations >= options.max_iterations {
309            return (x_best.clone(), f_best, false);
310        }
311        *iterations += 1;
312
313        let mut centroid = vec![0.0; n];
314        for (v, _) in &simplex[..n] {
315            for (c, x) in centroid.iter_mut().zip(v) {
316                *c += x / n as f64;
317            }
318        }
319        let (f_second, f_worst) = (simplex[n - 1].1, simplex[n].1);
320        let xr = along(&centroid, &simplex[n].0, -1.0);
321        let fr = f(&xr);
322        if fr < f_best {
323            let xe = along(&centroid, &simplex[n].0, -2.0);
324            let fe = f(&xe);
325            simplex[n] = if fe < fr { (xe, fe) } else { (xr, fr) };
326            continue;
327        }
328        if fr < f_second {
329            simplex[n] = (xr, fr);
330            continue;
331        }
332        // Contract outside, toward the reflected point, or inside.
333        let (xc, accept) = if fr < f_worst {
334            let xc = along(&centroid, &xr, 0.5);
335            let fc = f(&xc);
336            ((xc, fc), fc <= fr)
337        } else {
338            let xc = along(&centroid, &simplex[n].0, 0.5);
339            let fc = f(&xc);
340            ((xc, fc), fc < f_worst)
341        };
342        if accept {
343            simplex[n] = xc;
344            continue;
345        }
346        let x_best = simplex[0].0.clone();
347        for (v, fv) in &mut simplex[1..] {
348            *v = along(&x_best, v, 0.5);
349            *fv = f(v);
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    fn rosenbrock(x: &[f64]) -> f64 {
359        100.0 * (x[1] - x[0] * x[0]).powi(2) + (1.0 - x[0]).powi(2)
360    }
361
362    #[test]
363    fn nelder_mead_finds_the_bottom_of_a_quadratic_bowl() {
364        // A positive definite quadratic with minimum 2 at (1, -2, 0.5),
365        // by construction.
366        let f = |x: &[f64]| {
367            let (a, b, c) = (x[0] - 1.0, x[1] + 2.0, x[2] - 0.5);
368            a * a + 4.0 * b * b + 0.5 * c * c + 0.3 * a * b + 2.0
369        };
370        let m = nelder_mead(f, &[0.0, 0.0, 0.0], &NelderMead::default());
371        assert!(m.converged, "{m:?}");
372        for (x, want) in m.x.iter().zip([1.0, -2.0, 0.5]) {
373            assert!((x - want).abs() < 1e-7, "{m:?}");
374        }
375        assert!((m.value - 2.0).abs() < 1e-12, "{m:?}");
376    }
377
378    #[test]
379    fn nelder_mead_solves_rosenbrock() {
380        // Rosenbrock (1960) from the standard start (-1.2, 1); minimum 0 at (1, 1).
381        let m = nelder_mead(rosenbrock, &[-1.2, 1.0], &NelderMead::default());
382        assert!(m.converged, "{m:?}");
383        assert!(
384            (m.x[0] - 1.0).abs() < 1e-6 && (m.x[1] - 1.0).abs() < 1e-6,
385            "{m:?}"
386        );
387        assert!(m.value < 1e-12, "{m:?}");
388    }
389
390    #[test]
391    fn nelder_mead_works_in_one_dimension() {
392        // exp(x) - 3x has its minimum at ln 3.
393        let m = nelder_mead(|x| x[0].exp() - 3.0 * x[0], &[5.0], &NelderMead::default());
394        assert!(m.converged, "{m:?}");
395        assert!((m.x[0] - 3f64.ln()).abs() < 1e-7, "{m:?}");
396    }
397
398    #[test]
399    fn nelder_mead_steps_around_a_nan_region() {
400        // NaN for x >= 0.55; the minimum at (0.54, 1) sits next to that
401        // region. From (0.5, 0) the first vertex of the starting simplex,
402        // (0.6, 0), lies in it (call 0 is the start, call 1 that vertex).
403        let mut calls = 0;
404        let mut infeasible_calls = Vec::new();
405        let f = |x: &[f64]| {
406            calls += 1;
407            if x[0] >= 0.55 {
408                infeasible_calls.push(calls - 1);
409                return f64::NAN;
410            }
411            (x[0] - 0.54).powi(2) + (x[1] - 1.0).powi(2)
412        };
413        let m = nelder_mead(f, &[0.5, 0.0], &NelderMead::default());
414        assert!(m.converged, "{m:?}");
415        assert!(
416            (m.x[0] - 0.54).abs() < 1e-7 && (m.x[1] - 1.0).abs() < 1e-7,
417            "{m:?}"
418        );
419        assert_eq!(infeasible_calls.first(), Some(&1), "{infeasible_calls:?}");
420        assert!(
421            infeasible_calls.len() > 1,
422            "only the starting vertex was infeasible"
423        );
424    }
425
426    #[test]
427    fn nelder_mead_moves_off_a_start_near_zero() {
428        // A purely relative step would build a simplex 1e-11 wide around
429        // 1e-10, already within tolerance; the minimum is 0 at 3.
430        let m = nelder_mead(|x| (x[0] - 3.0).powi(2), &[1e-10], &NelderMead::default());
431        assert!(m.converged && m.iterations > 0, "{m:?}");
432        assert!((m.x[0] - 3.0).abs() < 1e-7 && m.value < 1e-14, "{m:?}");
433    }
434
435    #[test]
436    fn nelder_mead_rejects_a_step_within_tolerance() {
437        let f = |x: &[f64]| (x[0] - 3.0).powi(2);
438        for initial_step in [0.0, 1e-10, -0.1, f64::NAN, f64::INFINITY] {
439            let options = NelderMead {
440                initial_step,
441                ..NelderMead::default()
442            };
443            let m = nelder_mead(f, &[1.0], &options);
444            assert!(!m.converged && m.iterations == 0 && m.x == [1.0], "{m:?}");
445        }
446    }
447
448    #[test]
449    fn nelder_mead_reports_an_infeasible_start() {
450        let m = nelder_mead(|_| f64::NAN, &[1.0, 1.0], &NelderMead::default());
451        assert!(!m.converged && m.value == f64::INFINITY, "{m:?}");
452    }
453
454    #[test]
455    fn nelder_mead_stops_at_max_iterations() {
456        let options = NelderMead {
457            max_iterations: 10,
458            ..NelderMead::default()
459        };
460        let m = nelder_mead(rosenbrock, &[-1.2, 1.0], &options);
461        assert!(!m.converged && m.iterations == 10, "{m:?}");
462        assert!(m.value <= rosenbrock(&[-1.2, 1.0]), "{m:?}");
463    }
464
465    #[test]
466    fn brent_beats_golden_section_on_smooth_functions() {
467        let f = |x: f64| x.exp() - 3.0 * x;
468        let (x, _, n) = brent(0.0, 3.0, 1e-12, f);
469        assert!((x - 3f64.ln()).abs() < 1e-7, "{x}");
470        assert!(n < 20, "{n}");
471        // A minimum at the end of the bracket.
472        let (x, _, _) = brent(1.0, 2.0, 1e-12, |x| x * x);
473        assert!((x - 1.0).abs() < 1e-7);
474    }
475
476    #[test]
477    fn minimize_escapes_a_local_minimum() {
478        // A shallow local minimum near 0.5, the global one near 3.
479        let f = |x: f64| (x - 3.0).powi(2) * (x - 0.5).powi(2) - 0.5 * x;
480        let x = minimize(0.0, 4.0, f);
481        assert!(x > 2.5, "{x}");
482    }
483}