Skip to main content

chronix_analytics/forecast/
optimizer.rs

1//! Numerical optimizer for smoothing parameter estimation.
2//!
3//! Implements the Nelder-Mead simplex algorithm (downhill simplex method)
4//! for unconstrained optimization of objective functions over low-dimensional
5//! parameter spaces (1–5 dimensions), which is the standard approach for
6//! exponential smoothing parameter estimation.
7//!
8//! # Box constraints
9//!
10//! Parameters are internally transformed via a logit/sigmoid mapping to
11//! enforce box constraints (e.g. α ∈ (0, 1)) while the optimizer operates
12//! in unconstrained space.
13//!
14//! # References
15//!
16//! - Nelder, J.A. & Mead, R. (1965). "A simplex method for function
17//!   minimization." *The Computer Journal*, 7(4), 308–313.
18//! - Lagarias et al. (1998). "Convergence properties of the Nelder–Mead
19//!   simplex method in low dimensions." *SIAM J. Optim.*, 9(1), 112–147.
20
21/// Result of an optimization run.
22#[derive(Debug, Clone)]
23pub struct OptResult {
24    /// Optimal parameter values.
25    pub params: Vec<f64>,
26    /// Objective function value at the optimum.
27    pub value: f64,
28    /// Number of function evaluations used.
29    pub n_evals: usize,
30    /// Whether the optimizer converged within tolerance.
31    pub converged: bool,
32}
33
34/// A box constraint on a parameter.
35#[derive(Debug, Clone, Copy)]
36pub struct Bound {
37    /// Lower bound (exclusive).
38    pub lo: f64,
39    /// Upper bound (exclusive).
40    pub hi: f64,
41}
42
43impl Bound {
44    /// Create a new bound.
45    #[must_use]
46    pub fn new(lo: f64, hi: f64) -> Self {
47        debug_assert!(lo < hi, "lo must be less than hi");
48        Self { lo, hi }
49    }
50
51    /// Transform a constrained value to unconstrained space via logit.
52    fn to_unconstrained(self, x: f64) -> f64 {
53        let clamped = x.clamp(self.lo + 1e-10, self.hi - 1e-10);
54        let t = (clamped - self.lo) / (self.hi - self.lo);
55        (t / (1.0 - t)).ln()
56    }
57
58    /// Transform an unconstrained value back to constrained space via sigmoid.
59    fn to_constrained(self, y: f64) -> f64 {
60        let t = 1.0 / (1.0 + (-y).exp());
61        self.lo + t * (self.hi - self.lo)
62    }
63}
64
65/// Minimize `f` over a bounded parameter space using Nelder-Mead.
66///
67/// # Arguments
68///
69/// * `f` — objective function to minimize, called with a slice of parameters
70/// * `x0` — initial guess (must be within bounds)
71/// * `bounds` — box constraints for each parameter
72/// * `max_iter` — maximum number of iterations
73/// * `tol` — convergence tolerance on the simplex diameter
74///
75/// # Returns
76///
77/// An [`OptResult`] with the optimal parameters in the constrained space.
78pub fn minimize_nelder_mead<F>(
79    f: F,
80    x0: &[f64],
81    bounds: &[Bound],
82    max_iter: usize,
83    tol: f64,
84) -> OptResult
85where
86    F: Fn(&[f64]) -> f64,
87{
88    let n = x0.len();
89    assert_eq!(n, bounds.len(), "x0 and bounds must have the same length");
90    assert!(n > 0, "must have at least one parameter");
91
92    // Standard Nelder-Mead coefficients
93    let alpha = 1.0; // reflection
94    let gamma = 2.0; // expansion
95    let rho = 0.5; // contraction
96    let sigma = 0.5; // shrink
97
98    let mut n_evals = 0usize;
99
100    // Evaluate the objective in constrained space
101    let eval = |y: &[f64], evals: &mut usize| -> f64 {
102        let x: Vec<f64> = y
103            .iter()
104            .zip(bounds.iter())
105            .map(|(&yi, &b)| b.to_constrained(yi))
106            .collect();
107        *evals += 1;
108        let val = f(&x);
109        if val.is_nan() || val.is_infinite() {
110            f64::MAX
111        } else {
112            val
113        }
114    };
115
116    // Transform initial guess to unconstrained space
117    let y0: Vec<f64> = x0
118        .iter()
119        .zip(bounds.iter())
120        .map(|(&xi, &b)| b.to_unconstrained(xi))
121        .collect();
122
123    // Build initial simplex (n+1 vertices)
124    let mut simplex: Vec<Vec<f64>> = Vec::with_capacity(n + 1);
125    simplex.push(y0.clone());
126    for i in 0..n {
127        let mut vertex = y0.clone();
128        // Use a perturbation that's meaningful in logit space
129        let delta = if vertex[i].abs() < 1e-8 {
130            0.5
131        } else {
132            vertex[i].abs() * 0.25
133        };
134        vertex[i] += delta;
135        simplex.push(vertex);
136    }
137
138    // Evaluate all vertices
139    let mut values: Vec<f64> = simplex.iter().map(|v| eval(v, &mut n_evals)).collect();
140
141    for _ in 0..max_iter {
142        // Sort by objective value
143        let mut order: Vec<usize> = (0..=n).collect();
144        order.sort_by(|&a, &b| {
145            values[a]
146                .partial_cmp(&values[b])
147                .unwrap_or(std::cmp::Ordering::Equal)
148        });
149
150        // Reorder simplex and values by rank
151        let sorted_simplex: Vec<Vec<f64>> = order.iter().map(|&i| simplex[i].clone()).collect();
152        let sorted_values: Vec<f64> = order.iter().map(|&i| values[i]).collect();
153        simplex = sorted_simplex;
154        values = sorted_values;
155
156        // Check convergence: diameter of simplex
157        let mut diameter = 0.0f64;
158        for i in 1..=n {
159            let dist: f64 = simplex[i]
160                .iter()
161                .zip(simplex[0].iter())
162                .map(|(a, b)| (a - b).powi(2))
163                .sum::<f64>()
164                .sqrt();
165            diameter = diameter.max(dist);
166        }
167        // Also check value spread
168        let val_spread = values[n] - values[0];
169        if diameter < tol && val_spread.abs() < tol {
170            break;
171        }
172
173        // Centroid of all vertices except the worst
174        let centroid: Vec<f64> = (0..n)
175            .map(|dim| simplex[..n].iter().map(|v| v[dim]).sum::<f64>() / n as f64)
176            .collect();
177
178        // Reflection
179        let reflected: Vec<f64> = centroid
180            .iter()
181            .zip(simplex[n].iter())
182            .map(|(&c, &w)| c + alpha * (c - w))
183            .collect();
184        let f_reflected = eval(&reflected, &mut n_evals);
185
186        if f_reflected < values[0] {
187            // Expansion
188            let expanded: Vec<f64> = centroid
189                .iter()
190                .zip(simplex[n].iter())
191                .map(|(&c, &w)| c + gamma * (c - w))
192                .collect();
193            let f_expanded = eval(&expanded, &mut n_evals);
194            if f_expanded < f_reflected {
195                simplex[n] = expanded;
196                values[n] = f_expanded;
197            } else {
198                simplex[n] = reflected;
199                values[n] = f_reflected;
200            }
201        } else if f_reflected < values[n - 1] {
202            // Accept reflection
203            simplex[n] = reflected;
204            values[n] = f_reflected;
205        } else {
206            // Contraction
207            let contracted: Vec<f64> = if f_reflected < values[n] {
208                // Outside contraction
209                centroid
210                    .iter()
211                    .zip(simplex[n].iter())
212                    .map(|(&c, &w)| c + rho * (c - w))
213                    .collect()
214            } else {
215                // Inside contraction
216                centroid
217                    .iter()
218                    .zip(simplex[n].iter())
219                    .map(|(&c, &w)| c - rho * (c - w))
220                    .collect()
221            };
222            let f_contracted = eval(&contracted, &mut n_evals);
223
224            if f_contracted < values[n].min(f_reflected) {
225                simplex[n] = contracted;
226                values[n] = f_contracted;
227            } else {
228                // Shrink: move all vertices toward the best
229                for i in 1..=n {
230                    for dim in 0..n {
231                        simplex[i][dim] =
232                            simplex[0][dim] + sigma * (simplex[i][dim] - simplex[0][dim]);
233                    }
234                    values[i] = eval(&simplex[i], &mut n_evals);
235                }
236            }
237        }
238    }
239
240    // Return the best vertex in constrained space
241    let best_idx = values
242        .iter()
243        .enumerate()
244        .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
245        .map(|(i, _)| i)
246        .unwrap_or(0);
247
248    let best_params: Vec<f64> = simplex[best_idx]
249        .iter()
250        .zip(bounds.iter())
251        .map(|(&yi, &b)| b.to_constrained(yi))
252        .collect();
253
254    let diameter: f64 = (1..=n)
255        .map(|i| {
256            simplex[i]
257                .iter()
258                .zip(simplex[0].iter())
259                .map(|(a, b)| (a - b).powi(2))
260                .sum::<f64>()
261                .sqrt()
262        })
263        .fold(0.0f64, f64::max);
264
265    OptResult {
266        params: best_params,
267        value: values[best_idx],
268        n_evals,
269        converged: diameter < tol,
270    }
271}
272
273/// Minimize a scalar function `f(x)` over `[lo, hi]` using Brent's method.
274///
275/// This is specialized for 1-D optimization (e.g. SES alpha).
276/// It combines golden-section search with parabolic interpolation for
277/// super-linear convergence.
278///
279/// # Arguments
280///
281/// * `f` — objective function to minimize
282/// * `lo`, `hi` — bracket on the optimal parameter
283/// * `tol` — convergence tolerance
284/// * `max_iter` — maximum number of iterations
285pub fn minimize_brent<F>(f: F, lo: f64, hi: f64, tol: f64, max_iter: usize) -> OptResult
286where
287    F: Fn(f64) -> f64,
288{
289    // Golden ratio
290    let golden = 0.5 * (3.0 - 5.0_f64.sqrt());
291
292    let mut a = lo;
293    let mut b = hi;
294    let mut x = a + golden * (b - a);
295    let mut w = x;
296    let mut v = x;
297    let mut fx = f(x);
298    let mut fw = fx;
299    let mut fv = fx;
300    let mut n_evals = 1;
301    let mut d: f64 = 0.0;
302    let mut e: f64 = 0.0;
303
304    for _ in 0..max_iter {
305        let m = 0.5 * (a + b);
306        let tol1 = tol * x.abs() + 1e-10;
307        let tol2 = 2.0 * tol1;
308
309        if (x - m).abs() <= tol2 - 0.5 * (b - a) {
310            break;
311        }
312
313        let mut use_golden = true;
314
315        // Try parabolic interpolation
316        if (e).abs() > tol1 {
317            let r = (x - w) * (fx - fv);
318            let mut q = (x - v) * (fx - fw);
319            let mut p = (x - v) * q - (x - w) * r;
320            q = 2.0 * (q - r);
321            if q > 0.0 {
322                p = -p;
323            } else {
324                q = -q;
325            }
326
327            if p.abs() < (0.5 * q * e).abs() && p > q * (a - x) && p < q * (b - x) {
328                // Accept parabolic step
329                e = d;
330                d = p / q;
331                let u = x + d;
332                if (u - a) < tol2 || (b - u) < tol2 {
333                    d = if x < m { tol1 } else { -tol1 };
334                }
335                use_golden = false;
336            }
337        }
338
339        if use_golden {
340            e = if x < m { b - x } else { a - x };
341            d = golden * e;
342        }
343
344        let u = if d.abs() >= tol1 {
345            x + d
346        } else if d > 0.0 {
347            x + tol1
348        } else {
349            x - tol1
350        };
351
352        let fu = f(u);
353        n_evals += 1;
354
355        if fu <= fx {
356            if u < x {
357                b = x;
358            } else {
359                a = x;
360            }
361            v = w;
362            fv = fw;
363            w = x;
364            fw = fx;
365            x = u;
366            fx = fu;
367        } else {
368            if u < x {
369                a = u;
370            } else {
371                b = u;
372            }
373            if fu <= fw || (w - x).abs() < 1e-15 {
374                v = w;
375                fv = fw;
376                w = u;
377                fw = fu;
378            } else if fu <= fv || (v - x).abs() < 1e-15 || (v - w).abs() < 1e-15 {
379                v = u;
380                fv = fu;
381            }
382        }
383    }
384
385    OptResult {
386        params: vec![x],
387        value: fx,
388        n_evals,
389        converged: true,
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn brent_finds_parabola_minimum() {
399        // f(x) = (x - 0.3)^2 → minimum at 0.3
400        let result = minimize_brent(|x| (x - 0.3).powi(2), 0.0, 1.0, 1e-8, 100);
401        assert!(result.converged);
402        assert!((result.params[0] - 0.3).abs() < 1e-6);
403        assert!(result.value < 1e-12);
404    }
405
406    #[test]
407    fn brent_asymmetric_function() {
408        // f(x) = (x - 0.7)^2 + 0.1 * x
409        let result = minimize_brent(|x| (x - 0.7).powi(2) + 0.1 * x, 0.0, 1.0, 1e-8, 100);
410        assert!(result.converged);
411        // Minimum at x = 0.7 - 0.05 = 0.65
412        assert!((result.params[0] - 0.65).abs() < 1e-5);
413    }
414
415    #[test]
416    fn nelder_mead_rosenbrock_2d() {
417        // Rosenbrock: f(x,y) = (1-x)^2 + 100*(y-x^2)^2
418        // Minimum at (1, 1) — we search in a bounded region
419        let result = minimize_nelder_mead(
420            |p| (1.0 - p[0]).powi(2) + 100.0 * (p[1] - p[0].powi(2)).powi(2),
421            &[0.5, 0.5],
422            &[Bound::new(-2.0, 3.0), Bound::new(-2.0, 3.0)],
423            1000,
424            1e-10,
425        );
426        assert!((result.params[0] - 1.0).abs() < 1e-3);
427        assert!((result.params[1] - 1.0).abs() < 1e-3);
428    }
429
430    #[test]
431    fn nelder_mead_1d() {
432        let result = minimize_nelder_mead(
433            |p| (p[0] - 0.4).powi(2),
434            &[0.1],
435            &[Bound::new(0.01, 0.99)],
436            200,
437            1e-10,
438        );
439        assert!((result.params[0] - 0.4).abs() < 1e-4);
440    }
441
442    #[test]
443    fn nelder_mead_3d() {
444        // f(x,y,z) = (x-0.3)^2 + (y-0.1)^2 + (z-0.2)^2
445        let result = minimize_nelder_mead(
446            |p| (p[0] - 0.3).powi(2) + (p[1] - 0.1).powi(2) + (p[2] - 0.2).powi(2),
447            &[0.5, 0.5, 0.5],
448            &[
449                Bound::new(0.01, 0.99),
450                Bound::new(0.01, 0.99),
451                Bound::new(0.01, 0.99),
452            ],
453            500,
454            1e-10,
455        );
456        assert!((result.params[0] - 0.3).abs() < 1e-3);
457        assert!((result.params[1] - 0.1).abs() < 1e-3);
458        assert!((result.params[2] - 0.2).abs() < 1e-3);
459    }
460
461    #[test]
462    fn bound_roundtrip() {
463        let b = Bound::new(0.0, 1.0);
464        for &x in &[0.01, 0.1, 0.3, 0.5, 0.7, 0.9, 0.99] {
465            let y = b.to_unconstrained(x);
466            let x2 = b.to_constrained(y);
467            assert!(
468                (x - x2).abs() < 1e-10,
469                "roundtrip failed for x={x}: got {x2}",
470            );
471        }
472    }
473}