use scirs2_core::ndarray::{Array1, Array2};
use scirs2_core::random::rngs::StdRng;
use scirs2_core::random::{Rng, SeedableRng};
use crate::error::{OptimizeError, OptimizeResult};
use super::acquisition::{AcquisitionFn, ExpectedImprovement};
use super::gp::{GpSurrogate, GpSurrogateConfig, RbfKernel};
use super::sampling::{generate_samples, SamplingStrategy};
fn erf_approx(x: f64) -> f64 {
let a1 = 0.254829592_f64;
let a2 = -0.284496736_f64;
let a3 = 1.421413741_f64;
let a4 = -1.453152027_f64;
let a5 = 1.061405429_f64;
let p = 0.3275911_f64;
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x_abs = x.abs();
let t = 1.0 / (1.0 + p * x_abs);
let poly = (((a5 * t + a4) * t + a3) * t + a2) * t + a1;
sign * (1.0 - poly * t * (-x_abs * x_abs).exp())
}
fn norm_cdf(z: f64) -> f64 {
0.5 * (1.0 + erf_approx(z / std::f64::consts::SQRT_2))
}
fn norm_pdf(z: f64) -> f64 {
(-0.5 * z * z).exp() / (2.0 * std::f64::consts::PI).sqrt()
}
pub struct BlackBoxConstraint {
pub name: String,
pub evaluate: Box<dyn Fn(&[f64]) -> f64 + Send + Sync>,
}
impl std::fmt::Debug for BlackBoxConstraint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "BlackBoxConstraint {{ name: {:?} }}", self.name)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ConstrainedAcquisitionStrategy {
ExpectedFeasibleImprovement,
ProbabilityOfFeasibility,
ConstrainedExpectedImprovement,
AugmentedLagrangian {
penalty: f64,
},
}
impl Default for ConstrainedAcquisitionStrategy {
fn default() -> Self {
Self::ExpectedFeasibleImprovement
}
}
#[derive(Clone)]
pub struct ConstrainedBoConfig {
pub strategy: ConstrainedAcquisitionStrategy,
pub n_initial: usize,
pub acq_n_candidates: usize,
pub xi: f64,
pub min_pof: f64,
pub seed: Option<u64>,
pub verbose: usize,
pub gp_config: GpSurrogateConfig,
}
impl Default for ConstrainedBoConfig {
fn default() -> Self {
Self {
strategy: ConstrainedAcquisitionStrategy::default(),
n_initial: 10,
acq_n_candidates: 300,
xi: 0.01,
min_pof: 0.0,
seed: None,
verbose: 0,
gp_config: GpSurrogateConfig {
noise_variance: 1e-4,
optimize_hyperparams: true,
..Default::default()
},
}
}
}
#[derive(Debug, Clone)]
pub struct ConstrainedObservation {
pub x: Array1<f64>,
pub y: f64,
pub constraint_values: Vec<f64>,
pub feasible: bool,
}
#[derive(Debug, Clone)]
pub struct ConstrainedBoResult {
pub x_best: Option<Array1<f64>>,
pub f_best: f64,
pub observations: Vec<ConstrainedObservation>,
pub n_evals: usize,
pub best_history: Vec<f64>,
pub n_feasible: usize,
}
struct ConstraintSurrogate {
gp: GpSurrogate,
idx: usize,
}
impl ConstraintSurrogate {
fn new(idx: usize, config: GpSurrogateConfig) -> Self {
let gp = GpSurrogate::new(Box::new(RbfKernel::default()), config);
Self { gp, idx }
}
fn fit(&mut self, x: &Array2<f64>, g: &Array1<f64>) -> OptimizeResult<()> {
if x.nrows() < 2 {
return Ok(()); }
self.gp.fit(x, g)
}
fn predict_pof(&self, x: &scirs2_core::ndarray::ArrayView1<f64>) -> OptimizeResult<f64> {
if self.gp.n_train() == 0 {
return Ok(0.5);
}
let (mu, sigma) = self.gp.predict_single(x)?;
if sigma < 1e-12 {
return Ok(if mu <= 0.0 { 1.0 } else { 0.0 });
}
Ok(norm_cdf(-mu / sigma))
}
fn predict_expected_violation(
&self,
x: &scirs2_core::ndarray::ArrayView1<f64>,
) -> OptimizeResult<f64> {
if self.gp.n_train() == 0 {
return Ok(0.5);
}
let (mu, sigma) = self.gp.predict_single(x)?;
if sigma < 1e-12 {
return Ok(mu.max(0.0));
}
let z = mu / sigma;
Ok((mu * norm_cdf(z) + sigma * norm_pdf(z)).max(0.0))
}
fn idx(&self) -> usize {
self.idx
}
}
fn joint_pof(
x: &scirs2_core::ndarray::ArrayView1<f64>,
constraint_surrogates: &[ConstraintSurrogate],
) -> OptimizeResult<f64> {
let mut pof = 1.0_f64;
for cs in constraint_surrogates {
pof *= cs.predict_pof(x)?;
}
Ok(pof)
}
pub struct ConstrainedBo {
bounds: Vec<(f64, f64)>,
constraints: Vec<BlackBoxConstraint>,
config: ConstrainedBoConfig,
obj_surrogate: GpSurrogate,
constraint_surrogates: Vec<ConstraintSurrogate>,
observations: Vec<ConstrainedObservation>,
rng: StdRng,
f_best: f64,
best_history: Vec<f64>,
lagrange_multipliers: Vec<f64>,
}
impl ConstrainedBo {
pub fn new(
bounds: Vec<(f64, f64)>,
constraints: Vec<BlackBoxConstraint>,
config: ConstrainedBoConfig,
) -> OptimizeResult<Self> {
if bounds.is_empty() {
return Err(OptimizeError::InvalidInput("bounds must not be empty".into()));
}
let seed = config.seed.unwrap_or(0);
let rng = StdRng::seed_from_u64(seed);
let obj_surrogate = GpSurrogate::new(
Box::new(RbfKernel::default()),
config.gp_config.clone(),
);
let constraint_surrogates: Vec<_> = (0..constraints.len())
.map(|i| {
ConstraintSurrogate::new(
i,
GpSurrogateConfig {
noise_variance: 1e-4,
optimize_hyperparams: false,
..Default::default()
},
)
})
.collect();
let n_constraints = constraints.len();
Ok(Self {
bounds,
constraints,
config,
obj_surrogate,
constraint_surrogates,
observations: Vec::new(),
rng,
f_best: f64::INFINITY,
best_history: Vec::new(),
lagrange_multipliers: vec![1.0; n_constraints],
})
}
fn acquisition_value(
&self,
x: &scirs2_core::ndarray::ArrayView1<f64>,
) -> OptimizeResult<f64> {
match self.config.strategy {
ConstrainedAcquisitionStrategy::ProbabilityOfFeasibility => {
joint_pof(x, &self.constraint_surrogates)
}
ConstrainedAcquisitionStrategy::ExpectedFeasibleImprovement => {
let pof = joint_pof(x, &self.constraint_surrogates)?;
if pof < 1e-10 {
return Ok(0.0);
}
if self.obj_surrogate.n_train() == 0 {
return Ok(pof);
}
let ei = ExpectedImprovement::new(self.f_best, self.config.xi);
let ei_val = ei.evaluate(x, &self.obj_surrogate)?;
Ok(ei_val * pof)
}
ConstrainedAcquisitionStrategy::ConstrainedExpectedImprovement => {
if self.obj_surrogate.n_train() == 0 {
return joint_pof(x, &self.constraint_surrogates);
}
let ei = ExpectedImprovement::new(self.f_best, self.config.xi);
let ei_val = ei.evaluate(x, &self.obj_surrogate)?;
let pof = joint_pof(x, &self.constraint_surrogates)?;
Ok(ei_val * pof)
}
ConstrainedAcquisitionStrategy::AugmentedLagrangian { penalty } => {
if self.obj_surrogate.n_train() == 0 {
return joint_pof(x, &self.constraint_surrogates);
}
let ei = ExpectedImprovement::new(self.f_best, self.config.xi);
let ei_val = ei.evaluate(x, &self.obj_surrogate)?;
let mut total_penalty = 0.0_f64;
for (cs, &lam) in self
.constraint_surrogates
.iter()
.zip(self.lagrange_multipliers.iter())
{
let ev = cs.predict_expected_violation(x)?;
total_penalty += (lam + penalty * ev) * ev;
}
Ok((ei_val - total_penalty).max(0.0))
}
}
}
fn update_lagrange_multipliers(&mut self) {
if let ConstrainedAcquisitionStrategy::AugmentedLagrangian { penalty } =
self.config.strategy
{
for (i, lam) in self.lagrange_multipliers.iter_mut().enumerate() {
let avg_viol = self
.observations
.iter()
.filter_map(|obs| obs.constraint_values.get(i).copied())
.filter(|&v| v > 0.0)
.sum::<f64>()
/ (self.observations.len().max(1) as f64);
*lam = (*lam + penalty * avg_viol).max(0.0);
}
}
}
pub fn ask(&mut self) -> OptimizeResult<Vec<f64>> {
let ndim = self.bounds.len();
if self.observations.len() < self.config.n_initial {
let x: Vec<f64> = self
.bounds
.iter()
.map(|&(lo, hi)| lo + self.rng.random::<f64>() * (hi - lo))
.collect();
return Ok(x);
}
let candidates = generate_samples(
self.config.acq_n_candidates,
&self.bounds,
SamplingStrategy::LatinHypercube,
None,
)?;
let mut best_acq = f64::NEG_INFINITY;
let mut best_x: Vec<f64> = candidates.row(0).to_vec();
for i in 0..candidates.nrows() {
let row = candidates.row(i);
let pof = joint_pof(&row, &self.constraint_surrogates)?;
if pof < self.config.min_pof {
continue;
}
let val = self.acquisition_value(&row)?;
if val > best_acq {
best_acq = val;
best_x = row.to_vec();
}
}
if best_acq == f64::NEG_INFINITY {
for i in 0..candidates.nrows() {
let row = candidates.row(i);
let pof = joint_pof(&row, &self.constraint_surrogates)?;
if pof > best_acq {
best_acq = pof;
best_x = row.to_vec();
}
}
}
let _ = ndim; Ok(best_x)
}
fn evaluate_point<F>(&self, x: &[f64], objective: &mut F) -> (f64, Vec<f64>)
where
F: FnMut(&[f64]) -> f64,
{
let y = objective(x);
let g_vals: Vec<f64> = self.constraints.iter().map(|c| (c.evaluate)(x)).collect();
(y, g_vals)
}
pub fn tell(&mut self, x: Vec<f64>, y: f64, constraint_values: Vec<f64>) -> OptimizeResult<()> {
let ndim = self.bounds.len();
if x.len() != ndim {
return Err(OptimizeError::InvalidInput(format!(
"x has {} dims, expected {}",
x.len(),
ndim
)));
}
let feasible = constraint_values.iter().all(|&g| g <= 0.0);
if feasible && y < self.f_best {
self.f_best = y;
}
self.best_history.push(self.f_best);
self.observations.push(ConstrainedObservation {
x: Array1::from_vec(x.clone()),
y,
constraint_values: constraint_values.clone(),
feasible,
});
let feasible_obs: Vec<&ConstrainedObservation> =
self.observations.iter().filter(|o| o.feasible).collect();
if feasible_obs.len() >= 2 {
let nf = feasible_obs.len();
let mut x_rows = Vec::with_capacity(nf * ndim);
let mut y_vec = Vec::with_capacity(nf);
for obs in &feasible_obs {
x_rows.extend(obs.x.iter().copied());
y_vec.push(obs.y);
}
let x_mat = Array2::from_shape_vec((nf, ndim), x_rows)
.map_err(|e| OptimizeError::ComputationError(format!("shape: {}", e)))?;
let y_arr = Array1::from_vec(y_vec);
self.obj_surrogate.fit(&x_mat, &y_arr)?;
}
let n = self.observations.len();
if n >= 2 {
let mut x_rows = Vec::with_capacity(n * ndim);
for obs in &self.observations {
x_rows.extend(obs.x.iter().copied());
}
let x_mat = Array2::from_shape_vec((n, ndim), x_rows.clone())
.map_err(|e| OptimizeError::ComputationError(format!("shape: {}", e)))?;
for (i, cs) in self.constraint_surrogates.iter_mut().enumerate() {
let g_vec: Vec<f64> = self
.observations
.iter()
.filter_map(|obs| obs.constraint_values.get(i).copied())
.collect();
if g_vec.len() == n {
let g_arr = Array1::from_vec(g_vec);
let _ = cs.fit(&x_mat, &g_arr); }
}
}
self.update_lagrange_multipliers();
Ok(())
}
pub fn optimize<F>(&mut self, mut objective: F, n_calls: usize) -> OptimizeResult<ConstrainedBoResult>
where
F: FnMut(&[f64]) -> f64,
{
for iter in 0..n_calls {
let x = self.ask()?;
let (y, g_vals) = self.evaluate_point(&x, &mut objective);
let feasible = g_vals.iter().all(|&g| g <= 0.0);
if self.config.verbose >= 2 {
println!(
"[ConstrainedBo iter {}] x={:?} y={:.6} g={:?} feasible={}",
iter, x, y, g_vals, feasible
);
}
self.tell(x, y, g_vals)?;
}
let n_feasible = self.observations.iter().filter(|o| o.feasible).count();
if self.config.verbose >= 1 {
println!(
"[ConstrainedBo] Done. Best feasible f={:.6}, {}/{} feasible.",
self.f_best,
n_feasible,
self.observations.len()
);
}
let best_feasible = self
.observations
.iter()
.filter(|o| o.feasible)
.min_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal));
let (x_best, f_best) = if let Some(obs) = best_feasible {
(Some(obs.x.clone()), obs.y)
} else {
(None, f64::INFINITY)
};
Ok(ConstrainedBoResult {
x_best,
f_best,
observations: self.observations.clone(),
n_evals: self.observations.len(),
best_history: self.best_history.clone(),
n_feasible,
})
}
pub fn observations(&self) -> &[ConstrainedObservation] {
&self.observations
}
pub fn n_feasible(&self) -> usize {
self.observations.iter().filter(|o| o.feasible).count()
}
}
pub struct ExpectedFeasibleImprovement {
pub f_best: f64,
pub xi: f64,
}
impl ExpectedFeasibleImprovement {
pub fn new(f_best: f64, xi: f64) -> Self {
Self { f_best, xi: xi.max(0.0) }
}
pub fn evaluate(
&self,
x: &scirs2_core::ndarray::ArrayView1<f64>,
obj_gp: &GpSurrogate,
constraint_gps: &[&GpSurrogate],
) -> OptimizeResult<f64> {
let ei = ExpectedImprovement::new(self.f_best, self.xi);
let ei_val = ei.evaluate(x, obj_gp)?;
let mut pof = 1.0_f64;
for cgp in constraint_gps {
if cgp.n_train() == 0 {
pof *= 0.5; continue;
}
let (mu_g, sigma_g) = cgp.predict_single(x)?;
let pof_i = if sigma_g < 1e-12 {
if mu_g <= 0.0 { 1.0 } else { 0.0 }
} else {
norm_cdf(-mu_g / sigma_g)
};
pof *= pof_i;
}
Ok(ei_val * pof)
}
}
pub struct ProbabilityOfFeasibility;
impl ProbabilityOfFeasibility {
pub fn evaluate(
x: &scirs2_core::ndarray::ArrayView1<f64>,
constraint_gp: &GpSurrogate,
) -> OptimizeResult<f64> {
if constraint_gp.n_train() == 0 {
return Ok(0.5);
}
let (mu, sigma) = constraint_gp.predict_single(x)?;
if sigma < 1e-12 {
return Ok(if mu <= 0.0 { 1.0 } else { 0.0 });
}
Ok(norm_cdf(-mu / sigma))
}
pub fn joint(
x: &scirs2_core::ndarray::ArrayView1<f64>,
constraint_gps: &[&GpSurrogate],
) -> OptimizeResult<f64> {
let mut pof = 1.0_f64;
for cgp in constraint_gps {
pof *= Self::evaluate(x, cgp)?;
}
Ok(pof)
}
}
pub fn constrained_optimize<F>(
objective: F,
constraints: Vec<BlackBoxConstraint>,
bounds: Vec<(f64, f64)>,
n_calls: usize,
seed: Option<u64>,
) -> OptimizeResult<ConstrainedBoResult>
where
F: FnMut(&[f64]) -> f64,
{
let config = ConstrainedBoConfig {
n_initial: (n_calls / 4).max(3),
seed,
..Default::default()
};
let mut cbo = ConstrainedBo::new(bounds, constraints, config)?;
cbo.optimize(objective, n_calls)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_constraint(threshold: f64) -> BlackBoxConstraint {
BlackBoxConstraint {
name: format!("x_leq_{}", threshold),
evaluate: Box::new(move |x: &[f64]| x[0] - threshold),
}
}
#[test]
fn test_unconstrained_like_run() {
let mut cbo = ConstrainedBo::new(
vec![(0.0_f64, 4.0_f64)],
vec![],
ConstrainedBoConfig {
n_initial: 3,
seed: Some(42),
..Default::default()
},
)
.expect("create");
let result = cbo
.optimize(|x: &[f64]| (x[0] - 2.0_f64).powi(2), 8)
.expect("opt");
assert!(result.f_best.is_finite());
assert_eq!(result.n_evals, 8);
}
#[test]
fn test_constraint_eliminates_region() {
let con = make_constraint(1.0);
let mut cbo = ConstrainedBo::new(
vec![(0.0_f64, 4.0_f64)],
vec![con],
ConstrainedBoConfig {
n_initial: 4,
seed: Some(99),
acq_n_candidates: 100,
..Default::default()
},
)
.expect("create");
let result = cbo
.optimize(|x: &[f64]| (x[0] - 3.0_f64).powi(2), 12)
.expect("opt");
if let Some(x_best) = &result.x_best {
assert!(
x_best[0] <= 1.0 + 1e-6,
"best feasible x={:.4} should be <= 1.0",
x_best[0]
);
}
}
#[test]
fn test_probability_of_feasibility_bounds() {
let gp = GpSurrogate::new(
Box::new(RbfKernel::default()),
GpSurrogateConfig {
noise_variance: 1e-4,
optimize_hyperparams: false,
..Default::default()
},
);
let x = scirs2_core::ndarray::array![1.5];
let pof = ProbabilityOfFeasibility::evaluate(&x.view(), &gp).expect("pof");
assert!(
pof >= 0.0 && pof <= 1.0,
"PoF should be in [0,1], got {}",
pof
);
}
#[test]
fn test_multiple_constraints() {
let c1 = BlackBoxConstraint {
name: "lower".into(),
evaluate: Box::new(|x: &[f64]| 0.5 - x[0]),
};
let c2 = BlackBoxConstraint {
name: "upper".into(),
evaluate: Box::new(|x: &[f64]| x[0] - 2.5),
};
let mut cbo = ConstrainedBo::new(
vec![(0.0_f64, 4.0_f64)],
vec![c1, c2],
ConstrainedBoConfig {
n_initial: 4,
seed: Some(7),
..Default::default()
},
)
.expect("create");
let result = cbo
.optimize(|x: &[f64]| (x[0] - 1.5_f64).powi(2), 12)
.expect("opt");
assert!(
result.n_feasible > 0 || result.f_best.is_infinite(),
"expect some feasible points"
);
}
#[test]
fn test_pof_strategy() {
let con = make_constraint(2.0);
let mut cbo = ConstrainedBo::new(
vec![(0.0_f64, 4.0_f64)],
vec![con],
ConstrainedBoConfig {
strategy: ConstrainedAcquisitionStrategy::ProbabilityOfFeasibility,
n_initial: 3,
seed: Some(5),
..Default::default()
},
)
.expect("create");
let result = cbo
.optimize(|x: &[f64]| (x[0] - 1.0_f64).powi(2), 8)
.expect("opt");
assert_eq!(result.n_evals, 8);
}
#[test]
fn test_augmented_lagrangian_strategy() {
let con = make_constraint(1.5);
let mut cbo = ConstrainedBo::new(
vec![(0.0_f64, 4.0_f64)],
vec![con],
ConstrainedBoConfig {
strategy: ConstrainedAcquisitionStrategy::AugmentedLagrangian { penalty: 1.0 },
n_initial: 3,
seed: Some(3),
..Default::default()
},
)
.expect("create");
let result = cbo
.optimize(|x: &[f64]| (x[0] - 1.0_f64).powi(2), 8)
.expect("opt");
assert_eq!(result.n_evals, 8);
}
#[test]
fn test_constrained_optimize_fn() {
let con = BlackBoxConstraint {
name: "feasible".into(),
evaluate: Box::new(|x: &[f64]| x[0] - 3.0),
};
let result = constrained_optimize(
|x: &[f64]| (x[0] - 2.0_f64).powi(2),
vec![con],
vec![(0.0_f64, 4.0_f64)],
10,
Some(42),
)
.expect("opt");
assert!(result.n_evals > 0);
}
#[test]
fn test_expected_feasible_improvement() {
use scirs2_core::ndarray::{array, Array2};
let x = Array2::from_shape_vec((4, 1), vec![0.0, 1.0, 2.0, 3.0]).expect("shape");
let y = array![4.0, 1.0, 0.0, 1.0];
let mut gp = GpSurrogate::new(
Box::new(RbfKernel::default()),
GpSurrogateConfig {
noise_variance: 1e-4,
optimize_hyperparams: false,
..Default::default()
},
);
gp.fit(&x, &y).expect("fit");
let efi = ExpectedFeasibleImprovement::new(0.0, 0.01);
let xq = array![1.5];
let val = efi.evaluate(&xq.view(), &gp, &[]).expect("eval");
assert!(val.is_finite(), "EFI should be finite, got {}", val);
assert!(val >= 0.0, "EFI should be non-negative, got {}", val);
}
}