#![allow(clippy::useless_conversion)]
use crate::domain::*;
use crate::gp_config::*;
use crate::gp_mix::Gpx;
use crate::logging::init_logger;
use crate::qei_config::*;
use crate::trego_config::{TregoConfig, TregoConfigSpec};
use crate::types::*;
use egobox_ego::{CoegoStatus, InfillObjData, Result, find_best_result_index};
use egobox_gp::ThetaTuning;
use egobox_moe::NbClusters;
use ndarray::{Array1, Array2, ArrayView2, Axis, array, concatenate};
use numpy::{IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray2, ToPyArray};
use pyo3::exceptions::{PyTypeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::PyBool;
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use std::cmp::Ordering;
fn parse_trego_config(py: Python, value: Py<PyAny>) -> PyResult<TregoConfigSpec> {
if let Ok(spec) = value.extract(py) {
return Ok(spec);
}
let dict = value.bind(py).cast::<pyo3::types::PyDict>()?;
let mut cfg = TregoConfig::default();
for key_any in dict.keys().iter() {
let key = key_any.extract::<String>()?;
match key.as_str() {
"n_gl_steps" => cfg.n_gl_steps = dict.get_item("n_gl_steps")?.unwrap().extract()?,
"d" => cfg.d = dict.get_item("d")?.unwrap().extract()?,
"alpha" => cfg.alpha = dict.get_item("alpha")?.unwrap().extract()?,
"beta" => cfg.beta = dict.get_item("beta")?.unwrap().extract()?,
"sigma0" => cfg.sigma0 = dict.get_item("sigma0")?.unwrap().extract()?,
_ => return Err(PyValueError::new_err(format!("unknown trego key '{key}'"))),
}
}
Ok(TregoConfigSpec::Custom(cfg))
}
fn parse_run_info(py: Python, value: Py<PyAny>) -> PyResult<RunInfo> {
if let Ok(info) = value.extract(py) {
return Ok(info);
}
let dict = value.bind(py).cast::<pyo3::types::PyDict>()?;
let mut info = RunInfo::new("fobj".to_string(), 1);
for key_any in dict.keys().iter() {
let key = key_any.extract::<String>()?;
match key.as_str() {
"fname" => info.fname = dict.get_item("fname")?.unwrap().extract()?,
"num" => info.num = dict.get_item("num")?.unwrap().extract()?,
_ => {
return Err(PyValueError::new_err(format!(
"unknown run_info key '{key}'"
)));
}
}
}
Ok(info)
}
#[gen_stub_pyclass]
#[pyclass(skip_from_py_object)]
pub(crate) struct Egor {
pub xtypes: Vec<egobox_moe::XType>,
pub gp_config: GpConfig,
pub n_cstr: usize,
pub cstr_tol: Option<Vec<f64>>,
pub cstr_specs: Option<Vec<egobox_ego::CstrSpec>>,
pub n_start: usize,
pub n_doe: usize,
pub doe: Option<Array2<f64>>,
pub infill_strategy: InfillStrategy,
pub feasible_infill_strategy: FeasibleInfillStrategy,
pub cstr_infill: bool,
pub cstr_strategy: ConstraintStrategy,
pub qei_config: QEiConfig,
pub infill_optimizer: InfillOptimizer,
pub trego: Option<TregoConfig>,
pub coego_n_coop: usize,
pub target: f64,
pub failsafe_strategy: FailsafeStrategy,
pub seed: Option<u64>,
pub outdir: Option<String>,
pub warm_start: bool,
pub hot_start: Option<u64>,
}
#[gen_stub_pymethods]
#[pymethods]
impl Egor {
#[new]
#[pyo3(signature = (
xspecs,
gp_config = GpConfig::default(),
n_cstr = 0,
cstr_tol = None,
cstr_specs = None,
n_start = 20,
n_doe = 0,
doe = None,
infill_strategy = InfillStrategy::LogEi,
feasible_infill_strategy = FeasibleInfillStrategy::None,
cstr_infill = false,
cstr_strategy = ConstraintStrategy::Mc,
qei_config = QEiConfig::default(),
infill_optimizer = InfillOptimizer::Cobyla,
trego = None,
coego_n_coop = 0,
target = f64::MIN,
failsafe_strategy = FailsafeStrategy::Rejection,
seed = None,
outdir = None,
warm_start = false,
hot_start = None,
verbose = None
))]
#[allow(clippy::too_many_arguments)]
fn new(
py: Python,
xspecs: Py<PyAny>,
gp_config: GpConfig,
n_cstr: usize,
cstr_tol: Option<Vec<f64>>,
cstr_specs: Option<Vec<CstrSpec>>,
n_start: usize,
n_doe: usize,
doe: Option<PyReadonlyArray2<f64>>,
infill_strategy: InfillStrategy,
feasible_infill_strategy: FeasibleInfillStrategy,
cstr_infill: bool,
cstr_strategy: ConstraintStrategy,
qei_config: QEiConfig,
infill_optimizer: InfillOptimizer,
trego: Option<Py<PyAny>>,
coego_n_coop: usize,
target: f64,
failsafe_strategy: FailsafeStrategy,
seed: Option<u64>,
outdir: Option<String>,
warm_start: bool,
hot_start: Option<Py<PyAny>>,
verbose: Option<Py<PyAny>>,
) -> Self {
let warn = |msg: &str| {
let warnings = py.import("warnings").unwrap();
let depr = py
.import("builtins")
.unwrap()
.getattr("DeprecationWarning")
.unwrap();
warnings.call_method1("warn", (msg, depr)).ok();
};
if seed.is_some() {
warn(
"Passing 'seed' to Egor() is deprecated. Use 'seed' argument of minimize() or suggest() instead.",
);
}
if outdir.is_some() {
warn(
"Passing 'outdir' to Egor() is deprecated. Use 'outdir' argument of minimize() instead.",
);
}
if warm_start {
warn(
"Passing 'warm_start' to Egor() is deprecated. Use 'warm_start' argument of minimize() instead.",
);
}
if hot_start.is_some() {
warn(
"Passing 'hot_start' to Egor() is deprecated. Use 'hot_start' argument of minimize() instead.",
);
}
if verbose.is_some() {
init_logger(py, verbose);
warn(
"Passing 'verbose' to Egor() is deprecated. Use 'verbose' argument of minimize() instead.",
);
}
let hot_start = normalize_hot_start(py, hot_start).expect("Bad hot_start value");
let doe = doe.map(|x| x.to_owned_array());
let xtypes = parse(py, xspecs.clone_ref(py));
let trego = match trego {
Some(trego_py) => {
let trego_typ = parse_trego_config(py, trego_py).expect("Bad TREGO configuration");
match trego_typ {
TregoConfigSpec::Activated(active) => {
if active {
Some(TregoConfig::default())
} else {
None
}
}
TregoConfigSpec::Custom(cfg) => Some(cfg.into()),
}
}
None => None,
};
log::info!("TREGO config: {:?}", trego);
Egor {
xtypes,
gp_config,
n_cstr,
cstr_tol,
cstr_specs: cstr_specs.map(|specs| specs.into_iter().map(|s| s.inner).collect()),
n_start,
n_doe,
doe,
infill_strategy,
cstr_infill,
cstr_strategy,
feasible_infill_strategy,
qei_config,
infill_optimizer,
trego,
coego_n_coop,
target,
failsafe_strategy,
seed,
outdir,
warm_start,
hot_start,
}
}
#[pyo3(signature = (fun, fcstrs=vec![], fcstr_specs=vec![], max_iters = 20, run_info = None, outdir = None, warm_start = false, hot_start = None, seed = None, timeout = None, verbose = None, stop_on_error = false))]
#[allow(clippy::too_many_arguments)]
fn minimize(
&self,
py: Python,
fun: Py<PyAny>,
fcstrs: Vec<Py<PyAny>>,
fcstr_specs: Vec<CstrSpec>,
max_iters: usize,
run_info: Option<Py<PyAny>>,
outdir: Option<String>,
warm_start: bool,
hot_start: Option<Py<PyAny>>,
seed: Option<u64>,
timeout: Option<f64>,
verbose: Option<Py<PyAny>>,
stop_on_error: bool,
) -> PyResult<EgorOptim> {
init_logger(py, verbose);
let seed = seed.or(self.seed);
let outdir = outdir.or_else(|| self.outdir.clone());
let warm_start = if warm_start { true } else { self.warm_start };
let hot_start = match hot_start {
Some(hot_start) => normalize_hot_start(py, Some(hot_start))?,
None => self.hot_start,
};
let obj = |x: &ArrayView2<f64>| -> Result<Array2<f64>> {
Python::attach(|py| {
let args = (x.to_owned().into_pyarray(py),);
let res = fun.bind(py).call1(args);
match res {
Ok(res) => {
let pyarray = res.cast_into::<PyArray2<f64>>().unwrap();
Ok(pyarray.to_owned_array())
}
Err(e) => {
log::error!("Error during objective function evaluation: {:?}", e);
Err(egobox_ego::EgoError::ObjectiveFunctionError(e.to_string()))
}
}
})
};
let n_fcstr = fcstrs.len();
if !fcstr_specs.is_empty() && fcstr_specs.len() != n_fcstr {
return Err(PyValueError::new_err(format!(
"fcstr_specs length ({}) must match fcstrs length ({})",
fcstr_specs.len(),
n_fcstr
)));
}
let fcstr_specs = fcstr_specs
.into_iter()
.map(|spec| spec.inner)
.collect::<Vec<_>>();
let fcstrs = fcstrs
.iter()
.map(|cstr| {
|x: &[f64], g: Option<&mut [f64]>, _u: &mut InfillObjData<f64>| -> f64 {
Python::attach(|py| {
if let Some(g) = g {
let args = (Array1::from(x.to_vec()).into_pyarray(py), true);
let grad = cstr.bind(py).call1(args).unwrap();
let grad = grad.cast_into::<PyArray1<f64>>().unwrap().readonly();
g.copy_from_slice(grad.as_slice().unwrap())
}
let args = (Array1::from(x.to_vec()).into_pyarray(py), false);
cstr.bind(py).call1(args).unwrap().extract().unwrap()
})
}
})
.collect::<Vec<_>>();
let factory = egobox_ego::EgorFactory::optimize(obj);
let factory = if fcstr_specs.is_empty() {
factory.subject_to(fcstrs)
} else {
factory.subject_to_with_specs(fcstrs, fcstr_specs)
};
let mixintegor = factory
.configure(|config| {
self.apply_config(
config,
Some(max_iters),
n_fcstr,
self.doe.as_ref(),
outdir.as_deref(),
warm_start,
hot_start,
seed,
timeout,
stop_on_error,
)
})
.min_within_mixint_space(&self.xtypes)
.expect("Egor configured");
let py_run_info = if let Some(ri) = run_info {
parse_run_info(py, ri)?
} else {
RunInfo {
fname: "objective_function".to_string(),
num: 1,
}
};
let mixintegor = mixintegor.run_info(egobox_ego::RunInfo {
fname: py_run_info.fname.clone(),
num: py_run_info.num,
});
let res = py.detach(|| {
mixintegor
.run()
.expect("Egor should optimize the objective function")
});
let status = RunStatus {
info: py_run_info,
exit: (res.state.termination_status).into(),
init_doe_size: res.state.doe.doe_size,
best_iter: res.state.last_best_iter as usize,
total_iters: res.state.iter as usize,
elapsed_time: res
.state
.time
.map(|d| d.as_millis() as f64 / 1000.0)
.unwrap_or(0.0),
};
let x_opt = res.x_opt.into_pyarray(py).to_owned();
let y_opt = res.y_opt.into_pyarray(py).to_owned();
let x_doe = res.x_doe.into_pyarray(py).to_owned();
let y_doe = res.y_doe.into_pyarray(py).to_owned();
let result: Py<OptimResult> = Bound::new(
py,
OptimResult {
x_opt: x_opt.into(),
y_opt: y_opt.into(),
x_doe: x_doe.into(),
y_doe: y_doe.into(),
},
)?
.into();
Ok(EgorOptim { result, status })
}
#[pyo3(signature = (x_doe, y_doe, seed = None))]
fn suggest(
&self,
py: Python,
x_doe: PyReadonlyArray2<f64>,
y_doe: PyReadonlyArray2<f64>,
seed: Option<u64>,
) -> Py<PyArray2<f64>> {
let seed = seed.or(self.seed);
let x_doe = x_doe.as_array();
let y_doe = y_doe.as_array();
let doe = concatenate(Axis(1), &[x_doe.view(), y_doe.view()]).unwrap();
let mixintegor = egobox_ego::EgorServiceBuilder::optimize()
.configure(|config| {
self.apply_config(
config,
Some(1),
0,
Some(&doe),
None,
false,
None,
seed,
None,
false,
)
})
.min_within_mixint_space(&self.xtypes)
.expect("Egor configured");
let x_suggested = py.detach(|| mixintegor.suggest(&x_doe, &y_doe));
x_suggested.to_pyarray(py).into()
}
#[pyo3(signature = (y_doe))]
fn get_result_index(&self, y_doe: PyReadonlyArray2<f64>) -> usize {
let y_doe = y_doe.as_array();
let n_fcstrs = 0;
let c_doe = Array2::zeros((y_doe.nrows(), n_fcstrs));
find_best_result_index(&y_doe, &c_doe, &self.cstr_tol(n_fcstrs))
}
#[pyo3(signature = (x_doe, y_doe))]
fn get_result(
&self,
py: Python,
x_doe: PyReadonlyArray2<f64>,
y_doe: PyReadonlyArray2<f64>,
) -> OptimResult {
let x_doe = x_doe.as_array();
let y_doe = y_doe.as_array();
let n_fcstrs = 0;
let c_doe = Array2::zeros((y_doe.nrows(), n_fcstrs));
let idx = find_best_result_index(&y_doe, &c_doe, &self.cstr_tol(n_fcstrs));
let x_opt = x_doe.row(idx).to_pyarray(py).into();
let y_opt = y_doe.row(idx).to_pyarray(py).into();
let x_doe = x_doe.to_pyarray(py).into();
let y_doe = y_doe.to_pyarray(py).into();
OptimResult {
x_opt,
y_opt,
x_doe,
y_doe,
}
}
#[pyo3(signature = (file))]
fn load_gp_models(&self, file: String) -> Vec<Gpx> {
let msg = format!(
"Failed to load GP models from file {}. Make sure the file exists and is a valid GP models file.",
file
);
let gp_models = egobox_ego::load_gp_models(file.clone()).expect(&msg);
gp_models.into_iter().map(Gpx::from).collect()
}
}
impl Egor {
fn n_clusters(&self) -> NbClusters {
match self.gp_config.n_clusters.cmp(&0) {
Ordering::Greater => NbClusters::fixed(self.gp_config.n_clusters as usize),
Ordering::Equal => NbClusters::auto(),
Ordering::Less => NbClusters::automax(-self.gp_config.n_clusters as usize),
}
}
fn infill_strategy(&self) -> egobox_ego::InfillStrategy {
match self.infill_strategy {
InfillStrategy::Ei => egobox_ego::InfillStrategy::EI,
InfillStrategy::Wb2 => egobox_ego::InfillStrategy::WB2,
InfillStrategy::Wb2s => egobox_ego::InfillStrategy::WB2S,
InfillStrategy::LogEi => egobox_ego::InfillStrategy::LogEI,
}
}
fn feasible_infill_strategy(&self) -> egobox_ego::FeasibleInfillStrategy {
match self.feasible_infill_strategy {
FeasibleInfillStrategy::None => egobox_ego::FeasibleInfillStrategy::None,
FeasibleInfillStrategy::EfiP => egobox_ego::FeasibleInfillStrategy::EfiP,
FeasibleInfillStrategy::EfiFe => egobox_ego::FeasibleInfillStrategy::EfiFe(0.3),
}
}
fn cstr_strategy(&self) -> egobox_ego::ConstraintStrategy {
match self.cstr_strategy {
ConstraintStrategy::Mc => egobox_ego::ConstraintStrategy::MeanConstraint,
ConstraintStrategy::Utb => egobox_ego::ConstraintStrategy::UpperTrustBound,
}
}
fn qei_strategy(&self) -> egobox_ego::QEiStrategy {
match self.qei_config.strategy {
QEiStrategy::Kb => egobox_ego::QEiStrategy::KrigingBeliever,
QEiStrategy::Kblb => egobox_ego::QEiStrategy::KrigingBelieverLowerBound,
QEiStrategy::Kbub => egobox_ego::QEiStrategy::KrigingBelieverUpperBound,
QEiStrategy::Clmin => egobox_ego::QEiStrategy::ConstantLiarMinimum,
}
}
fn infill_optimizer(&self) -> egobox_ego::InfillOptimizer {
match self.infill_optimizer {
InfillOptimizer::Cobyla => egobox_ego::InfillOptimizer::Cobyla,
InfillOptimizer::Slsqp => egobox_ego::InfillOptimizer::Slsqp,
}
}
fn failsafe_strategy(&self) -> egobox_ego::FailsafeStrategy {
match self.failsafe_strategy {
FailsafeStrategy::Rejection => egobox_ego::FailsafeStrategy::Rejection,
FailsafeStrategy::Imputation => egobox_ego::FailsafeStrategy::Imputation,
FailsafeStrategy::Viability => egobox_ego::FailsafeStrategy::Viability,
}
}
fn cstr_tol(&self, n_fcstr: usize) -> Array1<f64> {
let cstr_tol = self
.cstr_tol
.clone()
.unwrap_or(vec![egobox_ego::DEFAULT_CSTR_TOL; self.n_cstr + n_fcstr]);
Array1::from_vec(cstr_tol)
}
fn recombination(&self) -> egobox_moe::Recombination<f64> {
match self.gp_config.recombination {
Recombination::Hard => egobox_moe::Recombination::Hard,
Recombination::Smooth => egobox_moe::Recombination::Smooth(Some(1.0)),
}
}
fn theta_tuning(&self) -> ThetaTuning<f64> {
let mut theta_tuning = ThetaTuning::<f64>::default();
if let Some(init) = self.gp_config.theta_init.as_ref() {
theta_tuning = ThetaTuning::Full {
init: Array1::from_vec(init.to_vec()),
bounds: array![ThetaTuning::<f64>::DEFAULT_BOUNDS],
}
}
if let Some(bounds) = self.gp_config.theta_bounds.as_ref() {
theta_tuning = ThetaTuning::Full {
init: theta_tuning.init().to_owned(),
bounds: bounds.iter().map(|v| (v[0], v[1])).collect(),
}
}
theta_tuning
}
#[allow(clippy::too_many_arguments)]
fn apply_config(
&self,
config: egobox_ego::EgorConfig,
max_iters: Option<usize>,
n_fcstr: usize,
doe: Option<&Array2<f64>>,
outdir: Option<&str>,
warm_start: bool,
hot_start: Option<u64>,
seed: Option<u64>,
timeout: Option<f64>,
stop_on_error: bool,
) -> egobox_ego::EgorConfig {
let infill_strategy = self.infill_strategy();
let feasible_infill_strategy = self.feasible_infill_strategy();
let cstr_strategy = self.cstr_strategy();
let qei_strategy = self.qei_strategy();
let infill_optimizer = self.infill_optimizer();
let failsafe_strategy = self.failsafe_strategy();
let coego_status = if self.coego_n_coop == 0 {
CoegoStatus::Disabled
} else {
CoegoStatus::Enabled(self.coego_n_coop)
};
let mut config = config
.n_cstr(self.n_cstr)
.max_iters(max_iters.unwrap_or(1))
.n_start(self.n_start)
.n_doe(self.n_doe);
if self.cstr_tol.is_some() {
let cstr_tol = self.cstr_tol(n_fcstr);
config = config.cstr_tol(cstr_tol);
}
if let Some(ref cstr_specs) = self.cstr_specs {
config = config.cstr_specs(cstr_specs.clone());
}
let mut config = config
.configure_gp(|gp| {
let regr = RegressionSpec(self.gp_config.regr_spec);
let corr = CorrelationSpec(self.gp_config.corr_spec);
gp.regression_spec(egobox_moe::RegressionSpec::from_bits(regr.0).unwrap())
.correlation_spec(egobox_moe::CorrelationSpec::from_bits(corr.0).unwrap())
.kpls_dim(self.gp_config.kpls_dim)
.n_clusters(self.n_clusters())
.recombination(self.recombination())
.theta_tuning(self.theta_tuning())
.n_start(self.gp_config.n_start)
.max_eval(self.gp_config.max_eval)
})
.infill_strategy(infill_strategy)
.feasible_infill_strategy(feasible_infill_strategy)
.cstr_infill(self.cstr_infill)
.cstr_strategy(cstr_strategy)
.configure_qei(|qei_config| {
qei_config
.batch(self.qei_config.batch)
.strategy(qei_strategy)
.optmod(self.qei_config.optmod)
})
.infill_optimizer(infill_optimizer)
.coego(coego_status)
.target(self.target)
.stop_on_error(stop_on_error)
.warm_start(warm_start)
.hot_start(hot_start.into())
.failsafe_strategy(failsafe_strategy);
if let Some(timeout) = timeout {
config = config.timeout(timeout);
}
if let Some(trego) = self.trego.as_ref() {
let strategy: egobox_ego::TregoStrategy = trego.clone().into();
config = config.iteration_strategy(Box::new(strategy))
}
if let Some(doe) = doe {
config = config.doe(doe);
};
if let Some(outdir) = outdir {
config = config.outdir(outdir.to_owned());
};
if let Some(seed) = seed {
config = config.seed(seed);
};
config
}
}
fn normalize_hot_start(py: Python, hot_start: Option<Py<PyAny>>) -> PyResult<Option<u64>> {
match hot_start {
Some(hot_start) => {
let hot_start = hot_start.bind(py);
if hot_start.is_none() {
Ok(None)
} else if hot_start.is_instance_of::<PyBool>() {
Ok(hot_start.extract::<bool>()?.then_some(0))
} else if let Ok(ext_iters) = hot_start.extract::<u64>() {
Ok(Some(ext_iters))
} else {
Err(PyTypeError::new_err(
"hot_start must be a bool, a non-negative integer, or None",
))
}
}
None => Ok(None),
}
}