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}