Skip to main content

basin/solver/
bobyqa.rs

1// Index-based loops mirror Powell's exposition (BOBYQA, Powell 2009) and the
2// dense index arithmetic of the H-factorization algebra; blanket-allowed here.
3#![allow(clippy::needless_range_loop)]
4
5//! BOBYQA (Powell 2009) — bound-constrained model-based derivative-free
6//! optimization.
7//!
8//! BOBYQA reuses the shared Powell quadratic-model core (the crate-internal
9//! `powell` module, shared with NEWUOA) and swaps NEWUOA's TRSAPP for the
10//! box-aware TRSBOX subproblem (§3),
11//! ALTMOV geometry (§3), and the RESCUE restoration procedure (§5). It binds
12//! [`CostFunction`](crate::core::problem::CostFunction) +
13//! [`BoxConstraints`](crate::core::constraint::BoxConstraints).
14
15pub(crate) mod driver;
16pub(crate) mod geometry;
17pub(crate) mod init;
18pub(crate) mod rescue;
19pub(crate) mod trsbox;
20
21#[cfg(test)]
22mod parity;
23
24use std::marker::PhantomData;
25
26use crate::core::constraint::BoxConstraints;
27use crate::core::inner::InitialState;
28use crate::core::math::{Scalar, VectorLen};
29use crate::core::problem::{CostFunction, Problem};
30use crate::core::solver::Solver;
31use crate::core::state::BobyqaState;
32use crate::core::termination::TerminationReason;
33
34use driver::{BobyqaWork, Transition};
35
36/// Type-state marker: box-constrained BOBYQA (the only mode). A marker keeps the
37/// door open for an unconstrained alias later without an API break, matching the
38/// `Lbfgs<Bounded>` / `NelderMead<Projected>` precedent.
39pub struct Bounded;
40
41/// BOBYQA (Powell 2009): bound-constrained model-based derivative-free
42/// trust-region optimization.
43///
44/// Configure the trust-region radii and interpolation-set size, then drive it
45/// with an [`Executor`](crate::Executor) over a [`BobyqaState`] on a problem
46/// that implements [`CostFunction`] + [`BoxConstraints`]:
47///
48/// ```
49/// use basin::{BoxConstraints, CostFunction, Executor, Bobyqa, BobyqaState, MaxCostEvals};
50///
51/// struct Booth {
52///     lower: Vec<f64>,
53///     upper: Vec<f64>,
54/// }
55/// impl CostFunction for Booth {
56///     type Param = Vec<f64>;
57///     type Output = f64;
58///     type Error = std::convert::Infallible;
59///     fn cost(&self, x: &Vec<f64>) -> Result<f64, std::convert::Infallible> {
60///         Ok((x[0] + 2.0 * x[1] - 7.0).powi(2) + (2.0 * x[0] + x[1] - 5.0).powi(2))
61///     }
62/// }
63/// impl BoxConstraints for Booth {
64///     fn lower(&self) -> &Vec<f64> { &self.lower }
65///     fn upper(&self) -> &Vec<f64> { &self.upper }
66/// }
67///
68/// let problem = Booth { lower: vec![-5.0, -5.0], upper: vec![5.0, 5.0] };
69/// let solver = Bobyqa::new().with_rho_beg(0.5).with_rho_end(1e-8);
70/// let state = BobyqaState::new(vec![0.0, 0.0]);
71/// let result = Executor::new(problem, solver, state)
72///     .terminate_on(MaxCostEvals(500))
73///     .run()
74///     .unwrap();
75/// assert!(result.best_param()[0].is_finite());
76/// ```
77///
78/// # Configuration
79///
80/// - [`with_rho_beg`](Self::with_rho_beg) — initial trust-region radius `ρ_beg`
81///   (default `1.0`). Also the initial `Δ`. The initial sampling needs room
82///   `b_i ≥ a_i + 2ρ_beg` (Powell 2009, eq. 2.1); if `ρ_beg` is too large for a
83///   narrow box it is reduced to `min(b_i − a_i)/4` (with `ρ_end` pulled down to
84///   match), mirroring PRIMA rather than rejecting the solve.
85/// - [`with_rho_end`](Self::with_rho_end) — final radius `ρ_end` (default
86///   `1e-6`); must satisfy `ρ_beg > ρ_end > 0`.
87/// - [`with_npt`](Self::with_npt) — interpolation-set size `npt`, in
88///   `[2n+1, ½(n+1)(n+2)]` (default `2n+1`).
89///
90/// # Panics
91///
92/// [`Executor::run`](crate::Executor::run) panics (in `init`) if the start point
93/// is empty, if `npt` is outside `[2n+1, ½(n+1)(n+2)]` (the `n+2 ≤ npt ≤ 2n`
94/// partial-model regime is not yet implemented), or if after the narrow-box
95/// revision `ρ_beg > ρ_end > 0` cannot hold (e.g. a degenerate `b_i = a_i`).
96///
97/// # RESCUE
98///
99/// The full method of RESCUE (Powell 2009, §5 — restoring the interpolation set
100/// when rounding damages the update denominator) is implemented and wired, but
101/// it fires only on severely ill-conditioned geometry. As Powell and PRIMA both
102/// note, it is essentially never invoked on well-behaved problems without heavy
103/// noise on the objective; expect it to stay dormant in normal use.
104///
105/// # Backends
106///
107/// Backend-generic over the parameter vector: `Vec<f64>`, nalgebra, ndarray, and
108/// faer all work — the parameter type needs only [`Clone`], [`VectorLen`], and
109/// `Index`/`IndexMut` element access. The model algebra is internal pure-Rust
110/// `Vec<f64>` scratch, so no `linalg`-tier op is required. wasm-clean.
111///
112/// # References
113///
114/// M. J. D. Powell, *The BOBYQA algorithm for bound constrained optimization
115/// without derivatives*, DAMTP report 2009/NA06, University of Cambridge.
116/// Cross-validated against [PRIMA](https://github.com/libprima/prima) v0.7.2.
117pub struct Bobyqa<Mode = Bounded, F = f64> {
118    rho_beg: F,
119    rho_end: F,
120    npt: Option<usize>,
121    /// Built in [`Solver::init`]; the resumable model + shifted bounds + ρ/Δ
122    /// schedule.
123    work: Option<BobyqaWork<F>>,
124    _mode: PhantomData<fn() -> Mode>,
125}
126
127impl<F: Scalar> Bobyqa<Bounded, F> {
128    /// A BOBYQA solver with the default schedule (`ρ_beg = 1`, `ρ_end = 1e-6`,
129    /// `npt = 2n+1`). Tune with the `with_*` builders.
130    pub fn new() -> Self {
131        Self {
132            rho_beg: F::from_f64(1.0).expect("1.0 representable"),
133            rho_end: F::from_f64(1e-6).expect("1e-6 representable"),
134            npt: None,
135            work: None,
136            _mode: PhantomData,
137        }
138    }
139
140    /// Set the initial trust-region radius `ρ_beg` (also the initial `Δ`).
141    pub fn with_rho_beg(mut self, rho_beg: F) -> Self {
142        self.rho_beg = rho_beg;
143        self
144    }
145
146    /// Set the final trust-region radius `ρ_end`. Must satisfy `ρ_beg > ρ_end > 0`.
147    pub fn with_rho_end(mut self, rho_end: F) -> Self {
148        self.rho_end = rho_end;
149        self
150    }
151
152    /// Set the interpolation-set size `npt`, in `[2n+1, ½(n+1)(n+2)]`. Defaults
153    /// to `2n+1` when unset.
154    pub fn with_npt(mut self, npt: usize) -> Self {
155        self.npt = Some(npt);
156        self
157    }
158}
159
160impl<F: Scalar> Default for Bobyqa<Bounded, F> {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166/// Build a `V` from a flat `&[F]`, reusing `template` for type and length.
167fn fill_from<V, F>(template: &V, slice: &[F]) -> V
168where
169    V: Clone + std::ops::IndexMut<usize, Output = F>,
170    F: Copy,
171{
172    let mut v = template.clone();
173    for (i, &x) in slice.iter().enumerate() {
174        v[i] = x;
175    }
176    v
177}
178
179/// Copy a backend vector `V` into a flat `Vec<F>` of length `n`.
180fn to_vec<V, F>(v: &V, n: usize) -> Vec<F>
181where
182    V: std::ops::Index<usize, Output = F>,
183    F: Copy,
184{
185    (0..n).map(|i| v[i]).collect()
186}
187
188impl<V, F> InitialState<V> for Bobyqa<Bounded, F>
189where
190    F: Scalar,
191    V: Clone,
192{
193    type State = BobyqaState<V, F>;
194    fn seed(&self, x: &V) -> Self::State {
195        BobyqaState::new(x.clone())
196    }
197}
198
199impl<P, V, F> Solver<P, BobyqaState<V, F>> for Bobyqa<Bounded, F>
200where
201    F: Scalar,
202    P: CostFunction<Param = V, Output = F> + BoxConstraints,
203    V: Clone
204        + VectorLen
205        + std::ops::Index<usize, Output = F>
206        + std::ops::IndexMut<usize, Output = F>,
207{
208    type Error = P::Error;
209
210    fn init(
211        &mut self,
212        problem: &mut Problem<P>,
213        mut state: BobyqaState<V, F>,
214    ) -> Result<BobyqaState<V, F>, Self::Error> {
215        let n = state.param.vec_len();
216        assert!(n >= 1, "Bobyqa requires a non-empty start point");
217        let npt = self.npt.unwrap_or(2 * n + 1);
218
219        let x0 = to_vec(&state.param, n);
220        let lower = to_vec(problem.inner().lower(), n);
221        let upper = to_vec(problem.inner().upper(), n);
222        let template = state.param.clone();
223
224        let (work, best_x, best_f) = {
225            let mut eval =
226                |slice: &[F]| -> Result<F, P::Error> { problem.cost(&fill_from(&template, slice)) };
227            BobyqaWork::try_init(
228                x0,
229                &lower,
230                &upper,
231                self.rho_beg,
232                self.rho_end,
233                npt,
234                &mut eval,
235            )?
236        };
237
238        state.param = fill_from(&template, &best_x);
239        state.cost = Some(best_f);
240        state.rho = work.rho();
241        self.work = Some(work);
242        Ok(state)
243    }
244
245    fn next_iter(
246        &mut self,
247        problem: &mut Problem<P>,
248        mut state: BobyqaState<V, F>,
249    ) -> Result<(BobyqaState<V, F>, Option<TerminationReason>), Self::Error> {
250        let template = state.param.clone();
251        let work = self
252            .work
253            .as_mut()
254            .expect("Bobyqa::init must run before next_iter");
255
256        let out = {
257            let mut eval =
258                |slice: &[F]| -> Result<F, P::Error> { problem.cost(&fill_from(&template, slice)) };
259            work.step(&mut eval)?
260        };
261        state.rho = work.rho();
262
263        // BOBYQA reports the least-F feasible point seen, so param/cost are
264        // monotone and coincide with best_param/best_cost.
265        let mut best_f = state.cost.expect("Bobyqa::init seeds the cost");
266        for (xabs, f_new) in &out.evaluated {
267            if *f_new < best_f {
268                best_f = *f_new;
269                state.param = fill_from(&template, xabs);
270                state.cost = Some(best_f);
271            }
272        }
273
274        let reason = match out.transition {
275            Transition::Converged => Some(TerminationReason::SolverConverged),
276            Transition::Continue | Transition::RhoReduced => None,
277        };
278        Ok((state, reason))
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use crate::core::constraint::BoxConstraints;
285    use crate::core::problem::CostFunction;
286    use crate::{Bobyqa, BobyqaState, Executor, MaxCostEvals};
287
288    struct Quad {
289        lower: Vec<f64>,
290        upper: Vec<f64>,
291    }
292    impl CostFunction for Quad {
293        type Param = Vec<f64>;
294        type Output = f64;
295        type Error = std::convert::Infallible;
296        fn cost(&self, x: &Vec<f64>) -> Result<f64, std::convert::Infallible> {
297            Ok((x[0] - 1.0).powi(2) + 2.0 * (x[1] + 2.0).powi(2))
298        }
299    }
300    impl BoxConstraints for Quad {
301        fn lower(&self) -> &Vec<f64> {
302            &self.lower
303        }
304        fn upper(&self) -> &Vec<f64> {
305            &self.upper
306        }
307    }
308
309    /// Converges to the interior minimizer (1, -2), well inside a slack box.
310    #[test]
311    fn converges_interior_minimum() {
312        let problem = Quad {
313            lower: vec![-5.0, -5.0],
314            upper: vec![5.0, 5.0],
315        };
316        let result = Executor::new(
317            problem,
318            Bobyqa::new().with_rho_beg(0.5).with_rho_end(1e-8),
319            BobyqaState::new(vec![0.0, 0.0]),
320        )
321        .terminate_on(MaxCostEvals(500))
322        .run()
323        .unwrap();
324        let x = result.best_param();
325        assert!((x[0] - 1.0).abs() < 1e-5, "x0 = {}", x[0]);
326        assert!((x[1] + 2.0).abs() < 1e-5, "x1 = {}", x[1]);
327        assert!(result.best_cost() < 1e-9, "cost = {}", result.best_cost());
328    }
329
330    /// A box narrower than `2·ρ_beg` triggers PRIMA's narrow-box revision: the
331    /// default `ρ_beg = 1.0` is reduced to `min(b_i − a_i)/4` instead of
332    /// panicking, and BOBYQA still converges to the interior optimum (1, −2).
333    #[test]
334    fn narrow_box_revises_rho_beg() {
335        let problem = Quad {
336            // widths 0.2 and 0.4 ⇒ min/2 = 0.1 ≪ default ρ_beg = 1.0.
337            lower: vec![0.9, -2.2],
338            upper: vec![1.1, -1.8],
339        };
340        let result = Executor::new(problem, Bobyqa::new(), BobyqaState::new(vec![1.0, -2.0]))
341            .terminate_on(MaxCostEvals(500))
342            .run()
343            .unwrap();
344        let x = result.best_param();
345        assert!((x[0] - 1.0).abs() < 1e-3, "x0 = {}", x[0]);
346        assert!((x[1] + 2.0).abs() < 1e-3, "x1 = {}", x[1]);
347    }
348
349    /// An infeasible start is clipped into the box, and BOBYQA still converges.
350    #[test]
351    fn infeasible_start_converges() {
352        let problem = Quad {
353            lower: vec![-3.0, -3.0],
354            upper: vec![3.0, 3.0],
355        };
356        let result = Executor::new(
357            problem,
358            Bobyqa::new().with_rho_beg(0.5).with_rho_end(1e-8),
359            BobyqaState::new(vec![100.0, -100.0]),
360        )
361        .terminate_on(MaxCostEvals(500))
362        .run()
363        .unwrap();
364        let x = result.best_param();
365        assert!((x[0] - 1.0).abs() < 1e-5, "x0 = {}", x[0]);
366        assert!((x[1] + 2.0).abs() < 1e-5, "x1 = {}", x[1]);
367    }
368
369    /// The minimizer lies outside the box; BOBYQA converges to the constrained
370    /// optimum at the corner (2, 2).
371    #[test]
372    fn converges_to_active_bound() {
373        struct Bowl {
374            lower: Vec<f64>,
375            upper: Vec<f64>,
376        }
377        impl CostFunction for Bowl {
378            type Param = Vec<f64>;
379            type Output = f64;
380            type Error = std::convert::Infallible;
381            fn cost(&self, x: &Vec<f64>) -> Result<f64, std::convert::Infallible> {
382                Ok((x[0] - 5.0).powi(2) + (x[1] - 5.0).powi(2))
383            }
384        }
385        impl BoxConstraints for Bowl {
386            fn lower(&self) -> &Vec<f64> {
387                &self.lower
388            }
389            fn upper(&self) -> &Vec<f64> {
390                &self.upper
391            }
392        }
393        let problem = Bowl {
394            lower: vec![-2.0, -2.0],
395            upper: vec![2.0, 2.0],
396        };
397        let result = Executor::new(
398            problem,
399            Bobyqa::new().with_rho_beg(0.5).with_rho_end(1e-8),
400            BobyqaState::new(vec![0.0, 0.0]),
401        )
402        .terminate_on(MaxCostEvals(500))
403        .run()
404        .unwrap();
405        let x = result.best_param();
406        assert!((x[0] - 2.0).abs() < 1e-5, "x0 = {}", x[0]);
407        assert!((x[1] - 2.0).abs() < 1e-5, "x1 = {}", x[1]);
408    }
409
410    /// Rosenbrock with slack bounds: BOBYQA reaches the valley minimum (1, 1).
411    #[test]
412    fn converges_rosenbrock_box() {
413        struct Rosen {
414            lower: Vec<f64>,
415            upper: Vec<f64>,
416        }
417        impl CostFunction for Rosen {
418            type Param = Vec<f64>;
419            type Output = f64;
420            type Error = std::convert::Infallible;
421            fn cost(&self, x: &Vec<f64>) -> Result<f64, std::convert::Infallible> {
422                Ok(100.0 * (x[1] - x[0] * x[0]).powi(2) + (1.0 - x[0]).powi(2))
423            }
424        }
425        impl BoxConstraints for Rosen {
426            fn lower(&self) -> &Vec<f64> {
427                &self.lower
428            }
429            fn upper(&self) -> &Vec<f64> {
430                &self.upper
431            }
432        }
433        let problem = Rosen {
434            lower: vec![-5.0, -5.0],
435            upper: vec![5.0, 5.0],
436        };
437        let result = Executor::new(
438            problem,
439            Bobyqa::new().with_rho_beg(0.5).with_rho_end(1e-6),
440            BobyqaState::new(vec![-1.2, 1.0]),
441        )
442        .terminate_on(MaxCostEvals(2000))
443        .run()
444        .unwrap();
445        let x = result.best_param();
446        assert!((x[0] - 1.0).abs() < 1e-3, "x0 = {}", x[0]);
447        assert!((x[1] - 1.0).abs() < 1e-3, "x1 = {}", x[1]);
448        assert!(result.best_cost() < 1e-6, "cost = {}", result.best_cost());
449    }
450}