Skip to main content

scirs2_optimize/derivative_free/
bobyqa.rs

1//! BOBYQA: Bound Optimization BY Quadratic Approximation
2//!
3//! BOBYQA is a derivative-free optimization algorithm for problems with bound
4//! constraints. It maintains a quadratic model of the objective function
5//! interpolated through a set of points, and uses a trust-region framework
6//! to ensure progress.
7//!
8//! This implementation provides the key structure and trust-region quadratic
9//! approximation strategy described by Powell (2009).
10//!
11//! # References
12//! - Powell, M.J.D. (2009). "The BOBYQA algorithm for bound constrained optimization
13//!   without derivatives." Technical Report DAMTP 2009/NA06, Cambridge University.
14
15use super::{clip, DerivativeFreeOptimizer, DfOptResult};
16use crate::error::{OptimizeError, OptimizeResult};
17use scirs2_core::ndarray::Array1;
18
19/// Options for the BOBYQA algorithm
20#[derive(Debug, Clone)]
21pub struct BOBYQAOptions {
22    /// Lower bounds (None = -inf for each variable)
23    pub lower: Option<Vec<f64>>,
24    /// Upper bounds (None = +inf for each variable)
25    pub upper: Option<Vec<f64>>,
26    /// Initial trust region radius
27    pub rho_begin: f64,
28    /// Final trust region radius (convergence)
29    pub rho_end: f64,
30    /// Maximum number of function evaluations
31    pub max_fev: usize,
32    /// Maximum number of iterations
33    pub max_iter: usize,
34    /// Number of interpolation points (npt). Must satisfy n+2 <= npt <= (n+1)(n+2)/2.
35    /// Default: 2*n+1
36    pub npt: Option<usize>,
37    /// Absolute tolerance for function value change
38    pub f_tol: f64,
39    /// Absolute tolerance for step size
40    pub x_tol: f64,
41}
42
43impl Default for BOBYQAOptions {
44    fn default() -> Self {
45        BOBYQAOptions {
46            lower: None,
47            upper: None,
48            rho_begin: 1.0,
49            rho_end: 1e-6,
50            max_fev: 50000,
51            max_iter: 10000,
52            npt: None,
53            f_tol: 1e-10,
54            x_tol: 1e-8,
55        }
56    }
57}
58
59/// BOBYQA solver
60pub struct BOBYQASolver {
61    pub options: BOBYQAOptions,
62}
63
64impl BOBYQASolver {
65    /// Create with default options
66    pub fn new() -> Self {
67        BOBYQASolver {
68            options: BOBYQAOptions::default(),
69        }
70    }
71
72    /// Create with custom options
73    pub fn with_options(options: BOBYQAOptions) -> Self {
74        BOBYQASolver { options }
75    }
76
77    /// Get effective lower/upper bounds for dimension n
78    fn get_bounds(&self, n: usize) -> (Vec<f64>, Vec<f64>) {
79        let lo = match &self.options.lower {
80            Some(l) => l.clone(),
81            None => vec![f64::NEG_INFINITY; n],
82        };
83        let hi = match &self.options.upper {
84            Some(u) => u.clone(),
85            None => vec![f64::INFINITY; n],
86        };
87        (lo, hi)
88    }
89
90    /// Project point onto box [lo, hi]
91    fn project(&self, x: &[f64], lo: &[f64], hi: &[f64]) -> Vec<f64> {
92        x.iter()
93            .zip(lo.iter().zip(hi.iter()))
94            .map(|(&xi, (&l, &h))| clip(xi, l, h))
95            .collect()
96    }
97
98    /// Compute quadratic model gradient at current point via finite differences
99    fn quadratic_gradient<F: Fn(&[f64]) -> f64>(
100        &self,
101        func: &F,
102        x: &[f64],
103        rho: f64,
104        lo: &[f64],
105        hi: &[f64],
106        nfev: &mut usize,
107    ) -> Vec<f64> {
108        let h = rho * 0.01;
109        let n = x.len();
110        let mut g = vec![0.0; n];
111        let fx = {
112            *nfev += 1;
113            func(x)
114        };
115        for i in 0..n {
116            let mut xp = x.to_vec();
117            xp[i] = clip(x[i] + h, lo[i], hi[i]);
118            let actual_h = xp[i] - x[i];
119            if actual_h.abs() > 1e-15 {
120                *nfev += 1;
121                g[i] = (func(&xp) - fx) / actual_h;
122            } else {
123                let mut xm = x.to_vec();
124                xm[i] = clip(x[i] - h, lo[i], hi[i]);
125                let actual_hm = x[i] - xm[i];
126                if actual_hm.abs() > 1e-15 {
127                    *nfev += 1;
128                    g[i] = (fx - func(&xm)) / actual_hm;
129                }
130            }
131        }
132        g
133    }
134
135    /// Solve the trust-region subproblem subject to bounds.
136    /// Minimizes: g^T d + 0.5 d^T H d   s.t. ||d|| <= rho, lo <= x+d <= hi
137    /// Uses a truncated steepest descent / conjugate gradient approach.
138    fn trust_region_step(
139        &self,
140        g: &[f64],
141        hess_diag: &[f64],
142        x: &[f64],
143        rho: f64,
144        lo: &[f64],
145        hi: &[f64],
146    ) -> Vec<f64> {
147        let n = x.len();
148        // Cauchy point: steepest descent clipped to trust region and bounds
149        let gn = g.iter().map(|v| v * v).sum::<f64>().sqrt();
150        if gn < 1e-15 {
151            return vec![0.0; n];
152        }
153
154        // Compute gT H g for scaling
155        let gt_hg: f64 = g
156            .iter()
157            .zip(hess_diag.iter())
158            .map(|(&gi, &hi_v)| gi * hi_v.max(0.0) * gi)
159            .sum();
160
161        let tau = if gt_hg > 0.0 {
162            (gn * gn / gt_hg).min(rho / gn)
163        } else {
164            rho / gn
165        };
166
167        let d: Vec<f64> = g.iter().map(|&gi| -tau * gi).collect();
168
169        // Scale to fit trust region
170        let dn = d.iter().map(|v| v * v).sum::<f64>().sqrt();
171        let scale = if dn > rho { rho / dn } else { 1.0 };
172        let d: Vec<f64> = d.iter().map(|v| v * scale).collect();
173
174        // Clip to bounds
175        x.iter()
176            .zip(d.iter())
177            .zip(lo.iter().zip(hi.iter()))
178            .map(|((&xi, &di), (&l, &h))| clip(xi + di, l, h) - xi)
179            .collect()
180    }
181
182    /// Estimate diagonal Hessian via finite differences
183    fn hessian_diagonal<F: Fn(&[f64]) -> f64>(
184        &self,
185        func: &F,
186        x: &[f64],
187        fx: f64,
188        h: f64,
189        lo: &[f64],
190        hi: &[f64],
191        nfev: &mut usize,
192    ) -> Vec<f64> {
193        let n = x.len();
194        let mut hd = vec![0.0; n];
195        for i in 0..n {
196            let mut xp = x.to_vec();
197            let mut xm = x.to_vec();
198            xp[i] = clip(x[i] + h, lo[i], hi[i]);
199            xm[i] = clip(x[i] - h, lo[i], hi[i]);
200            let hp = xp[i] - x[i];
201            let hm = x[i] - xm[i];
202            if hp.abs() > 1e-15 && hm.abs() > 1e-15 {
203                let fp = {
204                    *nfev += 1;
205                    func(&xp)
206                };
207                let fm = {
208                    *nfev += 1;
209                    func(&xm)
210                };
211                hd[i] = (fp - 2.0 * fx + fm) / (hp * hm);
212            }
213        }
214        hd
215    }
216}
217
218impl Default for BOBYQASolver {
219    fn default() -> Self {
220        BOBYQASolver::new()
221    }
222}
223
224impl DerivativeFreeOptimizer for BOBYQASolver {
225    fn minimize<F>(&self, func: F, x0: &[f64]) -> OptimizeResult<DfOptResult>
226    where
227        F: Fn(&[f64]) -> f64,
228    {
229        let n = x0.len();
230        if n == 0 {
231            return Err(OptimizeError::InvalidInput(
232                "x0 must be non-empty".to_string(),
233            ));
234        }
235
236        let (lo, hi) = self.get_bounds(n);
237
238        // Validate bounds
239        for i in 0..n {
240            if lo[i] > hi[i] {
241                return Err(OptimizeError::InvalidInput(format!(
242                    "lower[{}] > upper[{}]",
243                    i, i
244                )));
245            }
246        }
247
248        let mut x = self.project(x0, &lo, &hi);
249        let mut rho = self.options.rho_begin;
250        let rho_end = self.options.rho_end;
251
252        let mut nfev = 0usize;
253        let mut nit = 0usize;
254
255        let mut fx = {
256            nfev += 1;
257            func(&x)
258        };
259
260        // Main trust-region loop
261        // We reduce rho geometrically when no progress is made
262        let rho_factor = 0.5_f64;
263        let max_rho_reductions = 100usize;
264        let mut rho_reductions = 0usize;
265
266        loop {
267            if nit >= self.options.max_iter || nfev >= self.options.max_fev {
268                break;
269            }
270
271            if rho < rho_end || rho_reductions >= max_rho_reductions {
272                return Ok(DfOptResult {
273                    x: Array1::from_vec(x),
274                    fun: fx,
275                    nfev,
276                    nit,
277                    success: rho < rho_end * 10.0,
278                    message: if rho < rho_end * 10.0 {
279                        "Converged: trust region radius below tolerance".to_string()
280                    } else {
281                        "Maximum trust region reductions reached".to_string()
282                    },
283                });
284            }
285
286            // Compute gradient
287            let g = self.quadratic_gradient(&func, &x, rho, &lo, &hi, &mut nfev);
288
289            // Compute diagonal Hessian
290            let h_step = rho * 0.1;
291            let hd = self.hessian_diagonal(&func, &x, fx, h_step, &lo, &hi, &mut nfev);
292
293            // Solve trust-region subproblem
294            let d = self.trust_region_step(&g, &hd, &x, rho, &lo, &hi);
295
296            let step_norm = d.iter().map(|v| v * v).sum::<f64>().sqrt();
297            if step_norm < 1e-15 {
298                rho *= rho_factor;
299                rho_reductions += 1;
300                nit += 1;
301                continue;
302            }
303
304            let xnew: Vec<f64> = x.iter().zip(d.iter()).map(|(&xi, &di)| xi + di).collect();
305            let xnew = self.project(&xnew, &lo, &hi);
306            nfev += 1;
307            let fnew = func(&xnew);
308
309            // Predicted reduction (linear model)
310            let pred = -g
311                .iter()
312                .zip(d.iter())
313                .map(|(&gi, &di)| gi * di)
314                .sum::<f64>();
315
316            // Actual reduction
317            let actual = fx - fnew;
318
319            // Accept/reject step
320            let ratio = if pred.abs() > 1e-15 {
321                actual / pred
322            } else if actual > 0.0 {
323                1.0
324            } else {
325                0.0
326            };
327
328            let f_change = (fnew - fx).abs();
329
330            if ratio > 0.0 || fnew < fx {
331                // Accept step
332                x = xnew;
333                fx = fnew;
334
335                // Expand trust region on good progress
336                if ratio > 0.75 {
337                    rho = (rho * 2.0).min(self.options.rho_begin);
338                    rho_reductions = rho_reductions.saturating_sub(1);
339                }
340            } else {
341                // Reject step, shrink trust region
342                rho *= rho_factor;
343                rho_reductions += 1;
344            }
345
346            // Check function value convergence
347            if f_change < self.options.f_tol && step_norm < self.options.x_tol {
348                return Ok(DfOptResult {
349                    x: Array1::from_vec(x),
350                    fun: fx,
351                    nfev,
352                    nit,
353                    success: true,
354                    message: "Converged: function/step tolerance".to_string(),
355                });
356            }
357
358            nit += 1;
359        }
360
361        Ok(DfOptResult {
362            x: Array1::from_vec(x),
363            fun: fx,
364            nfev,
365            nit,
366            success: false,
367            message: "Maximum iterations or function evaluations reached".to_string(),
368        })
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use approx::assert_abs_diff_eq;
376
377    #[test]
378    fn test_bobyqa_unconstrained_quadratic() {
379        let solver = BOBYQASolver::new();
380        let result = solver
381            .minimize(
382                |x: &[f64]| (x[0] - 2.0).powi(2) + (x[1] - 3.0).powi(2),
383                &[0.0, 0.0],
384            )
385            .expect("optimization failed");
386        assert_abs_diff_eq!(result.x[0], 2.0, epsilon = 1e-2);
387        assert_abs_diff_eq!(result.x[1], 3.0, epsilon = 1e-2);
388        assert_abs_diff_eq!(result.fun, 0.0, epsilon = 1e-3);
389    }
390
391    #[test]
392    fn test_bobyqa_bounded_simple() {
393        let opts = BOBYQAOptions {
394            lower: Some(vec![1.0, 1.0]),
395            upper: Some(vec![5.0, 5.0]),
396            ..Default::default()
397        };
398        let solver = BOBYQASolver::with_options(opts);
399        // Minimum at (-3, -4) but bounded to [1,5]^2, so bound-constrained min is (1,1)
400        let result = solver
401            .minimize(
402                |x: &[f64]| (x[0] + 3.0).powi(2) + (x[1] + 4.0).powi(2),
403                &[2.0, 2.0],
404            )
405            .expect("optimization failed");
406        assert_abs_diff_eq!(result.x[0], 1.0, epsilon = 1e-2);
407        assert_abs_diff_eq!(result.x[1], 1.0, epsilon = 1e-2);
408    }
409
410    #[test]
411    fn test_bobyqa_interior_minimum() {
412        // Interior minimum within bounds
413        let opts = BOBYQAOptions {
414            lower: Some(vec![-10.0, -10.0]),
415            upper: Some(vec![10.0, 10.0]),
416            rho_begin: 1.0,
417            rho_end: 1e-7,
418            max_fev: 100000,
419            ..Default::default()
420        };
421        let solver = BOBYQASolver::with_options(opts);
422        let result = solver
423            .minimize(
424                |x: &[f64]| (x[0] - 1.5).powi(2) + (x[1] + 0.5).powi(2),
425                &[5.0, 5.0],
426            )
427            .expect("optimization failed");
428        assert_abs_diff_eq!(result.x[0], 1.5, epsilon = 1e-2);
429        assert_abs_diff_eq!(result.x[1], -0.5, epsilon = 1e-2);
430    }
431
432    #[test]
433    fn test_bobyqa_1d_bounded() {
434        let opts = BOBYQAOptions {
435            lower: Some(vec![0.0]),
436            upper: Some(vec![4.0]),
437            rho_begin: 0.5,
438            rho_end: 1e-8,
439            max_fev: 10000,
440            ..Default::default()
441        };
442        let solver = BOBYQASolver::with_options(opts);
443        // Minimum at x=-2, bounded minimum at x=0
444        let result = solver
445            .minimize(|x: &[f64]| (x[0] + 2.0).powi(2), &[2.0])
446            .expect("optimization failed");
447        assert_abs_diff_eq!(result.x[0], 0.0, epsilon = 1e-2);
448    }
449
450    #[test]
451    fn test_bobyqa_initial_point_on_bound() {
452        let opts = BOBYQAOptions {
453            lower: Some(vec![0.0, 0.0]),
454            upper: Some(vec![3.0, 3.0]),
455            ..Default::default()
456        };
457        let solver = BOBYQASolver::with_options(opts);
458        // Start at boundary
459        let result = solver
460            .minimize(
461                |x: &[f64]| (x[0] - 1.0).powi(2) + (x[1] - 1.0).powi(2),
462                &[0.0, 0.0],
463            )
464            .expect("optimization failed");
465        // Should find interior minimum at (1, 1)
466        assert_abs_diff_eq!(result.x[0], 1.0, epsilon = 1e-2);
467        assert_abs_diff_eq!(result.x[1], 1.0, epsilon = 1e-2);
468    }
469}