Source code for desdeo_problem.problem.Objective

"""Defines Objective classes to be used in Problems

"""

from abc import ABC, abstractmethod
from os import path
from typing import Callable, Dict, List, NamedTuple, Tuple, Union

import numpy as np
import pandas as pd
from desdeo_problem.surrogatemodels.SurrogateModels import BaseRegressor, ModelError


[docs]class ObjectiveError(Exception): """Raised when an error related to the Objective class is encountered."""
[docs]class ObjectiveEvaluationResults(NamedTuple): """The return object of <problem>.evaluate methods. Attributes: objectives (Union[float, np.ndarray]): The objective function value/s for the input vector. uncertainity (Union[None, float, np.ndarray]): The uncertainity in the objective value/s. """ objectives: Union[float, np.ndarray] uncertainity: Union[None, float, np.ndarray] = None def __str__(self): """Stringify the result. Returns: str: result in string form """ prnt_msg = ( "Objective Evaluation Results Object \n" f"Objective values are: \n{self.objectives}\n" f"Uncertainity values are: \n{self.uncertainity}\n" ) return prnt_msg
[docs]class ObjectiveBase(ABC): """The abstract base class for objectives."""
[docs] def evaluate(self, decision_vector: np.ndarray, use_surrogate: bool = False) -> ObjectiveEvaluationResults: """Evaluates the objective according to a decision variable vector. Uses surrogate model if use_surrogates is true. If use_surrogates is False, uses func_evaluate which evaluates using the true objective function. Arguments: decision_vector (np.ndarray): A vector of Variables to be used in the evaluation of the objective. use_surrogate (bool) : A boolean which determines whether to use surrogates or true function evaluator. False by default. """ if use_surrogate: return self._surrogate_evaluate(decision_vector) else: return self._func_evaluate(decision_vector)
@abstractmethod def _func_evaluate(self, decision_vector: np.ndarray) -> ObjectiveEvaluationResults: """Evaluates the true objective value. Value is evaluated with the decision variable vector as the input. Uses the true (potentially expensive) evaluator if available. Arguments: decision_vector (np.ndarray): A vector of Variables to be used in the evaluation of the objective. """ pass @abstractmethod def _surrogate_evaluate(self, decision_vector: np.ndarray) -> ObjectiveEvaluationResults: """Evaluates the objective value. Value is evaluated with the decision variable vector as the input. Uses the surrogartes if available. Arguments: decision_vector(np.ndarray): A vector of Variables to be used in the evaluation of the objective. """ pass
[docs]class VectorObjectiveBase(ABC): """The abstract base class for multiple objectives which are calculated at once."""
[docs] def evaluate(self, decision_vector: np.ndarray, use_surrogate: bool = False) -> ObjectiveEvaluationResults: """Evaluates the objective according to a decision variable vector. Uses surrogate model if use_surrogates is true. If use_surrogates is False, uses func_evaluate which evaluates using the true objective function. Arguments: decision_vector (np.ndarray): A vector of Variables to be used in the evaluation of the objective. use_surrogate (bool) : A boolean which determines whether to use surrogates or true function evaluator. False by default. """ if use_surrogate: return self._surrogate_evaluate(decision_vector) else: return self._func_evaluate(decision_vector)
@abstractmethod def _func_evaluate(self, decision_vector: np.ndarray) -> ObjectiveEvaluationResults: """Evaluates the true objective values according to a decision variable vector. Uses the true (potentially expensive) evaluator if available. Arguments: decision_vector (np.ndarray): A vector of Variables to be used in the evaluation of the objective. """ pass @abstractmethod def _surrogate_evaluate(self, decision_vector: np.ndarray) -> ObjectiveEvaluationResults: """Evaluates the objective values according to a decision variable vector. Uses the surrogartes if available. Arguments: decision_vector (np.ndarray): A vector of Variables to be used in the evaluation of the objective. """ pass
# TODO: Depreciate
[docs]class ScalarObjective(ObjectiveBase): """A simple objective function that returns a scalar. To be depreciated Arguments: name (str): Name of the objective. evaluator (Callable): The function to evaluate the objective's value. lower_bound (float): The lower bound of the objective. upper_bound (float): The upper bound of the objective. maximize (bool): Boolean to determine whether the objective is to be maximized. Attributes: __name (str): Name of the objective. __value (float): The current value of the objective function. __evaluator (Callable): The function to evaluate the objective's value. __lower_bound (float): The lower bound of the objective. __upper_bound (float): The upper bound of the objective. maximize (List[bool]): List of boolean to determine whether the objectives are to be maximized. All false by default Raises: ObjectiveError: When ill formed bounds are given. """ def __init__( self, name: str, evaluator: Callable, lower_bound: float = -np.inf, upper_bound: float = np.inf, maximize: List[bool] = None, ) -> None: # Check that the bounds make sense if not (lower_bound < upper_bound): msg = ("Lower bound {} should be less than the upper bound " "{}.").format(lower_bound, upper_bound) raise ObjectiveError(msg) self.__name: str = name self.__evaluator: Callable = evaluator self.__value: float = 0.0 self.__lower_bound: float = lower_bound self.__upper_bound: float = upper_bound if maximize is None: maximize = [False] self.maximize: bool = maximize # TODO implement set/getters. Have validation. @property def name(self) -> str: """Property: name Returns: str: name """ return self.__name @property def value(self) -> float: """Property: value Returns: float: value """ return self.__value @value.setter def value(self, value: float): """Setter: value Arguments: value (float): value to be set """ self.__value = value @property def evaluator(self) -> Callable: """Property: evaluator for the objective Returns: callable: evaluator """ return self.__evaluator @property def lower_bound(self) -> float: """Property: lower bound of the objective. Returns: float: lower bound of the objective """ return self.__lower_bound @property def upper_bound(self) -> float: """Property: upper bound of the objective. Returns: float: upper bound of the objective """ return self.__upper_bound def _func_evaluate(self, decision_vector: np.ndarray) -> ObjectiveEvaluationResults: """Evaluate the objective functions value. Arguments: decision_vector (np.ndarray): A vector of variables to evaluate the objective function with. Returns: ObjectiveEvaluationResults: A named tuple containing the evaluated value, and uncertainity of evaluation of the objective function. Raises: ObjectiveError: When a bad argument is supplied to the evaluator. """ try: result = self.evaluator(decision_vector) except (TypeError, IndexError) as e: msg = "Bad argument {} supplied to the evaluator: {}".format(str(decision_vector), str(e)) raise ObjectiveError(msg) # Store the value of the objective self.value = result uncertainity = np.full_like(result, np.nan, dtype=float) # Have to set dtype because if the tuple is of ints, then this array also # becomes dtype int. There's no nan value of int type return ObjectiveEvaluationResults(result, uncertainity) def _surrogate_evaluate(self, decusuib_vector: np.ndarray): """Evaluate the objective function value with surrogate. Not implemented, raises only error Arguments: decusuib_vector (np.ndarray): A vector of Variables to be used in the evaluation of the objective Raises: ObjectiveError: Surrogate is not trained """ raise ObjectiveError("Surrogates not trained")
[docs]class _ScalarObjective(ScalarObjective): pass
# TODO: Rename to "Objective"
[docs]class VectorObjective(VectorObjectiveBase): """An objective function vector with one or more objective functions. To be renamed to Objective Attributes: __name (List[str]): Names of the various objectives in a list __evaluator (Callable): The function that evaluates the objective values __lower_bounds (Union[List[float], np.ndarray), optional): Lower bounds of the objective values. Defaults to None. __upper_bounds (Union[List[float], np.ndarray), optional): Upper bounds of the objective values. Defaults to None. __maximize (List[bool]): *List* of boolean to determine whether the objectives are to be maximized. All false by default __n_of_objects (int): The number of objectives Arguments: name (List[str]): Names of the various objectives in a list evaluator (Callable): The function that evaluates the objective values lower_bounds (Union[List[float], np.ndarray), optional): Lower bounds of the objective values. Defaults to None. upper_bounds (Union[List[float], np.ndarray), optional): Upper bounds of the objective values. Defaults to None. maximize (List[bool]): *List* of boolean to determine whether the objectives are to be maximized. All false by default Raises: ObjectiveError: When lengths the input arrays are different. ObjectiveError: When any of the lower bounds is not smaller than the corresponding upper bound. """ def __init__( self, name: List[str], evaluator: Callable, lower_bounds: Union[List[float], np.ndarray] = None, upper_bounds: Union[List[float], np.ndarray] = None, maximize: List[bool] = None, ): n_of_objectives = len(name) if lower_bounds is None: lower_bounds = np.full(n_of_objectives, -np.inf) if upper_bounds is None: upper_bounds = np.full(n_of_objectives, np.inf) lower_bounds = np.asarray(lower_bounds) upper_bounds = np.asarray(upper_bounds) # Check if list lengths are the same if not (n_of_objectives == len(lower_bounds)): msg = ( "The length of the list of names and the number of elements in the " "lower_bounds array should be the same" ) raise ObjectiveError(msg) if not (n_of_objectives == len(upper_bounds)): msg = ( "The length of the list of names and the number of elements in the " "upper_bounds array should be the same" ) raise ObjectiveError(msg) # Check if all lower bounds are smaller than the corresponding upper bounds if not (np.all(lower_bounds < upper_bounds)): msg = "Lower bounds should be less than the upper bound " raise ObjectiveError(msg) self.__name: List[str] = name self.__n_of_objectives: int = n_of_objectives self.__evaluator: Callable = evaluator self.__values: Tuple[float] = (0.0,) * n_of_objectives self.__lower_bounds: np.ndarray = lower_bounds self.__upper_bounds: np.ndarray = upper_bounds if maximize is None: self.maximize = [False] * n_of_objectives else: self.maximize: bool = maximize @property def name(self) -> str: """Property: name Returns: str: name of the objective """ return self.__name @property def n_of_objectives(self) -> int: """Property: number of objectives Returns: int: the number of objectives """ return self.__n_of_objectives @property def values(self) -> Tuple[float]: """Property: values Returns: Tuple[float]: Evaluated value and uncertainty of evaluation """ return self.__values @values.setter def values(self, values: Tuple[float]): """Setter: values Arguments: values (Tuple[float]): Value of the objective and its uncertainty. """ self.__values = values @property def evaluator(self) -> Callable: """Property: evaluator Returns: Callable: Evaluator of the objective """ return self.__evaluator @property def lower_bounds(self) -> np.ndarray: """Property: lower bounds Returns: np.ndarray: lower bounds for vector valued objective. """ return self.__lower_bounds @property def upper_bounds(self) -> np.ndarray: """Property: upper bounds Returns: np.ndarray: upper bounds for vector valued objective. """ return self.__upper_bounds def _func_evaluate(self, decision_vector: np.ndarray) -> ObjectiveEvaluationResults: """Evaluate the multiple objective functions value. Arguments: decision_vector (np.ndarray): A vector of variables to evaluate the objective function with. Returns: ObjectiveEvaluationResults: A named tuple containing the evaluated value, and uncertainity of evaluation of the objective function. Raises: ObjectiveError: When a bad argument is supplies to the evaluator or when the evaluator returns an unexpected number of outputs. """ try: result = self.evaluator(decision_vector) except (TypeError, IndexError) as e: msg = "Bad argument {} supplied to the evaluator: {}".format(str(decision_vector), str(e)) raise ObjectiveError(msg) result = tuple(result) # Store the value of the objective self.values = result uncertainity = np.full_like(result, np.nan, dtype=float) # Have to set dtype because if the tuple is of ints, then this array also # becomes dtype int. There's no nan value of int type return ObjectiveEvaluationResults(result, uncertainity) def _surrogate_evaluate(self, decusuib_vector: np.ndarray): raise ObjectiveError("Surrogates not trained")
# TODO: Depreciate
[docs]class ScalarDataObjective(ScalarObjective): """A simple Objective class for single valued objectives. To be depreciated. Use when the an evaluator/simulator returns a single objective value or when there is no evaluator/simulator Attributes: X (pd.DataFrame): Dataframe with corresponds the points where the objective value is known. y (pd.Series): The objective values corresponding the points. variable_names (pd.Index): The names of the variables in X _model(BaseRegressor): Model of the data Arguments: name (List[str]): The name of the objective. Should be the same as a column name in the data. data (pd.DataFrame): The data in a pandas dataframe. The columns should be named after variables/objective. evaluator (Union[None, Callable], optional): A python function that contains the analytical function or calls the simulator to get the true objective value. By default None, as this is not required. lower_bound (float, optional): Lower bound of the objective, by default -np.inf upper_bound (float, optional): Upper bound of the objective, by default np.inf maximize (List[bool], optional): Boolean describing whether the objective is to be maximized or not, by default None, which defaults to [False], hence minimizes. Raises: ObjectiveError: When the name provided during initialization does not match any name in the columns of the data provided during initilizaiton. """ def __init__( self, name: List[str], data: pd.DataFrame, evaluator: Union[None, Callable] = None, lower_bound: float = -np.inf, upper_bound: float = np.inf, maximize: List[bool] = None, ) -> None: if name in data.columns: super().__init__(name, evaluator, lower_bound, upper_bound, maximize) else: msg = f'Name "{name}" not found in the dataframe provided' raise ObjectiveError(msg) self.X = data.drop(name, axis=1) self.y = data[name] self.variable_names = self.X.columns self._model = None self.__evaluator = evaluator #this is how we added analatycal function
[docs] def train( self, model: BaseRegressor, model_parameters: Dict = None, index: List[int] = None, data: pd.DataFrame = None, ): """Train surrogate model for the objective. Arguments: model (BaseRegressor): A regressor. The regressor, when initialized, should have a fit method and a predict method. The predict method should return the predicted objective value, as well as the uncertainity value, in a tuple. If the regressor does not support calculating uncertainity, return a tuple of objective value and None. model_parameters (Dict): **model_parameters is passed to the model when initialized. index (List[int], optional): Indices of the samples (in self.X and self.y), to be used to train the surrogate model. By default None, which trains the model on the entire dataset. This behaviour may be changed in the future to support test-train split or cross validation. data (pd.DataFrame, optional): Extra data to be used for training only. This data is not saved. By default None, which then uses self.X and self.y for training. Raises: ObjectiveError: For unexpected errors """ if model_parameters is None: model_parameters = {} self._model = model(**model_parameters) if index is None and data is None: self._model.fit(self.X, self.y) return elif index is not None: self._model.fit(self.X[index], self.y[index]) return elif data is not None: self._model.fit(data[self.variable_names], data[self.name]) return msg = "I don't know how you got this error" raise ObjectiveError(msg)
def _surrogate_evaluate(self, decision_vector: np.ndarray) -> ObjectiveEvaluationResults: """Evaluate the values with surrogate function. Arguments: decision_vector (np.ndarray): Variable values where evaluation is done Returns: ObjectiveEvaluationResults: Result and uncertainty Raises: ObjectiveError: If model has not been trained yet or a bad argument supplied to the model """ if self._model is None: raise ObjectiveError("Model not trained yet") try: result, uncertainity = self._model.predict(decision_vector) except ModelError: msg = "Bad argument supplied to the model" raise ObjectiveError(msg) return ObjectiveEvaluationResults(result, uncertainity) def _func_evaluate(self, decision_vector: np.ndarray) -> ObjectiveEvaluationResults: """Evaluate the values with analytical function. Arguments: decision_vector (np.ndarray): Variable values where evaluation is done Returns: ObjectiveEvaluationResults: Result and uncertainty Raises: ObjectiveError: If the analytical function is not provided """ if self.evaluator is None: msg = "No analytical function provided" raise ObjectiveError(msg) results = super()._func_evaluate(decision_vector) #self.X = np.vstack((self.X, decision_vector)) #changes bhupinder and pouya made #self.y = np.vstack((self.y, results.objectives)) #changes bhupinder and pouya made return results
[docs]class _ScalarDataObjective(ScalarDataObjective): pass
[docs]class VectorDataObjective(VectorObjective): """A Objective class for multi/valued objectives. Use when the an evaluator/simulator returns a multiple objective values or when there is no evaluator/simulator. Attributes: X (pd.DataFrame): Dataframe with corresponds the points where the objective value is known. y (pd.Series): The objective values corresponding the points. variable_names (pd.Index): The names of the variables in X _model(Dict): BaseRegressor (or None if not trained) models for each objective, keys are the names of objectives. _model_trained(Dict): boolean if model is trained for each objective, keys are the names of objectives. Default false. Arguments: name (List[str]): The name of the objective. Should be the same as a column name in the data. data (pd.DataFrame): The data in a pandas dataframe. The columns should be named after variables/objective. evaluator (Union[None, Callable], optional): A python function that contains the analytical function or calls the simulator to get the true objective value. By default None, as this is not required. lower_bound (float, optional): Lower bound of the objective, by default -np.inf upper_bound (float, optional): Upper bound of the objective, by default np.inf maximize (List[bool], optional): Boolean describing whether the objective is to be maximized or not, by default None, which defaults to [False], hence minimizes. Raises: ObjectiveError: When the name provided during initialization does not match any name in the columns of the data provided during initilizaiton. """ def __init__( self, name: List[str], data: pd.DataFrame, evaluator: Union[None, Callable] = None, lower_bounds: Union[List[float], np.ndarray] = None, upper_bounds: Union[List[float], np.ndarray] = None, maximize: List[bool] = None, ) -> None: if all(obj in data.columns for obj in name): super().__init__(name, evaluator, lower_bounds, upper_bounds, maximize) else: msg = f'Name "{name}" not found in the dataframe provided' raise ObjectiveError(msg) self.X = data.drop(name, axis=1) self.y = data[name] self.variable_names = self.X.columns self._model = dict.fromkeys(name) # TODO: Make the set of keys immutable? self._model_trained = dict.fromkeys(name, False)
[docs] def train( self, models: Union[BaseRegressor, List[BaseRegressor]], model_parameters: Union[Dict, List[Dict]] = None, index: List[int] = None, data: pd.DataFrame = None, ): """Train surrogate models for the objective. Arguments: model (BaseRegressor or List[BaseRegressors]): A regressor or a list of regressors. The regressor/s, when initialized, should have a fit method and a predict method. The predict method should return the predicted objective value, as well as the uncertainity value, in a tuple. If the regressor does not support calculating uncertainity, return a tuple of objective value and None. If a single regressor is provided, that regressor is used for all the objectives. If a list of regressors is provided, and if the list contains one regressor for each objective, then those individual regressors are used to model the objectives. If the number of regressors is not equal to the number of objectives, an error is raised. model_parameters (Dict or List[Dict]): The parameters for the regressors. Should be a dict if a single regressor is provided. If a list of regressors is provided, the parameters should be in a list of dicts, same length as the list of regressors(= number of objs). index (List[int], optional): Indices of the samples (in self.X and self.y), to be used to train the surrogate model. By default None, which trains the model on the entire dataset. This behaviour may be changed in the future to support test-train split or cross validation. data (pd.DataFrame, optional): Extra data to be used for training only. This data is not saved. By default None, which then uses self.X and self.y for training. Raises: ObjectiveError: If the formats of the model and model parameters do not match ObjectiveError: If the lengths of list of models and/or model parameter dictionaries are not equal to the number of objectives. """ if model_parameters is None: model_parameters = {} if not isinstance(models, list): if not (isinstance(model_parameters, dict)): msg = "If only one model is provided, model parameters should be a dict" raise ObjectiveError(msg) models = [models] * len(self.name) model_parameters = [model_parameters] * len(self.name) elif not (len(models) == len(model_parameters) == self.n_of_objectives): msg = ( "The length of lists of models and parameters should be the same as" "the number of objectives in this objective class" ) for model, model_params, name in zip(models, model_parameters, self.name): self._train_one_objective(name, model, model_params, index, data)
def _train_one_objective( self, name: str, model: BaseRegressor, model_parameters: Dict, index: List[int] = None, data: pd.DataFrame = None, ): """Train surrogate model for the objective. Arguments: name (str): Name of the objective for which you want to train the surrogate model model (BaseRegressor): A regressor. The regressor, when initialized, should have a fit method and a predict method. The predict method should return the predicted objective value, as well as the uncertainity value, in a tuple. If the regressor does not support calculating uncertainity, return a tuple of objective value and None. model_parameters (Dict): **model_parameters is passed to the model when initialized. index (List[int], optional): Indices of the samples (in self.X and self.y), to be used to train the surrogate model. By default None, which trains the model on the entire dataset. This behaviour may be changed in the future to support test-train split or cross validation. data (pd.DataFrame, optional): Extra data to be used for training only. This data is not saved. By default None, which then uses self.X and self.y for training. Raises: ObjectiveError: For unexpected errors """ if name not in self.name: raise ObjectiveError(f'"{name}" not found in the list of' f"original objective names: {self.name}") if model_parameters is None: model_parameters = {} self._model[name] = model(**model_parameters) if index is None and data is None: self._model[name].fit(self.X, self.y[name]) self._model_trained[name] = True return elif index is not None: self._model[name].fit(self.X[index], self.y[name][index]) self._model_trained[name] = True return elif data is not None: self._model[name].fit(data[self.variable_names], data[name]) self._model_trained[name] = True return msg = "I don't know how you got this error" raise ObjectiveError(msg) def _surrogate_evaluate(self, decision_vector: np.ndarray) -> ObjectiveEvaluationResults: """Evaluate the values with surrogate function. Arguments: decision_vector (np.ndarray): Variable values where evaluation is done Returns: ObjectiveEvaluationResults: Result and uncertainty Raises: ObjectiveError: If all models have not been trained yet or a bad argument is supplied to the model """ if not all(self._model_trained.values()): msg = ( f"Some or all models have not been trained.\n" f"Models for the following objectives have been trained:\n" f"{self._model_trained}" ) raise ObjectiveError(msg) result = pd.DataFrame(index=range(decision_vector.shape[0]), columns=self.name) uncertainity = pd.DataFrame(index=range(decision_vector.shape[0]), columns=self.name) for name, model in self._model.items(): try: result[name], uncertainity[name] = model.predict(decision_vector) except ModelError: msg = "Bad argument supplied to the model" raise ObjectiveError(msg) return ObjectiveEvaluationResults(result, uncertainity) def _func_evaluate(self, decision_vector: np.ndarray) -> ObjectiveEvaluationResults: """Evaluate the values with analytical function. Arguments: decision_vector (np.ndarray): Variable values where evaluation is done Returns: ObjectiveEvaluationResults: Result and uncertainty Raises: ObjectiveError: If the analytical function is not provided """ if self.evaluator is None: msg = "No analytical function provided" raise ObjectiveError(msg) results = super()._func_evaluate(decision_vector) self.X = np.vstack((self.X, decision_vector)) self.y = np.vstack((self.y, results.objectives)) return results