chronix_analytics/forecast/
optimizer.rs1#[derive(Debug, Clone)]
23pub struct OptResult {
24 pub params: Vec<f64>,
26 pub value: f64,
28 pub n_evals: usize,
30 pub converged: bool,
32}
33
34#[derive(Debug, Clone, Copy)]
36pub struct Bound {
37 pub lo: f64,
39 pub hi: f64,
41}
42
43impl Bound {
44 #[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 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 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
65pub 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 let alpha = 1.0; let gamma = 2.0; let rho = 0.5; let sigma = 0.5; let mut n_evals = 0usize;
99
100 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 let y0: Vec<f64> = x0
118 .iter()
119 .zip(bounds.iter())
120 .map(|(&xi, &b)| b.to_unconstrained(xi))
121 .collect();
122
123 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 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 let mut values: Vec<f64> = simplex.iter().map(|v| eval(v, &mut n_evals)).collect();
140
141 for _ in 0..max_iter {
142 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 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 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 let val_spread = values[n] - values[0];
169 if diameter < tol && val_spread.abs() < tol {
170 break;
171 }
172
173 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 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 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 simplex[n] = reflected;
204 values[n] = f_reflected;
205 } else {
206 let contracted: Vec<f64> = if f_reflected < values[n] {
208 centroid
210 .iter()
211 .zip(simplex[n].iter())
212 .map(|(&c, &w)| c + rho * (c - w))
213 .collect()
214 } else {
215 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 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 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
273pub fn minimize_brent<F>(f: F, lo: f64, hi: f64, tol: f64, max_iter: usize) -> OptResult
286where
287 F: Fn(f64) -> f64,
288{
289 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 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 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 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 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 assert!((result.params[0] - 0.65).abs() < 1e-5);
413 }
414
415 #[test]
416 fn nelder_mead_rosenbrock_2d() {
417 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 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}