1use super::{clip, DerivativeFreeOptimizer, DfOptResult};
16use crate::error::{OptimizeError, OptimizeResult};
17use scirs2_core::ndarray::Array1;
18
19#[derive(Debug, Clone)]
21pub struct BOBYQAOptions {
22 pub lower: Option<Vec<f64>>,
24 pub upper: Option<Vec<f64>>,
26 pub rho_begin: f64,
28 pub rho_end: f64,
30 pub max_fev: usize,
32 pub max_iter: usize,
34 pub npt: Option<usize>,
37 pub f_tol: f64,
39 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
59pub struct BOBYQASolver {
61 pub options: BOBYQAOptions,
62}
63
64impl BOBYQASolver {
65 pub fn new() -> Self {
67 BOBYQASolver {
68 options: BOBYQAOptions::default(),
69 }
70 }
71
72 pub fn with_options(options: BOBYQAOptions) -> Self {
74 BOBYQASolver { options }
75 }
76
77 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 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 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 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 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 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 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 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 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 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 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 let g = self.quadratic_gradient(&func, &x, rho, &lo, &hi, &mut nfev);
288
289 let h_step = rho * 0.1;
291 let hd = self.hessian_diagonal(&func, &x, fx, h_step, &lo, &hi, &mut nfev);
292
293 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 let pred = -g
311 .iter()
312 .zip(d.iter())
313 .map(|(&gi, &di)| gi * di)
314 .sum::<f64>();
315
316 let actual = fx - fnew;
318
319 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 x = xnew;
333 fx = fnew;
334
335 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 rho *= rho_factor;
343 rho_reductions += 1;
344 }
345
346 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 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 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 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 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 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}