Skip to main content

nereids_fitting/
nelder_mead.rs

1//! Bounded Nelder-Mead simplex minimizer.
2//!
3//! Derivative-free polish optimizer used after a gradient-based stage to
4//! escape stall points.  Benchmarking of backgrounded counts-path fits on
5//! the synthetic counts benchmark established the need: a single L-BFGS
6//! start frequently stalls at the initial guess (1/20 self-flagged
7//! convergence on the hardest backgrounded regime tested), while a
8//! Nelder-Mead polish from that stall point resolves the failure cleanly
9//! (10/20 convergence, density bias from −5.94% to +0.013%, D/DOF from
10//! 905 to 1.001).  On real VENUS counts D saturates at 10⁴–10⁵ from
11//! un-modelled upstream physics and polish cannot self-terminate, which
12//! is why it is disabled by default — see
13//! `JointPoissonFitConfig::enable_polish` in `joint_poisson`.
14//!
15//! ## Algorithm
16//!
17//! Standard Nelder-Mead simplex with reflection / expansion / contraction /
18//! shrink (Nelder & Mead 1965), using the classical coefficients
19//! (α=1, γ=2, ρ=0.5, σ=0.5).
20//!
21//! Box bounds are enforced via **reflection at the wall**: when a proposed
22//! vertex would leave the feasible box, each coordinate is reflected back
23//! inside (`x_i ← 2·bound − x_i` once, then clamped).  This preserves the
24//! simplex volume in bulk while keeping all vertices feasible.
25//!
26//! ## Convergence
27//!
28//! Terminates when both
29//! - the maximum coordinate distance from any simplex vertex to the current
30//!   best vertex (`simplex[0]`) is below `xatol`, AND
31//! - the range of objective values across the simplex is below `fatol`.
32//!
33//! This matches scipy's `optimize.minimize(method='Nelder-Mead')` simplex-
34//! spread check (`max(|sim[i] - sim[0]|)` over coordinates) behaviour.
35
36use crate::error::FittingError;
37
38/// Nelder-Mead configuration.
39#[derive(Debug, Clone)]
40pub struct NelderMeadConfig {
41    /// Absolute tolerance on vertex displacement.
42    pub xatol: f64,
43    /// Absolute tolerance on objective range across the simplex.
44    pub fatol: f64,
45    /// Maximum number of simplex iterations (each iteration = at most a
46    /// constant number of objective evaluations).
47    pub max_iter: usize,
48    /// Initial simplex edge length, used as a signed multiplier on each
49    /// coordinate: `step_i = initial_step_frac * x0_i` (so 0.05 gives a
50    /// 5 % perturbation in the direction of the coordinate's sign).
51    /// When `|x0_i| < 1e-8` the fallback `initial_step_abs` is used
52    /// instead.  Note: this is NOT `initial_step_frac * max(|x0|, 1)`
53    /// — for `|x0| < 1` the perturbation is therefore smaller than
54    /// `initial_step_frac` itself.
55    pub initial_step_frac: f64,
56    /// Small absolute initial step for parameters whose `|x_0| < 1e-8`.
57    pub initial_step_abs: f64,
58}
59
60impl Default for NelderMeadConfig {
61    fn default() -> Self {
62        // Defaults match scipy.optimize.minimize(method='Nelder-Mead'):
63        // xatol = 1e-4, fatol = 1e-4.  The counts-path polish regime uses
64        // tighter tolerances (1e-9 / 1e-10) on the caller side.
65        Self {
66            xatol: 1e-4,
67            fatol: 1e-4,
68            max_iter: 5000,
69            initial_step_frac: 0.05,
70            initial_step_abs: 0.00025,
71        }
72    }
73}
74
75/// Nelder-Mead result.
76#[derive(Debug, Clone)]
77pub struct NelderMeadResult {
78    /// Best parameter vector found.
79    pub x: Vec<f64>,
80    /// Objective value at `x`.
81    pub fun: f64,
82    /// Number of simplex iterations performed.
83    pub iterations: usize,
84    /// Total objective evaluations (including initial simplex).
85    pub n_evals: usize,
86    /// `true` if both `xatol` and `fatol` were satisfied before hitting
87    /// `max_iter`.  Acceptance should be judged from the deviance
88    /// value, not this flag.
89    pub self_converged: bool,
90}
91
92/// Minimize a scalar objective with optional per-coordinate box bounds.
93///
94/// - `f` must be non-panicking; it may return `Err` to signal an infeasible
95///   point (the NM logic treats the vertex as +∞ and contracts away from it).
96/// - `x0` is the initial point.  An initial simplex of `n+1` vertices is
97///   built by perturbing each coordinate in turn.
98/// - `bounds`, if present, must have the same length as `x0`.  Each pair is
99///   `(lower, upper)`; use `f64::NEG_INFINITY` / `f64::INFINITY` to disable.
100///
101/// ## Panics
102///
103/// Does not panic on infeasible objective values.  Panics only if `x0` is
104/// empty or `bounds.len() != x0.len()`.
105pub fn nelder_mead_minimize<F>(
106    mut f: F,
107    x0: &[f64],
108    bounds: Option<&[(f64, f64)]>,
109    config: &NelderMeadConfig,
110) -> Result<NelderMeadResult, FittingError>
111where
112    F: FnMut(&[f64]) -> Result<f64, FittingError>,
113{
114    let n = x0.len();
115    assert!(n > 0, "nelder_mead_minimize: x0 must not be empty");
116    if let Some(b) = bounds {
117        assert_eq!(
118            b.len(),
119            n,
120            "nelder_mead_minimize: bounds length {} != x0 length {}",
121            b.len(),
122            n
123        );
124        for (i, &(lo, hi)) in b.iter().enumerate() {
125            assert!(
126                lo <= hi,
127                "nelder_mead_minimize: bound {i} has lo {lo} > hi {hi}"
128            );
129        }
130    }
131    // Classical Nelder-Mead coefficients.
132    const ALPHA: f64 = 1.0; // reflection
133    const GAMMA: f64 = 2.0; // expansion
134    const RHO: f64 = 0.5; // contraction
135    const SIGMA: f64 = 0.5; // shrink
136
137    // Project a point onto the bounding box.
138    let project = |x: &mut [f64]| {
139        if let Some(b) = bounds {
140            for (xi, &(lo, hi)) in x.iter_mut().zip(b.iter()) {
141                if *xi < lo {
142                    *xi = 2.0 * lo - *xi; // reflect
143                    if *xi > hi {
144                        *xi = hi;
145                    }
146                    if *xi < lo {
147                        *xi = lo;
148                    }
149                } else if *xi > hi {
150                    *xi = 2.0 * hi - *xi;
151                    if *xi < lo {
152                        *xi = lo;
153                    }
154                    if *xi > hi {
155                        *xi = hi;
156                    }
157                }
158            }
159        }
160    };
161
162    // Objective evaluator that turns Err into +∞ (infeasible → avoid).
163    let mut n_evals = 0usize;
164    let mut eval = |x: &[f64], f: &mut F| -> f64 {
165        n_evals += 1;
166        match f(x) {
167            Ok(v) if v.is_finite() => v,
168            _ => f64::INFINITY,
169        }
170    };
171
172    // Build initial simplex.  Vertex 0 is x0; vertex i>0 perturbs coord i-1.
173    let mut simplex: Vec<Vec<f64>> = Vec::with_capacity(n + 1);
174    let mut fvals: Vec<f64> = Vec::with_capacity(n + 1);
175    let mut v0 = x0.to_vec();
176    project(&mut v0);
177    fvals.push(eval(&v0, &mut f));
178    simplex.push(v0.clone());
179    for i in 0..n {
180        let mut v = v0.clone();
181        let base = v[i];
182        let step = if base.abs() > 1e-8 {
183            config.initial_step_frac * base
184        } else {
185            config.initial_step_abs
186        };
187        v[i] = base + step;
188        project(&mut v);
189        // If projection collapsed the perturbation (e.g. vertex hit a wall
190        // and the reflection / clamp put it back on the original coord),
191        // try the opposite direction so the simplex remains non-degenerate.
192        if (v[i] - base).abs() < 1e-14 {
193            v[i] = base - step;
194            project(&mut v);
195            if (v[i] - base).abs() < 1e-14 {
196                // Give up and use the tiny default step — the simplex is
197                // near a corner but still has to start somewhere.
198                v[i] = base
199                    + config
200                        .initial_step_abs
201                        .copysign(if base >= 0.0 { 1.0 } else { -1.0 });
202                project(&mut v);
203            }
204        }
205        fvals.push(eval(&v, &mut f));
206        simplex.push(v);
207    }
208
209    // Sort simplex by ascending f-value.
210    let mut order: Vec<usize> = (0..=n).collect();
211    order.sort_by(|&a, &b| {
212        fvals[a]
213            .partial_cmp(&fvals[b])
214            .unwrap_or(std::cmp::Ordering::Equal)
215    });
216    simplex = order.iter().map(|&i| simplex[i].clone()).collect();
217    fvals = order.iter().map(|&i| fvals[i]).collect();
218
219    let mut centroid = vec![0.0; n];
220    let mut xr = vec![0.0; n];
221    let mut xe = vec![0.0; n];
222    let mut xc = vec![0.0; n];
223
224    let mut iter = 0usize;
225    let mut self_converged = false;
226    while iter < config.max_iter {
227        iter += 1;
228
229        // Convergence check.
230        let fmin = fvals[0];
231        let fmax = fvals[n];
232        let frange = fmax - fmin;
233        // Max coordinate distance from any vertex to the best vertex
234        // (`simplex[0]`).  Matches the scipy Nelder-Mead spread check.
235        let mut xrange = 0.0f64;
236        for v in simplex.iter() {
237            for (j, &xj) in v.iter().enumerate() {
238                let d = (xj - simplex[0][j]).abs();
239                if d > xrange {
240                    xrange = d;
241                }
242            }
243        }
244        if xrange <= config.xatol && frange <= config.fatol {
245            self_converged = true;
246            break;
247        }
248
249        // Centroid of all vertices except the worst.
250        for (j, c) in centroid.iter_mut().enumerate() {
251            let mut s = 0.0;
252            for v in simplex.iter().take(n) {
253                s += v[j];
254            }
255            *c = s / (n as f64);
256        }
257
258        // Reflection.
259        for j in 0..n {
260            xr[j] = centroid[j] + ALPHA * (centroid[j] - simplex[n][j]);
261        }
262        project(&mut xr);
263        let fxr = eval(&xr, &mut f);
264
265        if fvals[0] <= fxr && fxr < fvals[n - 1] {
266            simplex[n] = xr.clone();
267            fvals[n] = fxr;
268        } else if fxr < fvals[0] {
269            // Expansion.
270            for j in 0..n {
271                xe[j] = centroid[j] + GAMMA * (xr[j] - centroid[j]);
272            }
273            project(&mut xe);
274            let fxe = eval(&xe, &mut f);
275            if fxe < fxr {
276                simplex[n] = xe.clone();
277                fvals[n] = fxe;
278            } else {
279                simplex[n] = xr.clone();
280                fvals[n] = fxr;
281            }
282        } else {
283            // Contraction.  Outside contraction (fxr ≥ f[n-1]) chooses the
284            // reflected side; inside contraction chooses the worst side.
285            let (x_src, f_src) = if fxr < fvals[n] {
286                (&xr, fxr)
287            } else {
288                (&simplex[n], fvals[n])
289            };
290            for j in 0..n {
291                xc[j] = centroid[j] + RHO * (x_src[j] - centroid[j]);
292            }
293            project(&mut xc);
294            let fxc = eval(&xc, &mut f);
295            if fxc < f_src {
296                simplex[n] = xc.clone();
297                fvals[n] = fxc;
298            } else {
299                // Shrink toward the best vertex.  Snapshot the best vertex
300                // first to avoid aliasing borrows when mutating
301                // `simplex[i]`.
302                let best = simplex[0].clone();
303                for i in 1..=n {
304                    for (j, xj) in simplex[i].iter_mut().enumerate() {
305                        *xj = best[j] + SIGMA * (*xj - best[j]);
306                    }
307                    project(&mut simplex[i]);
308                    fvals[i] = eval(&simplex[i], &mut f);
309                }
310            }
311        }
312
313        // Re-sort simplex (O(n log n) — n is small for our use).
314        let mut order: Vec<usize> = (0..=n).collect();
315        order.sort_by(|&a, &b| {
316            fvals[a]
317                .partial_cmp(&fvals[b])
318                .unwrap_or(std::cmp::Ordering::Equal)
319        });
320        simplex = order.iter().map(|&i| simplex[i].clone()).collect();
321        fvals = order.iter().map(|&i| fvals[i]).collect();
322    }
323
324    Ok(NelderMeadResult {
325        x: simplex[0].clone(),
326        fun: fvals[0],
327        iterations: iter,
328        n_evals,
329        self_converged,
330    })
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_nm_quadratic_1d_converges() {
339        // f(x) = (x − 3)².
340        let f = |x: &[f64]| Ok((x[0] - 3.0).powi(2));
341        let cfg = NelderMeadConfig {
342            xatol: 1e-10,
343            fatol: 1e-12,
344            max_iter: 5000,
345            initial_step_frac: 0.1,
346            initial_step_abs: 0.01,
347        };
348        let r = nelder_mead_minimize(f, &[0.0], None, &cfg).unwrap();
349        assert!((r.x[0] - 3.0).abs() < 1e-6, "x = {:?}", r.x);
350        assert!(r.fun < 1e-12);
351        assert!(r.self_converged);
352    }
353
354    #[test]
355    fn test_nm_rosenbrock_2d() {
356        // Classic: f(x,y) = (1-x)² + 100(y-x²)², minimum at (1,1) with f=0.
357        let f = |x: &[f64]| Ok((1.0 - x[0]).powi(2) + 100.0 * (x[1] - x[0].powi(2)).powi(2));
358        let cfg = NelderMeadConfig {
359            xatol: 1e-6,
360            fatol: 1e-8,
361            max_iter: 10_000,
362            initial_step_frac: 0.1,
363            initial_step_abs: 0.01,
364        };
365        let r = nelder_mead_minimize(f, &[-1.2, 1.0], None, &cfg).unwrap();
366        assert!(
367            (r.x[0] - 1.0).abs() < 1e-3 && (r.x[1] - 1.0).abs() < 1e-3,
368            "Rosenbrock minimizer off: x = {:?} fun = {}",
369            r.x,
370            r.fun
371        );
372        assert!(r.fun < 1e-6);
373    }
374
375    #[test]
376    fn test_nm_respects_bounds_reflection() {
377        // f(x) = (x − 5)²; but bound x to [0, 2] — true minimum inside the
378        // box is at x = 2 (boundary).  Verify NM returns x ≈ 2 and never a
379        // value outside the box during search.
380        let lo = 0.0;
381        let hi = 2.0;
382        let f = {
383            move |x: &[f64]| -> Result<f64, FittingError> {
384                assert!(
385                    x[0] >= lo - 1e-12 && x[0] <= hi + 1e-12,
386                    "NM passed out-of-bounds x = {}",
387                    x[0]
388                );
389                Ok((x[0] - 5.0).powi(2))
390            }
391        };
392        let cfg = NelderMeadConfig::default();
393        let bounds = [(lo, hi)];
394        let r = nelder_mead_minimize(f, &[1.0], Some(&bounds), &cfg).unwrap();
395        assert!(
396            (r.x[0] - 2.0).abs() < 1e-2,
397            "expected x ≈ 2, got {}",
398            r.x[0]
399        );
400        assert!(r.x[0] >= lo - 1e-12 && r.x[0] <= hi + 1e-12);
401    }
402
403    #[test]
404    fn test_nm_handles_infeasible_objective() {
405        // f returns Err for x[0] < 0.1, otherwise (x-0.5)^2.  NM should
406        // find x ≈ 0.5 and never return the infeasible region.
407        let f = |x: &[f64]| -> Result<f64, FittingError> {
408            if x[0] < 0.1 {
409                Err(FittingError::EvaluationFailed("x too small".into()))
410            } else {
411                Ok((x[0] - 0.5).powi(2))
412            }
413        };
414        let cfg = NelderMeadConfig {
415            xatol: 1e-8,
416            fatol: 1e-10,
417            max_iter: 5000,
418            initial_step_frac: 0.2,
419            initial_step_abs: 0.05,
420        };
421        let r = nelder_mead_minimize(f, &[1.0], None, &cfg).unwrap();
422        assert!(
423            (r.x[0] - 0.5).abs() < 1e-3,
424            "expected x ≈ 0.5, got {} (fun = {})",
425            r.x[0],
426            r.fun
427        );
428    }
429}