From 1b5a300c9bb0d2ae9d6ecfd980ea496c34ac1c11 Mon Sep 17 00:00:00 2001 From: Giovanni Misitano Date: Tue, 23 Apr 2024 14:41:02 +0300 Subject: [PATCH] Added and tested support for scenario-based problems. Objectives, constraints, extra functions, and scalarization functions now have an optional "scenario_keys" field, which indicated the scenario(s) they belong to. The Problem model has also a new method "scenario" that when called with a scenario key, will return a new problem with just that scenario's functions. The Problem has also the field "scenario_keys" which lists all the defined scenarios. Implemented also a simple scenario-based test problem and tested the new functionalities. --- desdeo/problem/__init__.py | 2 + desdeo/problem/schema.py | 133 +++++++++++++++++++++++++- desdeo/problem/testproblems.py | 165 ++++++++++++++++++++++++++++++++- tests/test_problem_schema.py | 65 ++++++++++++- 4 files changed, 361 insertions(+), 4 deletions(-) diff --git a/desdeo/problem/__init__.py b/desdeo/problem/__init__.py index d69339d..9218487 100644 --- a/desdeo/problem/__init__.py +++ b/desdeo/problem/__init__.py @@ -30,6 +30,7 @@ "simple_data_problem", "simple_knapsack", "simple_linear_test_problem", + "simple_scenario_test_problem", "simple_test_problem", "ScalarizationFunction", "Variable", @@ -71,6 +72,7 @@ simple_data_problem, simple_knapsack, simple_linear_test_problem, + simple_scenario_test_problem, simple_test_problem, zdt1, ) diff --git a/desdeo/problem/schema.py b/desdeo/problem/schema.py index b37dfd0..389022c 100644 --- a/desdeo/problem/schema.py +++ b/desdeo/problem/schema.py @@ -55,7 +55,34 @@ def parse_infix_to_func(cls, v: str | list) -> list: return v # Raise an error if v is neither a string nor a list - msg = f"func must be a string (infix expression) or a list, got {type(v)}" + msg = f"The function expressions must be a string (infix expression) or a list. Got {type(v)}." + raise ValueError(msg) + + +def parse_scenario_key_singleton_to_list(cls, v: str | list[str]) -> list[str]: + """Validator that checks the type of a scenario key. + + If the type is a list, it will be returned as it is. If it is a string, + then a list with the single string is returned. Else, a ValueError is raised. + + Args: + cls: the class fo the pydantic model the validtor is applied to. + v (str | list[str]): the scenario key, or keys, to be validted. + + Raises: + ValueError: raised when `v` it neither a string or a list. + + Returns: + list[str]: a list with scenario keys. + """ + if v is None: + return v + if isinstance(v, str): + return [v] + if isinstance(v, list): + return v + + msg = f"The scenario keys must be either a list of strings, or a single string. Got {type(v)}." raise ValueError(msg) @@ -202,8 +229,15 @@ class ExtraFunction(BaseModel): description="Whether the function expression is twice differentiable or not. Defaults to `False`", default=False ) """Whether the function expression is twice differentiable or not. Defaults to `False`""" + scenario_keys: list[str] | None = Field( + description="Optional. The keys of the scenario the extra functions belongs to.", default=None + ) + """Optional. The keys of the scenarios the extra functions belongs to.""" _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func) + _parse_scenario_key_singleton_to_list = field_validator("scenario_keys", mode="before")( + parse_scenario_key_singleton_to_list + ) class ScalarizationFunction(BaseModel): @@ -249,8 +283,15 @@ class ScalarizationFunction(BaseModel): frozen=True, ) """Whether the function expression is twice differentiable or not. Defaults to `False`""" + scenario_keys: list[str] = Field( + description="Optional. The keys of the scenarios the scalarization function belongs to.", default=False + ) + """Optional. The keys of the scenarios the scalarization function belongs to.""" _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func) + _parse_scenario_key_singleton_to_list = field_validator("scenario_keys", mode="before")( + parse_scenario_key_singleton_to_list + ) class Objective(BaseModel): @@ -328,8 +369,15 @@ class Objective(BaseModel): description="Whether the function expression is twice differentiable or not. Defaults to `False`", default=False ) """Whether the function expression is twice differentiable or not. Defaults to `False`""" + scenario_keys: list[str] | None = Field( + description="Optional. The keys of the scenarios the objective function belongs to.", default=None + ) + """Optional. The keys of the scenarios the objective function belongs to.""" _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func) + _parse_scenario_key_singleton_to_list = field_validator("scenario_keys", mode="before")( + parse_scenario_key_singleton_to_list + ) class Constraint(BaseModel): @@ -392,8 +440,15 @@ class Constraint(BaseModel): description="Whether the function expression is twice differentiable or not. Defaults to `False`", default=False ) """Whether the function expression is twice differentiable or not. Defaults to `False`""" + scenario_keys: list[str] | None = Field( + description="Optional. The keys of the scenarios the constraint belongs to.", default=None + ) + """Optional. The keys of the scenarios the constraint belongs to.""" _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func) + _parse_scenario_key_singleton_to_list = field_validator("scenario_keys", mode="before")( + parse_scenario_key_singleton_to_list + ) class DiscreteRepresentation(BaseModel): @@ -815,6 +870,68 @@ def is_twice_differentiable(self) -> bool: return all(is_diff_values) + def scenario(self, scenario_key: str) -> "Problem": + """Returns a new Problem with fields belonging to a specified scenario. + + The new problem will have the fields `objectives`, `constraints`, `extra_funcs`, + and `scalarization_funcs` with only the entries that belong to the specified + scenario. The other entries will remain unchanged. + + Note: + Fields with their `scenario_key` being `None` are assumed to belong to all scenarios, + and are thus always included in each scenario. + + Args: + scenario_key (str): the key of the scenario we wish to get. + + Raises: + ValueError: the given `scenario_key` has not been defined to be a scenario. + + Returns: + Problem: a new problem with only the field that belong to the specified scenario. + """ + if self.scenario_keys is None or scenario_key not in self.scenario_keys: + # invalid scenario + msg = ( + f"The scenario '{scenario_key} has not been defined to be a valid scenario, or the problem has no " + "scenarios defined." + ) + raise ValueError(msg) + + # add the fields if the field has the given scenario_key in its scenario_keys, or if the + # scenario_keys is None + scenario_objectives = [ + obj for obj in self.objectives if obj.scenario_keys is None or scenario_key in obj.scenario_keys + ] + scenario_constraints = ( + [cons for cons in self.constraints if cons.scenario_keys is None or scenario_key in cons.scenario_keys] + if self.constraints is not None + else None + ) + scenario_extras = ( + [extra for extra in self.extra_funcs if extra.scenario_keys is None or scenario_key in extra.scenario_keys] + if self.extra_funcs is not None + else None + ) + scenario_scals = ( + [ + scal + for scal in self.scalarization_funcs + if scal.scenario_keys is None or scenario_key in scal.scenario_keys + ] + if self.scalarization_funcs is not None + else None + ) + + return self.model_copy( + update={ + "objectives": scenario_objectives, + "constraints": scenario_constraints, + "extra_funcs": scenario_extras, + "scalarization_funcs": scenario_scals, + } + ) + name: str = Field( description="Name of the problem.", ) @@ -857,11 +974,23 @@ def is_twice_differentiable(self) -> bool: ), default=None, ) - """ Optional. Required when there are one or more 'data_based' Objectives. + """Optional. Required when there are one or more 'data_based' Objectives. The corresponding values of the 'data_based' objective function will be fetched from this with the given variable values. Is also utilized for methods which require both an analytical and discrete representation of a problem. Defaults to `None`.""" + scenario_keys: list[str] | None = Field( + description=( + "Optional. The scenario keys defined for the problem. Each key will point to a subset of objectives, " + "constraints, extra functions, and scalarization functions that have the same scenario key defined to them." + "If None, then the problem is assumed to not contain scenarios." + ), + default=None, + ) + """Optional. The scenario keys defined for the problem. Each key will point + to a subset of objectives, " "constraints, extra functions, and + scalarization functions that have the same scenario key defined to them." + "If None, then the problem is assumed to not contain scenarios.""" if __name__ == "__main__": diff --git a/desdeo/problem/testproblems.py b/desdeo/problem/testproblems.py index 7e7f438..9a6d78e 100644 --- a/desdeo/problem/testproblems.py +++ b/desdeo/problem/testproblems.py @@ -851,6 +851,169 @@ def simple_knapsack() -> Problem: ) +def simple_scenario_test_problem(): + """Returns a simple, scenario-based multiobjective optimization test problem.""" + constants = [Constant(name="c_1", symbol="c_1", value=3)] + variables = [ + Variable( + name="x_1", + symbol="x_1", + lowerbound=-5.1, + upperbound=6.2, + initial_value=0, + variable_type=VariableTypeEnum.real, + ), + Variable( + name="x_2", + symbol="x_2", + lowerbound=-5.2, + upperbound=6.1, + initial_value=0, + variable_type=VariableTypeEnum.real, + ), + ] + + constraints = [ + Constraint( + name="con_1", + symbol="con_1", + cons_type=ConstraintTypeEnum.LTE, + func="x_1 + x_2 - 15", + is_linear=True, + is_convex=True, + is_twice_differentiable=True, + scenario_keys="s_1", + ), + Constraint( + name="con_2", + symbol="con_2", + cons_type=ConstraintTypeEnum.LTE, + func="x_1 + x_2 - 65", + is_linear=True, + is_convex=True, + is_twice_differentiable=True, + scenario_keys="s_2", + ), + Constraint( + name="con_3", + symbol="con_3", + cons_type=ConstraintTypeEnum.LTE, + func="x_2 - 50", + is_linear=True, + is_convex=True, + is_twice_differentiable=True, + scenario_keys=None, + ), + Constraint( + name="con_4", + symbol="con_4", + cons_type=ConstraintTypeEnum.LTE, + func="x_1 - 5", + is_linear=True, + is_convex=True, + is_twice_differentiable=True, + scenario_keys=["s_1", "s_2"], + ), + ] + + expr_1 = "x_1 + x_2" + expr_2 = "x_1 - x_2" + expr_3 = "(x_1 - 3)**2 + x_2" + expr_4 = "c_1 + x_2**2 - x_1" + expr_5 = "-x_1 - x_2" + + objectives = [ + Objective( + name="f_1", + symbol="f_1", + func=expr_1, + maximize=False, + ideal=-100, + nadir=100, + objective_type=ObjectiveTypeEnum.analytical, + is_linear=True, + is_convex=True, + is_twice_differentiable=True, + scenario_keys="s_1", + ), + Objective( + name="f_2", + symbol="f_2", + func=expr_2, + maximize=False, + ideal=-100, + nadir=100, + objective_type=ObjectiveTypeEnum.analytical, + is_linear=True, + is_convex=True, + is_twice_differentiable=True, + scenario_keys=["s_1", "s_2"], + ), + Objective( + name="f_3", + symbol="f_3", + func=expr_3, + maximize=False, + ideal=-100, + nadir=100, + objective_type=ObjectiveTypeEnum.analytical, + is_linear=True, + is_convex=True, + is_twice_differentiable=True, + scenario_keys=None, + ), + Objective( + name="f_4", + symbol="f_4", + func=expr_4, + maximize=False, + ideal=-100, + nadir=100, + objective_type=ObjectiveTypeEnum.analytical, + is_linear=True, + is_convex=True, + is_twice_differentiable=True, + scenario_keys="s_2", + ), + Objective( + name="f_5", + symbol="f_5", + func=expr_5, + maximize=False, + ideal=-100, + nadir=100, + objective_type=ObjectiveTypeEnum.analytical, + is_linear=True, + is_convex=True, + is_twice_differentiable=True, + scenario_keys="s_2", + ), + ] + + extra_funcs = [ + ExtraFunction( + name="extra_1", + symbol="extra_1", + func="5*x_1", + is_linear=True, + is_convex=True, + is_twice_differentiable=True, + scenario_keys="s_2", + ) + ] + + return Problem( + name="Simple scenario test problem", + description="For testing the implementation of scenario-based problems.", + variables=variables, + constants=constants, + constraints=constraints, + objectives=objectives, + extra_funcs=extra_funcs, + scenario_keys=["s_1", "s_2"], + ) + + if __name__ == "__main__": - problem = simple_knapsack() + problem = simple_scenario_test_problem() print(problem.model_dump_json(indent=2)) diff --git a/tests/test_problem_schema.py b/tests/test_problem_schema.py index 919d8c3..2a917fa 100644 --- a/tests/test_problem_schema.py +++ b/tests/test_problem_schema.py @@ -19,7 +19,13 @@ VariableDomainTypeEnum, VariableTypeEnum, ) -from desdeo.problem.testproblems import momip_ti7, nimbus_test_problem, river_pollution_problem, simple_knapsack +from desdeo.problem.testproblems import ( + momip_ti7, + nimbus_test_problem, + river_pollution_problem, + simple_knapsack, + simple_scenario_test_problem, +) from desdeo.tools.scalarization import add_scalarization_function @@ -524,3 +530,60 @@ def test_is_twice_diff(): problem_nondiff = river_pollution_problem() assert not problem_nondiff.is_twice_differentiable() + + +def test_scenario_problem(): + """Tests that scenario problems are handled correctly.""" + problem = simple_scenario_test_problem() + + assert len(problem.scenario_keys) == 2 + + # get scenario 1 + problem_s1 = problem.scenario("s_1") + + assert len(problem_s1.objectives) == 3 + assert len(problem_s1.constraints) == 3 + assert len(problem_s1.extra_funcs) == 0 + + symbols_s1 = problem_s1.get_all_symbols() + + assert "f_1" in symbols_s1 + assert "f_2" in symbols_s1 + assert "f_3" in symbols_s1 + assert "f_4" not in symbols_s1 + + assert "con_1" in symbols_s1 + assert "con_2" not in symbols_s1 + assert "con_3" in symbols_s1 + assert "con_4" in symbols_s1 + + assert "extra_1" not in symbols_s1 + + assert "x_1" in symbols_s1 + assert "x_2" in symbols_s1 + assert "c_1" in symbols_s1 + + # get scenario 2 + problem_s2 = problem.scenario("s_2") + + assert len(problem_s2.objectives) == 4 + assert len(problem_s2.constraints) == 3 + assert len(problem_s2.extra_funcs) == 1 + + symbols_s2 = problem_s2.get_all_symbols() + + assert "f_1" not in symbols_s2 + assert "f_2" in symbols_s2 + assert "f_3" in symbols_s2 + assert "f_4" in symbols_s2 + + assert "con_1" not in symbols_s2 + assert "con_2" in symbols_s2 + assert "con_3" in symbols_s2 + assert "con_4" in symbols_s2 + + assert "extra_1" in symbols_s2 + + assert "x_1" in symbols_s2 + assert "x_2" in symbols_s2 + assert "c_1" in symbols_s2