Source code for smt_optim.core.driver

import numpy as np
from dataclasses import dataclass
import warnings
import time

import os

from typing import Callable


from smt_optim.core import Problem, State
from smt_optim.surrogate_models import Surrogate
from smt_optim.acquisition_strategies import AcquisitionStrategy

from smt_optim.core import Evaluator

from smt_optim.utils.initial_design import generate_initial_design
from smt_optim.utils.stop_criteria import check_stop_criteria
from smt_optim.utils.logger import ConsoleLogger, JsonLogger


[docs] def wrap_func(func: Callable, factor: float = 1, step: float = 0) -> Callable: """ Wrap function to return factor * (func - step). :param func: Function to wrap. :type func: Callable :param factor: Multiplicative factor. :type factor: float :param step: Additive factor. :type step: float :return: Wrapped function. :rtype: Callable """ def wrapped(x, f=func): return factor * (f(x) - step) return wrapped
[docs] def wrap_array( array: np.ndarray, factor: float | np.ndarray = 1.0, step: float | np.ndarray = 0.0 ) -> np.ndarray: return factor * (array - step)
[docs] def check_bounds(x: np.ndarray, bounds: np.ndarray) -> np.ndarray: """ Apply L1 correction to x point to make sure it's within the problem's bounds. :param x: Infill point. :type x: np.ndarray :param bounds: Problem boundaries. :type bounds: np.ndarray :return: The bounds corrected infill point. :rtype: np.ndarray """ x_corrected = np.where(x < bounds[:, 0], bounds[:, 0], x) x_corrected = np.where(x_corrected > bounds[:, 1], bounds[:, 1], x_corrected) if np.any(x != x_corrected): warnings.warn( f"Infill point was outside of the bounds. L1 correction was applied: (initial = {x}; corrected = {x_corrected})." ) return x_corrected
# def compute_rscv(self, cstr_array: np.ndarray, cstr_config: list[ConstraintConfig], g_tol: float = 0., h_tol: float = 0.) -> np.ndarray:
[docs] def compute_rscv( cstr_array: np.ndarray, cstr_config: list, g_tol: float = 0.0, h_tol: float = 0.0 ) -> np.ndarray: scv = np.full_like(cstr_array, 0.0) # Square Constraint Violation for c_id, c_config in enumerate(cstr_config): if c_config.type in ["less", "greater"]: valid_mask = cstr_array[:, c_id] <= g_tol scv[~valid_mask, c_id] = cstr_array[~valid_mask, c_id] ** 2 elif c_config.type == "equal": valid_mask = np.abs(cstr_array[:, c_id]) <= h_tol scv[~valid_mask, c_id] = cstr_array[~valid_mask, c_id] ** 2 else: raise Exception( f"{c_config.type} is not a valid constraint type. It must be 'less', 'greater' or 'equal'." ) rscv = np.sqrt(scv.sum(axis=1)) return rscv
[docs] @dataclass class ObjectiveConfig: """ Configuration of the objective function used in the optimization problem. This class stores an objective callable(s) together with surrogate modeling information used to approximate the objective during optimization. Attributes ---------- objective : list[Callable] List of an objective functions. Each callable must accept a decision variable vector ``x`` and return a scalar objective value. The functions must be ordered in increasing level of fidelity. type : {"minimize", "maximize"}, default="minimize" Specifies whether the objective should be minimized or maximized. surrogate : Surrogate or None, default=None Surrogate model used to approximate the objective function. surrogate_kwargs : dict or None, default=None Optional keyword arguments passed to the surrogate model. """ objective: list[Callable] surrogate: type[Surrogate] type: str = "minimize" # problem's type -> "minimize" or "maximize" surrogate_kwargs: dict | None = None
[docs] @dataclass class ConstraintConfig: """ Configuration of a constraint function used in the optimization problem. This class stores a constraint callable(s) together with surrogate modeling information used to approximate the constraint during optimization. Attributes ---------- constraint : list[Callable] List of constraint functions. Each callable must accept a decision variable vector ``x`` and return a scalar constraint value. The functions must be ordered in increasing level of fidelity. lower: float | None Lower bound of the constraint. If not specified, the constraint is considered unconstrained in this direction. upper: float | None Upper bound of the constraint. If not specified, the constraint is considered unconstrained in this direction. equal: float | None Equality constraint. surrogate : Surrogate or None, default=None Surrogate model used to approximate the constraint function. surrogate_kwargs : dict or None, default=None Optional keyword arguments passed to the surrogate model. Notes ----- A constraint must either be an inequality constraint or an equality constraint. For inequality constraints, it is possible to define a lower and upper bound. """ constraint: list[Callable] lower: float = None upper: float = None equal: float = None surrogate: type[Surrogate] = None surrogate_kwargs: dict | None = None
[docs] @dataclass class DriverConfig: """ Optimization driver configuration Attributes ---------- max_iter: int Maximum number of iteration max_budget: float, default=inf Maximum budget before termination of the optimization process max_time: float, default=inf Maximum time before termination of the optimization process nt_init: int Number of samples in the initial DoE xt_init: list[np.ndarray] Initial DoE to use. The Numpy array must be of shape (num_sample, num_dimension). By providing an initial DoE, the driver will not generate an initial DoE. Cannot be used with `nt_init` results_dir: str or None, default=None Name of the logging directory verbose: bool, default=False Print optimization information. log_doe: bool, default=False Log the value of the function values as soon as they are sampled. The values are stored in a .csv file. log_stats: bool, default=False Log optimization statistics at the end of each iteration. The stats are store in a .jsonl file. scaling: bool, default=True Scale the data. The objective is standardized. The constraints are divided by their standard deviation. The design variables are normalized between 0 and 1. seed: default=None Seed for experiment reproducibility """ ctol: float = 1e-4 # tolerance for all constraints max_iter: int | None = None # max number of BO iterations max_budget: float = float("inf") # max BO budget max_time: float = float("inf") # max BO elapsed time nt_init: int | None = None # number of samples in initial DOE (with LHS) xt_init: np.ndarray | None = ( None # initial training data [np.ndarray(nt, dim), np.ndarray(nt, dim)] ) results_dir: str | None = "bo_result" # name for the results directory verbose: bool = False # True/False print each iteration informations log_doe: bool = False log_stats: bool = False callback_func: list[Callable] | Callable | None = ( None # additional method to call at the end of each iteration ) scaling: bool = True # standardize the training data seed: None = None
[docs] class Driver: def __init__( self, problem: Problem, config: DriverConfig, strategy: AcquisitionStrategy, strategy_kwargs: dict = {}, ): """ Initializes the object with the given parameters. Parameters ---------- problem : Problem The problem instance to be optimized. config : DriverConfig The configuration settings for the optimization process. strategy : AcquisitionStrategy The infill sampling strategy to use. strategy_kwargs : dict, optional Additional keyword arguments passed to the strategy constructor (default is an empty dictionary). Notes ----- This method sets up the object's internal state and initializes its components. """ self.problem = problem self.config = config self.state = State(problem) self.state.dataset.log_data = True self.strategy_kwargs = strategy_kwargs self.strategy_kwargs["seed"] = config.seed # = {"seed": config.seed if config.seed is not None else None} self.strategy = strategy(self.state, **self.strategy_kwargs) if self.config.log_doe or self.config.log_stats: self.config.results_dir = self.make_res_dir(self.config.results_dir) else: self.config.results_dir = None self.evaluator = Evaluator(problem, config.results_dir) # setup loggers self.loggers = [] if self.config.verbose: self.loggers.append(ConsoleLogger(self.config)) if self.config.log_stats: self.loggers.append(JsonLogger(self.config))
[docs] def iteration(self, state): """ Performs an optimization iteration on the given state. The iteration process involves the following steps: 1. Scaling the training data according to the specified scaling configuration. 2. Building surrogate models for approximating the expensive-to-evaluate functions. 3. Acquiring points to sample and their associated fidelity levels using the infill strategy. 4. Sampling the original, unmodified functions with the acquired points. Parameters ---------- state : State The optimization state on which to perform an iteration. Returns ------- State: The updated optimization state after completing the iteration. """ state.iter += 1 # scale data state.scale_dataset(self.config.scaling) # build models state.build_models() # get infill t0 = time.perf_counter() infill = self.strategy.get_infill(state) t1 = time.perf_counter() state.iter_log["acq_opt_time"] = t1 - t0 # safe descale, clip infill to the bounds, verify design space for i in range(len(infill)): if infill[i] is not None: infill[i] = safe_descale(infill[i], state) state.iter_log["fidelity"] = i + 1 infill_not_in_xt(infill, state) # evaluate infill points self.evaluator.sample_func(infill, state) # log iteration data self.call_loggers(state) return state
[docs] def optimize(self): """ Performs an optimization process on the current state. The process consists of two stages: 1. If the initial dataset is empty, it generates a Design of Experiment (DoE). 2. Iteratively performs optimization iterations until termination criteria are met. Returns ------- State: The updated optimization state after completing the optimization process. """ self.start_optim() # loop - check stop criteria while check_stop_criteria(self.state, self.config): # iteration self.iteration(self.state) return self.state
[docs] def start_optim(self): """ Initializes the optimization process by creating an initial Design of Experiment (DoE) if necessary. If the State dataset is empty (i.e., contains no samples), a new DoE will be generated. Otherwise, no action is taken to avoid modifying existing sampling points. Returns ------- None """ # generate initial design if len(self.state.dataset.samples) == 0: generate_initial_design(self.state, self.evaluator, self.config) self.call_loggers(self.state)
[docs] def call_loggers(self, state): # if self.loggers is not None: for logger in self.loggers: try: logger.on_iter_end(state) except Exception as e: print(f"Error while logging: {e}")
[docs] def make_res_dir(self, res_dir: str | None) -> str | None: """ Creates a unique results directory path based on the provided input. If a directory with the same name already exists, the method will append an incrementing index to it (e.g., "results" -> "results_1", "results_2", etc.) until a unique name is found. No directory will be created if `res_dir` is set to `None`. Parameters ---------- res_dir : Optional[str] The desired results directory path. If `None`, no directory will be created. Returns ------- str or None: The unique results directory path, or `None` if the input was `None`. """ og_res_dir = res_dir if res_dir is not None: # if results_dir already exists, append '_idx' to it to avoid overwriting existing data idx = 1 while os.path.exists(res_dir): res_dir = og_res_dir + f"_{idx}" idx += 1 # create results_dir directory os.makedirs(res_dir, exist_ok=False) return res_dir else: return None
[docs] def safe_descale(x_scaled: np.ndarray, state: State) -> np.ndarray: """ Descale point X and verify that it satisfies the design space. If necessary, correct it. Parameters ---------- x_scaled: np.ndarray state: State Returns ------- x_clip """ ds = state.problem.design_space ds_bounds = ds.get_num_bounds() # descale infill point # descaling only applies to cont. variables -> int. and cat. variables are not scaled x_raw = x_scaled * state.x_factor + state.x_step # rounds cat. and int. variables # only clips bounds for cat. variables x_corr, _ = ds.correct_get_acting(x_raw) # clips bounds for all variables x_clip = np.clip(x_corr, ds_bounds[:, 0], ds_bounds[:, 1]) if np.linalg.norm(x_clip - x_raw) > 1e-10: # TODO: add customizable tolerance warnings.warn("Infill point did not respect the design space.") return x_clip
[docs] def infill_not_in_xt(infills: list[np.ndarray], state: State) -> None: """ Raise exception if an infill point is already in the training data. Parameters ---------- infills: list[np.ndarray] state: State Returns ------- """ dataset = state.dataset.export_as_dict() fidelity = dataset["fidelity"].ravel() xt = dataset["x"] for lvl in range(len(infills)): if infills[lvl] is not None: fid_mask = fidelity == lvl xt_lvl = xt[fid_mask, :] for idx in range(infills[lvl].shape[0]): diff = xt_lvl - infills[lvl][idx, :] l2_norms = np.linalg.norm(diff, axis=1) if np.min(l2_norms) < 1e-8: # TODO: add customizable tolerance # raise Exception("Infill point already in training data.") warnings.warn( f"Infill point {infills[lvl][idx, :]} already in training data. L2 = {np.min(l2_norms)}" ) return None
# # ======= old ======= # class OptimizerOld(): # # def __init__(self, obj_config: ObjectiveConfig, config: OptimizerConfig, strategy: AcquisitionStrategy, strategy_params: dict | None = None): # # # initialize print setting # self.verbose = config.verbose # # self.opt_data = {} # # self.results_dir = config.results_dir # self.log_types = ["doe", "json"] # # # initialize objective function # self.obj_config = obj_config # self.obj_func = obj_config.objective # self.domain = obj_config.design_space # self.obj_type = obj_config.type # self.obj_models = obj_config.surrogate # self.costs = obj_config.costs # # # get constraint configurations # self.cstr_config = config.constraints # self.cstr_funcs = [] # self.ctol = config.ctol # # self.iter = 0 # # # get stopping criteria # self.max_iter = config.max_iter # self.max_budget = config.max_budget # self.max_time = config.max_time # # # get initial training data / setup # self.nt_init = config.nt_init # self.xt_init = config.xt_init # # # get misc configuration # self.callback_func = config.callback_func # self.scaling = config.scaling # self.dynamic_costs = config.dynamic_costs # # self._check_optimizer_config() # self._setup_logging() # # # # self.strategy = strategy # self.strategy_params = strategy_params # # self.num_dim = 0 # self.num_fidelity = 0 # self.num_obj = 0 # self.num_cstr = 0 # # self.yt_factor = None # self.ct_factor = None # self.ct_step = None # # self._check_objective() # self._check_constraints() # self._setup_stopping_criteria() # self._check_init_points() # # self.obj_models = [] # self.cstr_models = [] # self.g_models = [] # self.h_models = [] # # self._initialize_surrogates() # # self.dataset = OptimizationDataset() # self.scaled_dataset = None # self.data = [] # self.xt = [] # self.yt = [] # self.ct = [] # self.f_min = np.inf # self.rscv_min = np.inf # self.x_min = None # self.c_min = None # self.samples_time = [] # # self.xt_scaled = [] # self.yt_scaled = [] # self.ct_scaled = [] # self.f_min_scaled = np.inf # # # self._check_init_points() # # self._gen_init_train_data() # # self.update_f_min() # # self.acq_strategy = None # self._initialize_acq_strategy() # # self.opt_data = {} # self.iter_data = {} # # def _check_optimizer_config(self) -> None: # # if self.dynamic_costs is not None and self.dynamic_costs not in ["samples"]: # raise Exception(f"Dynamic costs '{self.dynamic_costs}' is not supported.") # # if callable(self.callback_func): # self.callback_func = [self.callback_func] # # # def _setup_logging(self): # # results_dir = self.results_dir # # if results_dir is not None: # # if results_dir already exists, append '_idx' to it to avoid overwriting existing data # idx = 1 # while os.path.exists(results_dir): # results_dir = self.results_dir + f"_{idx}" # idx += 1 # self.results_dir = results_dir # # # create results_dir directory # os.makedirs(self.results_dir) # # # create the DOE subdirectory (used to save the DOEs from each level) # doe_path = os.path.join(self.results_dir, "DOE/") # os.makedirs(doe_path, exist_ok=True) # # # def _check_objective(self) -> None: # # if callable(self.obj_func): # self.obj_func = [self.obj_func] # elif type(self.obj_func) is list: # pass # else: # raise Exception("ObjectiveConfig.objective must be of type list.") # # if self.obj_type == "minimize": # maximize = False # self.yt_factor = 1. # elif self.obj_type == "maximize": # maximize = True # self.yt_factor = -1. # else: # raise Exception("ObjectiveConfig.type must be 'minimize' or 'maximize'.") # # # self.obj_func = self._wrap_objectives(self.obj_func, maximize=maximize) # # self.num_dim = self.domain.shape[0] # self.num_obj += 1 # # self.num_fidelity = len(self.obj_func) # # self.obj_func = [self.obj_func] # # if len(self.costs) != self.num_fidelity: # raise Exception("ObjectiveConfig.costs must have the same number of levels as the objective.") # # # TODO: check costs are in ascending order # # def _check_constraints(self) -> None: # # if self.cstr_config is None: # self.cstr_config = [] # # self.num_cstr = len(self.cstr_config) # # if self.num_cstr > 0: # # self.ct_factor = np.empty(self.num_cstr) # self.ct_step = np.empty(self.num_cstr) # # for c_id, c_config in enumerate(self.cstr_config): # # # self.cstr_funcs.append([]) # # if c_config.type == "greater": # self.ct_factor[c_id] = -1. # else: # self.ct_factor[c_id] = 1. # # self.ct_step[c_id] = c_config.value # # if callable(c_config.constraint): # c_config.constraint = [c_config.constraint] # elif type(c_config.constraint) is list: # pass # else: # raise Exception("ConstraintConfig.constraint must be of type list.") # # if len(c_config.constraint) != self.num_fidelity: # raise Exception("ConstraintConfig.constraint must have the same number of levels as the objective.") # # self.cstr_funcs.append( c_config.constraint ) # # self._wrap_constraints(c_config.constraint, c_config.type, c_config.value) # # ) # # def _setup_stopping_criteria(self): # # if self.num_dim == 0: # raise Exception("Problem must have at least one dimension.") # # if self.max_iter is None: # warnings.warn("Max number of iterations not specified. Set to 100.") # self.max_iter = 10*self.num_dim # # if self.max_budget is None: # self.max_budget = np.inf # # if self.max_time is None: # self.max_time = np.inf # # def _check_init_points(self): # # if self.nt_init is not None and self.xt_init is not None: # raise Exception("Define nt_init or xt_init, but not both.") # # elif self.nt_init is None: # self.nt_init = 3*self.num_dim # # if self.xt_init is None: # sampler = stats.qmc.LatinHypercube(self.num_dim) # xt = sampler.random(self.nt_init) # xt = stats.qmc.scale(xt, self.domain[:, 0], self.domain[:, 1]) # self.xt_init = [xt for _ in range(self.num_fidelity)] # # def _initialize_surrogates(self): # # self.obj_models = [self.obj_config.surrogate(optimizer=self)] # # for c_id, c_config in enumerate(self.cstr_config): # self.cstr_models.append( # c_config.surrogate(optimizer=self) # ) # # if c_config.type in ["less", "greater"]: # self.g_models.append(self.cstr_models[c_id]) # elif c_config.type == "equal": # self.h_models.append(self.cstr_models[c_id]) # # def _gen_init_train_data(self): # # for lvl in range(self.num_fidelity): # for idx in range(self.xt_init[lvl].shape[0]): # self.sample_point(self.xt_init[lvl][idx, :], lvl) # # def _initialize_acq_strategy(self): # # acq_context = self.generate_acq_context() # # if type(self.strategy_params) is dict: # self.acq_strategy = self.strategy(acq_context, **self.strategy_params) # else: # self.acq_strategy = self.strategy(acq_context) # # self.acq_strategy.validate_config(acq_context) # # # def update_f_min(self): # # # feasible_mask = np.any(self.ct[-1] <= 1e-4, axis=1) # use cstr_tol in ConstraintConfig # # # self.f_min = np.min(np.where(feasible_mask == True, self.yt[-1], np.inf)) # # # print(f"f_min = {self.f_min}") # # # # previous_f_min = self.f_min # # # # #feas_mask = np.all(self.ct[-1] <= self.ctol, axis=1) # # valid_cstr = np.full((self.ct[-1].shape[0], self.num_cstr), False) # # # # for c_id, c_config in enumerate(self.cstr_config): # # if c_config.type in ["less", "greater"]: # # valid_cstr[:, c_id] = np.where(self.ct[-1][:, c_id] <= self.ctol, True, False) # # elif c_config.type == "equal": # # valid_cstr[:, c_id] = np.where(np.abs(self.ct[-1][:, c_id]) <= self.ctol, True, False) # # # # feas_mask = np.all(valid_cstr, axis=1) # # # # if np.any(feas_mask): # # local_min_id = self.yt[-1][feas_mask].argmin() # # # global_min_id = feas_mask[local_min_id] # # # # self.f_min = self.yt[-1][feas_mask][local_min_id].item() # # self.x_min = self.xt[-1][feas_mask][local_min_id] # # self.c_min = self.ct[-1][feas_mask][local_min_id] # # # # else: # # self.f_min = np.inf # # self.x_min = None # # self.c_min = None # # # # self._check_f_min_decreasing(self.f_min, previous_f_min) # # # # def update_rscv_min(self): # # rscv = compute_rscv(self.ct[-1], self.cstr_config, g_tol=0.0, h_tol=0.0) # # self.rscv_min = rscv.min() # # # # # # def _check_f_min_decreasing(self, next_f_min, previous_f_min): # # if previous_f_min < next_f_min: # # warnings.warn("f_min is increasing.") # # def sample_point(self, x_new: np.ndarray, level: int) -> None: # # x_new = check_bounds(x_new, self.domain) # # obj_values = np.empty(self.num_obj) # cstr_values = np.empty(self.num_cstr) # times = np.empty(self.num_obj+self.num_cstr) # # def sample_func(x_new: np.ndarray, func: Callable) -> tuple[float, float]: # t0 = time.perf_counter() # value = func(x_new) # t1 = time.perf_counter() # return value, t1-t0 # # for obj_idx in range(self.num_obj): # obj_values[obj_idx], times[obj_idx] = sample_func(x_new, self.obj_func[obj_idx][level]) # # for cstr_idx in range(self.num_cstr): # cstr_values[cstr_idx], times[self.num_obj+cstr_idx] = sample_func(x_new, self.cstr_funcs[cstr_idx][level]) # # sample = Sample( # x=x_new, # fidelity=level, # obj=obj_values, # cstr=cstr_values, # eval_time=times, # metadata={"iter": self.iter} # ) # # self.dataset.add(sample) # # if "doe" in self.log_types: # self.save_sample(sample) # # def _standardize_data(self, data: np.ndarray) -> tuple[np.ndarray | float]: # # mean = data.mean() # std = data.std() # std_data = (data - mean)/std # # return std_data, mean, std # # def scale_data(self): # # num_qoi = self.num_obj + self.num_cstr # qoi_factor = np.empty(num_qoi) # qoi_step = np.empty(num_qoi) # # for obj_idx in range(self.num_obj): # if self.obj_config.type == "minimize": # qoi_factor[obj_idx] = 1 # elif self.obj_config.type == "maximize": # qoi_factor[obj_idx] = -1 # # qoi_step[obj_idx] = 0 # # for cstr_idx in range(self.num_cstr): # c_config = self.cstr_config[cstr_idx] # # if c_config.type in ["less", "equal"]: # qoi_factor[self.num_obj+cstr_idx] = 1 # elif c_config.type in ["greater"]: # qoi_factor[self.num_obj+cstr_idx] = -1 # # qoi_step[self.num_obj+cstr_idx] = c_config.value # # self.qoi_factor = qoi_factor # self.qoi_step = qoi_step # # self.scaled_dataset = OptimizationDataset() # # for sample in self.dataset.samples: # # scaled_sample = copy.deepcopy(sample) # # # should only normalize real variables # scaled_sample.x -= self.domain[:, 0] # scaled_sample.x /= (self.domain[:, 1] - self.domain[:, 0]) # scaled_sample.obj[:] *= self.qoi_factor[:self.num_obj] # scaled_sample.cstr[:] *= self.qoi_factor[self.num_obj:self.num_obj+self.num_cstr] # # self.scaled_dataset.add(scaled_sample) # # # def build_models(self): # # def group_by_fidelity() -> tuple[list[np.ndarray], list[np.ndarray]]: # # x = [] # qoi = [] # # for lvl in range(self.num_fidelity): # # samples = self.scaled_dataset.get_by_fidelity(lvl) # # x_lvl = np.empty((len(samples), self.num_dim)) # qoi_lvl = np.empty((len(samples), self.num_obj + self.num_cstr)) # # for idx, sample in enumerate(samples): # x_lvl[idx, :] = sample.x # qoi_lvl[idx, :self.num_obj] = sample.obj # qoi_lvl[idx, self.num_obj:] = sample.cstr # # x.append(x_lvl) # qoi.append(qoi_lvl) # # return x, qoi # # x_train, qoi_train = group_by_fidelity() # # qoi_models = self.obj_models + self.cstr_models # # for qoi_idx in range(self.num_obj+self.num_cstr): # # idx_train = [] # # for lvl in range(self.num_fidelity): # idx_train.append(qoi_train[lvl][:, qoi_idx].reshape(-1, 1)) # # qoi_models[qoi_idx].train(x_train, idx_train) # # # def generate_acq_context(self): # # cstr_types = [] # for c_id, c_config in enumerate(self.cstr_config): # if c_config.type in ["less", "greater"]: # cstr_types.append("less") # elif c_config.type == "equal": # cstr_types.append("equal") # # acq_context = State( # num_dim=self.num_dim, # num_obj=1, # num_cstr=self.num_cstr, # num_fidelity=self.num_fidelity, # design_space=np.array([[0, 1]] * self.num_dim), # obj_models=self.obj_models, # cstr_models=self.cstr_models, # cstr_types=cstr_types, # dataset=self.scaled_dataset, # ) # # return acq_context # # def sample_infills(self, next_x: list[np.ndarray]) -> None: # # # Convert the single fidelity acquisition function output to a list as # # to make it compatible with the multi-fidelity approach # if type(next_x) is not list: # next_x = [next_x] # # if len(next_x) != self.num_fidelity: # warnings.warn("") # # # Sample each fidelity level sequentially # for k in range(self.num_fidelity): # # # if None -> the fidelity k is not to be sampled # if self.next_x[k] is None: # continue # # # unscale infill point # if self.scaling: # self.next_x[k] *= (self.domain[:, 1] - self.domain[:, 0]) # self.next_x[k] += self.domain[:, 0] # # # Check if the infill point is already in the training data # # TODO: what to do if the next infill location is already in the training data? # # if np.any(np.all(self.xt[k] == self.next_x[k], axis=1)): # # warnings.warn("Infill point is already in the training data.") # # continue # # # sample objective function and constraints # self.sample_point(self.next_x[k], k) # # # def perform_iteration(self): # # self.iter_data = dict() # reset iteration data dictionary # # # ------- Wrap training data ------- # # self._wrap_training_data() # # # ------- Scale training data ------- # # self.scale_training_data() # self.scale_data() # # # ------- Update cost ratio ------- # self.update_costs() # # # ------- Surrogate models training ------- # gp_t0 = time.perf_counter() # # self.build_models() # # gp_t1 = time.perf_counter() # gp_time = gp_t1 - gp_t0 # elapsed time for training the models # # # log gp training elapsed time # self.iter_data["gp_training_time"] = gp_time # # # # ------- Acquisition function optimization ------- # acq_context = self.generate_acq_context() # # acq_t0 = time.perf_counter() # # # Find enrichment location # self.next_x, acq_data = self.acq_strategy.get_infill(acq_context) # self.iter_data["acq_data"] = acq_data # # acq_t1 = time.perf_counter() # acq_time = acq_t1 - acq_t0 # elapsed time for finding the next acquisition point # # # log acquisition function maximization time # self.iter_data["acq_opt_time"] = acq_time # # # ------- Sample infill location ------- # self.sample_infills(self.next_x) # # if self.callback_func is not None: # for func in self.callback_func: # func(self) # # # def optimize(self): # # bo_start = time.perf_counter() # # # generate initial doe # self._gen_init_train_data() # # # self.scale_training_data() # self.scale_data() # # # update f_min # # self.update_f_min() # # self.update_rscv_min() # # iter_id = 0 # self.iter = iter_id # # # for l in range(self.num_fidelity): # # self.iter_data[f"n{l + 1}"] = len(self.yt[l]) # # # # self.update_costs() # # self.budget = self.compute_used_budget() # # self.iter_data["budget"] = self.compute_used_budget() # # self.iter_data["f_min"] = self.f_min # # self.iter_data["x_min"] = self.x_min # # self.iter_data["c_min"] = self.c_min # # self.opt_data[0] = self.iter_data # self.save_data() # # self.continue_bo = True # # # if self.verbose: print( # # f"| iter= {iter_id}/{self.max_iter} | budget={self.budget:.3f}/{self.max_budget:.3f} | f_min={self.f_min:.3e} | rscv_min={self.rscv_min:.3e} |" # # ) # # while self.continue_bo: # # iter_id += 1 # self.iter = iter_id # # self.perform_iteration() # # # # update f_min # # self.update_f_min() # # self.update_rscv_min() # # # for l in range(self.num_fidelity): # # self.iter_data[f"n{l + 1}"] = len(self.yt[l]) # # # # self.iter_data["f_min"] = self.f_min # # self.iter_data["x_min"] = self.x_min # # self.iter_data["c_min"] = self.c_min # # self.budget = self.compute_used_budget() # self.iter_data["budget"] = self.budget # # self.iter_data["costs"] = self.costs # # # elapsed time since optimization start # self.bo_time = time.perf_counter() - bo_start # self.iter_data["bo_time"] = self.bo_time # # self.continue_bo = self.check_stop_criteria() # self.iter_data["continue"] = self.continue_bo # # # add iteration data to the optimization data dictionary # self.opt_data[iter_id] = self.iter_data # # # self.dump_pikle_log() # self.save_data() # # # Display the iteration number, best feasible objective and fidelity level sampled # # if self.verbose : print( # # f"| iter= {iter_id}/{self.max_iter} | budget={self.budget:.3f}/{self.max_budget:.3f} | f_min={self.f_min:.3e} | rscv_min={self.rscv_min:.3e} | lvl={max_level}/{self.num_fidelity - 1} | gp_time={gp_time:.3f} | acq_time={acq_time:.3f}") # # if self.verbose : print( # f"| iter= {iter_id}/{self.max_iter} | budget={self.budget:.3f}/{self.max_budget:.3f}") # # # ------- End of optimization loop ------- # # return self.opt_data # # # def dump_pikle_log(self): # # try: # # # # path = os.path.join(self.results_dir, "opt_data.pkl") # # # # with open(path, 'wb') as file: # # pickle.dump(self.opt_data, file) # # # # except Exception as e: # # # TODO: use warnings.warn # # warnings.warn(f"Error while saving optimization data: {e}") # # # def dump_json_log(self): # try: # # path = os.path.join(self.results_dir, "opt_data.json") # # with open(path, 'w') as file: # safe_data = json_safe(self.opt_data) # json.dump(safe_data, file, indent=2) # # except Exception as e: # # TODO: use warnings.warn # warnings.warn(f"Error while saving optimization data: {e}") # # # def save_data(self): # # if "json" in self.log_types: # self.dump_json_log() # # # # # def save_sample(self, sample: Sample) -> None: # # try: # row = dict() # # row["iter"] = self.iter # row["budget"] = self.compute_used_budget() # self.budget # # # save variable # for i in range(self.num_dim): # row[f"x{i}"] = sample.x[i] # # # save objectives # for i in range(self.num_obj): # row[f"{i}"] = sample.obj[i] # # # save constraints # for i in range(self.num_cstr): # row[f"c{i}"] = sample.cstr[i] # # row["time"] = np.sum(sample.eval_time) # # path = os.path.join(self.results_dir, "DOE", f"doe_fidelity_{sample.fidelity}.csv") # file_exists = os.path.isfile(path) # # # possibly does not work on Windows -> to be tested # with open(path, 'a') as file: # writer = csv.DictWriter(file, fieldnames=row.keys()) # # if not file_exists: # writer.writeheader() # # writer.writerow(row) # # except Exception as e: # print(f"Error while saving the DoE: {e}") # # # def dump_csv_doe(self, level): # pass # # try: # row = dict() # # row["iter"] = self.iter # row["budget"] = self.budget # # x = self.xt[level][-1, :] # print(f"x = {x}") # for i in range(len(x)): # row[f"x{i}"] = x[i] # # row["f"] = self.data[level][-1, 0] # # for i in range(self.num_cstr): # row[f"c{i}"] = self.data[level][-1, i+1] # # path = f"DoE/{self.log_filename}_{level}.csv" # file_exists = os.path.isfile(path) # print(f"file exists = {file_exists}") # # with open(path, 'w') as file: # writer = csv.DictWriter(file, fieldnames=row.keys()) # # if not file_exists: # writer.writeheader() # # writer.writerow(row) # # except Exception as e: # print(f"Error while logging the DoE: {e}") # # def compute_used_budget(self): # # budget = 0 # # for k in range(self.num_fidelity): # samples = self.dataset.get_by_fidelity(k) # budget += self.costs[k] * len(samples) # # return budget # # def check_stop_criteria(self): # # if self.iter >= self.max_iter: # return False # elif self.budget >= self.max_budget: # return False # elif self.bo_time >= self.max_time: # return False # else: # return True # # def update_costs(self) -> None: # """ # Update the costs of each level. # - If set to None, the costs are never updated. # - If set to 'samples', the cost of each level corresponds to it's average time to be sampled. # # :return: None # """ # # if self.dynamic_costs == "samples": # for lvl in range(self.num_fidelity): # # average sampling time per level # self.costs[lvl] = self.samples_time[lvl].sum(axis=1).mean().item() # # # def scale_training_data(self): # # # # self.xt_scaled = [] # # self.yt_scaled = [] # # self.ct_scaled = [] # # # # for lvl in range(self.num_fidelity): # # # # self.xt_scaled.append(self.xt[lvl].copy()) # # # # # transform the objective into a minimization problem # # self.yt_scaled.append(wrap_array(self.yt[lvl], factor=self.yt_factor)) # # # # if self.num_cstr >= 1: # # # transform the constraints to define the feasible domain as: g <= 0 and h == 0 # # self.ct_scaled.append(wrap_array(self.ct[lvl], factor=self.ct_factor, step=self.ct_step)) # # # # if self.scaling: # # # scale xt between 0 and 1 # # self.xt_scaled[lvl][:] -= self.domain[:, 0] # # self.xt_scaled[lvl][:] /= (self.domain[:, 1] - self.domain[:, 0]) # # # # # update scaled domain boundaries # # self.domain_scaled = np.empty((self.num_dim, 2)) # # self.domain_scaled[:, 0] = 0. # # self.domain_scaled[:, 1] = 1. # # # # # # # scaled objective to unit std # # yt_scaled, yt_mean, yt_std = self._standardize_data(self.yt[lvl]) # # self.yt_scaled[lvl] = yt_scaled # # # # # update minimum objective # # self.f_min_scaled = (self.f_min - yt_mean) / yt_std # # # # # scaled constraints to unit std # # if self.num_cstr >= 1: # # for c_id in range(self.ct[lvl].shape[1]): # # self.ct_scaled[lvl][:, c_id] /= self.ct[lvl][:, c_id].std() # # else: # # self.domain_scaled = self.domain.copy() if __name__ == "__main__": pass