Skip to main content

ogeom_math/
optimize.rs

1//! Minima of functions of several variables over a box: local, and global.
2//!
3//! [`minimize_local`] walks downhill from a start by the Nelder-Mead simplex,
4//! which asks for values only. [`global_minimum`] finds the least value over
5//! the whole box by branch and bound: each sub-box's floor is its centre's
6//! value less a Lipschitz constant times its half-diagonal, the box with the
7//! lowest floor is split, and the search stops when no floor lies more than
8//! the tolerance below the best value found. The constant is estimated from
9//! the function's own slopes, so the certificate holds as far as that
10//! estimate does. [`swarm_minimum`] is the particle swarm, for a function
11//! too rough for a Lipschitz bound to say much.
12
13use std::collections::BinaryHeap;
14
15use ogeom_core::{OgeomResult, ogeom_bail};
16
17/// A minimum found over a box.
18#[derive(Debug, Clone, PartialEq)]
19pub struct Minimum {
20    /// Where.
21    pub point: Vec<f64>,
22    /// The function's value there.
23    pub value: f64,
24    /// Whether the search proved no point of the box lies lower by more
25    /// than its tolerance (under the slope bound it estimated), rather than
26    /// stopping at its evaluation budget.
27    pub certified: bool,
28    /// Function evaluations spent.
29    pub evaluations: usize,
30}
31
32fn check_box(lower: &[f64], upper: &[f64]) -> OgeomResult<()> {
33    if lower.is_empty() || lower.len() != upper.len() {
34        ogeom_bail!(
35            Construction,
36            "a box needs matching, non-empty bounds; got {} and {}",
37            lower.len(),
38            upper.len()
39        );
40    }
41    if lower
42        .iter()
43        .zip(upper)
44        .any(|(a, b)| !a.is_finite() || !b.is_finite() || b <= a)
45    {
46        ogeom_bail!(Construction, "a box's bounds must be finite and increasing");
47    }
48    Ok(())
49}
50
51/// One evaluation, counted, a NaN read as no minimum.
52fn call<F: FnMut(&[f64]) -> f64>(f: &mut F, x: &[f64], evaluations: &mut usize) -> f64 {
53    *evaluations += 1;
54    let v = f(x);
55    if v.is_nan() { f64::INFINITY } else { v }
56}
57
58fn clamp_into(x: &mut [f64], lower: &[f64], upper: &[f64]) {
59    for ((v, a), b) in x.iter_mut().zip(lower).zip(upper) {
60        *v = v.clamp(*a, *b);
61    }
62}
63
64/// A local minimum of `f` near `start` within the box, by the Nelder-Mead
65/// simplex from a first simplex of `step` along each axis. Points are held
66/// to the box.
67///
68/// # Errors
69///
70/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
71/// box is malformed or `start` does not match it.
72pub fn minimize_local<F: FnMut(&[f64]) -> f64>(
73    mut f: F,
74    start: &[f64],
75    lower: &[f64],
76    upper: &[f64],
77    step: f64,
78    tolerance: f64,
79    max_evaluations: usize,
80) -> OgeomResult<Minimum> {
81    check_box(lower, upper)?;
82    if start.len() != lower.len() {
83        ogeom_bail!(
84            Construction,
85            "the start has {} coordinates, the box {}",
86            start.len(),
87            lower.len()
88        );
89    }
90    let n = start.len();
91    let mut evaluations = 0usize;
92    let mut eval = |x: &mut Vec<f64>, evaluations: &mut usize| -> f64 {
93        clamp_into(x, lower, upper);
94        call(&mut f, x, evaluations)
95    };
96    let mut simplex: Vec<(Vec<f64>, f64)> = Vec::with_capacity(n + 1);
97    let mut first = start.to_vec();
98    let v = eval(&mut first, &mut evaluations);
99    simplex.push((first, v));
100    for i in 0..n {
101        let mut x = start.to_vec();
102        x[i] += if x[i] + step <= upper[i] { step } else { -step };
103        let v = eval(&mut x, &mut evaluations);
104        simplex.push((x, v));
105    }
106    while evaluations < max_evaluations {
107        simplex.sort_by(|a, b| a.1.total_cmp(&b.1));
108        let (best, worst) = (simplex[0].1, simplex[n].1);
109        let size = simplex[1..]
110            .iter()
111            .map(|(x, _)| {
112                x.iter()
113                    .zip(&simplex[0].0)
114                    .map(|(a, b)| (a - b).abs())
115                    .fold(0.0_f64, f64::max)
116            })
117            .fold(0.0_f64, f64::max);
118        if (worst - best).abs() <= tolerance && size <= tolerance.sqrt() * 1e-3 + 1e-14 {
119            break;
120        }
121        if size <= 1e-15 {
122            break;
123        }
124        #[allow(clippy::cast_precision_loss)]
125        let centroid: Vec<f64> = (0..n)
126            .map(|k| simplex[..n].iter().map(|(x, _)| x[k]).sum::<f64>() / n as f64)
127            .collect();
128        let toward = |t: f64| -> Vec<f64> {
129            centroid
130                .iter()
131                .zip(&simplex[n].0)
132                .map(|(c, w)| c + t * (c - w))
133                .collect()
134        };
135        let mut reflected = toward(1.0);
136        let fr = eval(&mut reflected, &mut evaluations);
137        if fr < simplex[0].1 {
138            let mut expanded = toward(2.0);
139            let fe = eval(&mut expanded, &mut evaluations);
140            simplex[n] = if fe < fr {
141                (expanded, fe)
142            } else {
143                (reflected, fr)
144            };
145        } else if fr < simplex[n - 1].1 {
146            simplex[n] = (reflected, fr);
147        } else {
148            let mut contracted = if fr < simplex[n].1 {
149                toward(0.5)
150            } else {
151                toward(-0.5)
152            };
153            let fc = eval(&mut contracted, &mut evaluations);
154            if fc < simplex[n].1.min(fr) {
155                simplex[n] = (contracted, fc);
156            } else {
157                // Shrink toward the best.
158                let anchor = simplex[0].0.clone();
159                for entry in simplex.iter_mut().skip(1) {
160                    let mut x: Vec<f64> = entry
161                        .0
162                        .iter()
163                        .zip(&anchor)
164                        .map(|(p, a)| a + 0.5 * (p - a))
165                        .collect();
166                    let v = eval(&mut x, &mut evaluations);
167                    *entry = (x, v);
168                }
169            }
170        }
171    }
172    simplex.sort_by(|a, b| a.1.total_cmp(&b.1));
173    let (point, value) = simplex.swap_remove(0);
174    Ok(Minimum {
175        point,
176        value,
177        certified: false,
178        evaluations,
179    })
180}
181
182/// One box of the search, ordered by its floor, lowest first.
183struct Cell {
184    floor: f64,
185    lower: Vec<f64>,
186    upper: Vec<f64>,
187}
188
189impl PartialEq for Cell {
190    fn eq(&self, other: &Self) -> bool {
191        self.floor.total_cmp(&other.floor).is_eq()
192    }
193}
194impl Eq for Cell {}
195impl PartialOrd for Cell {
196    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
197        Some(self.cmp(other))
198    }
199}
200impl Ord for Cell {
201    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
202        other.floor.total_cmp(&self.floor)
203    }
204}
205
206/// The bounds a box's floor is read from.
207struct Bounds {
208    lipschitz: f64,
209    curvature: f64,
210}
211
212impl Bounds {
213    /// A box's centre value and floor: the tighter of the slope bound from
214    /// its centre, and the centre's gradient with the curvature bound,
215    /// which closes on a smooth minimum as the box shrinks.
216    fn probe<F: FnMut(&[f64]) -> f64>(
217        &self,
218        f: &mut F,
219        c: &[f64],
220        r: f64,
221        evaluations: &mut usize,
222    ) -> (f64, f64) {
223        let fc = call(f, c, evaluations);
224        let mut gradient = 0.0_f64;
225        let step = (r * 1e-3).max(1e-9);
226        for k in 0..c.len() {
227            let mut a = c.to_vec();
228            let mut b = c.to_vec();
229            a[k] += step;
230            b[k] -= step;
231            let d = (call(f, &a, evaluations) - call(f, &b, evaluations)) / (2.0 * step);
232            gradient += d * d;
233        }
234        let first = fc - self.lipschitz * r;
235        let second = fc - gradient.sqrt() * r - 0.5 * self.curvature * r * r;
236        (fc, first.max(second))
237    }
238}
239
240/// The least value of `f` over the box, to within `tolerance`, by
241/// Lipschitz branch and bound with local polishing.
242///
243/// A box's floor is the tighter of two: its centre's value less a slope
244/// bound times its half-diagonal, and less the centre's gradient times the
245/// half-diagonal and half a curvature bound times its square, which closes
246/// on a smooth minimum as the box shrinks. Both bounds are estimated from
247/// finite differences over a sampling of the box and doubled; a function
248/// steeper or more sharply bent somewhere than any sample shows can hide a
249/// minimum from the certificate. `max_evaluations`
250/// caps the work; a search stopped by it reports `certified: false`.
251///
252/// # Errors
253///
254/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
255/// box is malformed or the tolerance is not positive.
256pub fn global_minimum<F: FnMut(&[f64]) -> f64>(
257    mut f: F,
258    lower: &[f64],
259    upper: &[f64],
260    tolerance: f64,
261    max_evaluations: usize,
262) -> OgeomResult<Minimum> {
263    check_box(lower, upper)?;
264    if !(tolerance > 0.0 && tolerance.is_finite()) {
265        ogeom_bail!(Construction, "a tolerance of {tolerance} is not positive");
266    }
267    let n = lower.len();
268    let mut evaluations = 0usize;
269
270    // The slope and curvature bounds: finite differences at a
271    // low-discrepancy sampling, along the axes and the diagonals.
272    let widths: Vec<f64> = lower.iter().zip(upper).map(|(a, b)| b - a).collect();
273    let mut best_point: Vec<f64> = lower
274        .iter()
275        .zip(upper)
276        .map(|(a, b)| 0.5 * (a + b))
277        .collect();
278    let mut best = call(&mut f, &best_point, &mut evaluations);
279    let (mut slope, mut bend) = (0.0_f64, 0.0_f64);
280    let directions: Vec<Vec<f64>> = (0..n)
281        .map(|k| (0..n).map(|j| if j == k { 1.0 } else { 0.0 }).collect())
282        .chain((0..n).map(|k| {
283            #[allow(clippy::cast_precision_loss)]
284            let norm = (n as f64).sqrt();
285            (0..n)
286                .map(|j| if j < k { -1.0 / norm } else { 1.0 / norm })
287                .collect()
288        }))
289        .collect();
290    let h = widths.iter().copied().fold(f64::INFINITY, f64::min) * 1e-3;
291    for i in 1..=32 * n {
292        let x: Vec<f64> = (0..n)
293            .map(|k| {
294                let t = halton(i, PRIMES[k % PRIMES.len()]);
295                lower[k] + h + (widths[k] - 2.0 * h) * t
296            })
297            .collect();
298        let fx = call(&mut f, &x, &mut evaluations);
299        if fx < best {
300            (best, best_point) = (fx, x.clone());
301        }
302        for d in &directions {
303            let ahead: Vec<f64> = x.iter().zip(d).map(|(v, e)| v + h * e).collect();
304            let behind: Vec<f64> = x.iter().zip(d).map(|(v, e)| v - h * e).collect();
305            let (fa, fb) = (
306                call(&mut f, &ahead, &mut evaluations),
307                call(&mut f, &behind, &mut evaluations),
308            );
309            if fx.is_finite() && fa.is_finite() && fb.is_finite() {
310                slope = slope.max((fa - fb).abs() / (2.0 * h));
311                bend = bend.max((fa - 2.0 * fx + fb).abs() / (h * h));
312            }
313        }
314    }
315    // Doubled for safety, and the slope bound widened by the dimension:
316    // the samples read it along a few directions only.
317    #[allow(clippy::cast_precision_loss)]
318    let lipschitz = (2.0 * slope * (n as f64).sqrt()).max(tolerance);
319    #[allow(clippy::cast_precision_loss)]
320    let curvature = (2.0 * bend * n as f64).max(tolerance);
321
322    let half_diagonal = |lo: &[f64], hi: &[f64]| -> f64 {
323        lo.iter()
324            .zip(hi)
325            .map(|(a, b)| (0.5 * (b - a)).powi(2))
326            .sum::<f64>()
327            .sqrt()
328    };
329    let bounds = Bounds {
330        lipschitz,
331        curvature,
332    };
333    let mut heap = BinaryHeap::new();
334    let centre: Vec<f64> = lower
335        .iter()
336        .zip(upper)
337        .map(|(a, b)| 0.5 * (a + b))
338        .collect();
339    let (_, floor) = bounds.probe(
340        &mut f,
341        &centre,
342        half_diagonal(lower, upper),
343        &mut evaluations,
344    );
345    heap.push(Cell {
346        floor,
347        lower: lower.to_vec(),
348        upper: upper.to_vec(),
349    });
350    let mut certified = false;
351    let step = widths.iter().copied().fold(f64::INFINITY, f64::min) * 1e-2;
352    while let Some(cell) = heap.pop() {
353        if cell.floor >= best - tolerance {
354            certified = true;
355            break;
356        }
357        if evaluations >= max_evaluations {
358            heap.push(cell);
359            break;
360        }
361        // Split across the longest side.
362        let k = (0..n)
363            .max_by(|a, b| {
364                (cell.upper[*a] - cell.lower[*a]).total_cmp(&(cell.upper[*b] - cell.lower[*b]))
365            })
366            .unwrap_or(0);
367        let mid = 0.5 * (cell.lower[k] + cell.upper[k]);
368        for half in 0..2 {
369            let (mut lo, mut hi) = (cell.lower.clone(), cell.upper.clone());
370            if half == 0 {
371                hi[k] = mid;
372            } else {
373                lo[k] = mid;
374            }
375            let c: Vec<f64> = lo.iter().zip(&hi).map(|(a, b)| 0.5 * (a + b)).collect();
376            let (fc, floor) = bounds.probe(&mut f, &c, half_diagonal(&lo, &hi), &mut evaluations);
377            if fc < best - tolerance {
378                // A new basin: polished before it is trusted.
379                let polished =
380                    minimize_local(&mut f, &c, lower, upper, step, tolerance * 1e-3, 400)?;
381                evaluations += polished.evaluations;
382                if polished.value < fc {
383                    (best, best_point) = (polished.value, polished.point);
384                } else {
385                    (best, best_point) = (fc, c.clone());
386                }
387            } else if fc < best {
388                (best, best_point) = (fc, c.clone());
389            }
390            heap.push(Cell {
391                floor,
392                lower: lo,
393                upper: hi,
394            });
395        }
396    }
397    if heap.is_empty() {
398        certified = true;
399    }
400    Ok(Minimum {
401        point: best_point,
402        value: best,
403        certified,
404        evaluations,
405    })
406}
407
408/// A minimum of `f` over the box by particle swarm: `particles` points
409/// moving under their own best and the swarm's best for `iterations`
410/// rounds, from a deterministic scattering, then polished locally. No
411/// certificate: the swarm reports the best it saw.
412///
413/// # Errors
414///
415/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
416/// box is malformed or there are no particles.
417pub fn swarm_minimum<F: FnMut(&[f64]) -> f64>(
418    mut f: F,
419    lower: &[f64],
420    upper: &[f64],
421    particles: usize,
422    iterations: usize,
423) -> OgeomResult<Minimum> {
424    check_box(lower, upper)?;
425    if particles == 0 {
426        ogeom_bail!(Construction, "a swarm needs particles");
427    }
428    let n = lower.len();
429    let mut evaluations = 0usize;
430    let widths: Vec<f64> = lower.iter().zip(upper).map(|(a, b)| b - a).collect();
431    let mut state = 0x9E37_79B9_7F4A_7C15_u64;
432    let mut random = move || -> f64 {
433        // xorshift64*, deterministic so a result reproduces.
434        state ^= state >> 12;
435        state ^= state << 25;
436        state ^= state >> 27;
437        #[allow(clippy::cast_precision_loss)]
438        let r = (state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64;
439        r
440    };
441    let mut position: Vec<Vec<f64>> = (1..=particles)
442        .map(|i| {
443            (0..n)
444                .map(|k| lower[k] + widths[k] * halton(i, PRIMES[k % PRIMES.len()]))
445                .collect()
446        })
447        .collect();
448    let mut velocity: Vec<Vec<f64>> = vec![vec![0.0; n]; particles];
449    let mut own_best: Vec<(Vec<f64>, f64)> = position
450        .iter()
451        .map(|x| (x.clone(), call(&mut f, x, &mut evaluations)))
452        .collect();
453    let mut swarm_best = own_best
454        .iter()
455        .min_by(|a, b| a.1.total_cmp(&b.1))
456        .cloned()
457        .unwrap_or_else(|| (position[0].clone(), f64::INFINITY));
458    let (inertia, own, social) = (0.72, 1.49, 1.49);
459    for _ in 0..iterations {
460        for p in 0..particles {
461            for k in 0..n {
462                let (r1, r2) = (random(), random());
463                velocity[p][k] = inertia * velocity[p][k]
464                    + own * r1 * (own_best[p].0[k] - position[p][k])
465                    + social * r2 * (swarm_best.0[k] - position[p][k]);
466                velocity[p][k] = velocity[p][k].clamp(-widths[k], widths[k]);
467                position[p][k] = (position[p][k] + velocity[p][k]).clamp(lower[k], upper[k]);
468            }
469            let v = call(&mut f, &position[p], &mut evaluations);
470            if v < own_best[p].1 {
471                own_best[p] = (position[p].clone(), v);
472                if v < swarm_best.1 {
473                    swarm_best = (position[p].clone(), v);
474                }
475            }
476        }
477    }
478    let step = widths.iter().copied().fold(f64::INFINITY, f64::min) * 1e-3;
479    let polished = minimize_local(&mut f, &swarm_best.0, lower, upper, step, 1e-14, 400)?;
480    let (point, best) = if polished.value < swarm_best.1 {
481        (polished.point, polished.value)
482    } else {
483        swarm_best
484    };
485    Ok(Minimum {
486        point,
487        value: best,
488        certified: false,
489        evaluations: evaluations + polished.evaluations,
490    })
491}
492
493const PRIMES: [usize; 8] = [2, 3, 5, 7, 11, 13, 17, 19];
494
495/// The `i`th element of the van der Corput sequence in `base`.
496fn halton(mut i: usize, base: usize) -> f64 {
497    let mut result = 0.0;
498    #[allow(clippy::cast_precision_loss)]
499    let mut fraction = 1.0 / base as f64;
500    while i > 0 {
501        #[allow(clippy::cast_precision_loss)]
502        {
503            result += fraction * (i % base) as f64;
504            fraction /= base as f64;
505        }
506        i /= base;
507    }
508    result
509}