From 122bf708550c8a7d5e92b089cf2b314ae93d7739 Mon Sep 17 00:00:00 2001 From: TobyBoyne Date: Fri, 20 Oct 2023 13:51:24 +0100 Subject: [PATCH 1/3] Create ConstraintList for multiple constraints --- docs/notebooks/constraint_classes.ipynb | 160 ++++++++++++++++++++++-- entmoot/constraints.py | 39 +++++- 2 files changed, 187 insertions(+), 12 deletions(-) diff --git a/docs/notebooks/constraint_classes.ipynb b/docs/notebooks/constraint_classes.ipynb index 8e50330..e38e740 100644 --- a/docs/notebooks/constraint_classes.ipynb +++ b/docs/notebooks/constraint_classes.ipynb @@ -35,16 +35,7 @@ "cell_type": "code", "execution_count": 2, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "c:\\users\\tobyb\\phd\\entmoot\\entmoot\\models\\mean_models\\tree_ensemble.py:23: UserWarning: No 'train_params' for tree ensemble training specified. Switch training to default params!\n", - " warnings.warn(\n" - ] - } - ], + "outputs": [], "source": [ "from entmoot.benchmarks import build_reals_only_problem, eval_reals_only_testfunc\n", "\n", @@ -189,10 +180,157 @@ "from entmoot.constraints import ExpressionConstraint\n", "\n", "class SumLessThanTen(ExpressionConstraint):\n", - " \"\"\"A constraint that enforces all features to be equal.\"\"\"\n", + " \"\"\"A constraint that enforces selected features to sum to less than ten.\"\"\"\n", " def _get_expr(self, features):\n", " return sum(features) <= 10" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Constraint Lists\n", + "\n", + "For a problem definition, it may be easier to define a set of constraints." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "problem_config = ProblemConfig(rnd_seed=73)\n", + "build_reals_only_problem(problem_config)\n", + "rnd_sample = problem_config.get_rnd_sample_list(num_samples=50)\n", + "testfunc_evals = eval_reals_only_testfunc(rnd_sample)\n", + "\n", + "params = {\"unc_params\": {\"dist_metric\": \"l1\", \"acq_sense\": \"penalty\"}}\n", + "enting = Enting(problem_config, params=params)\n", + "# fit tree ensemble\n", + "enting.fit(rnd_sample, testfunc_evals)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "from entmoot.constraints import LinearInequalityConstraint, ConstraintList\n", + "import pyomo.environ as pyo\n", + "model_pyo = problem_config.get_pyomo_model_core()\n", + "\n", + "# define the constraint\n", + "# then immediately apply it to the model\n", + "constraints = [\n", + " NChooseKConstraint(\n", + " feature_keys=[\"x1\", \"x2\", \"x3\", \"x4\", \"x5\"], \n", + " min_count=1,\n", + " max_count=4,\n", + " none_also_valid=True\n", + " ),\n", + " LinearInequalityConstraint(\n", + " feature_keys=[\"x3\", \"x4\", \"x5\"],\n", + " coefficients=[1, 1, 1],\n", + " rhs=12.0\n", + " )\n", + "]\n", + "\n", + "model_pyo.problem_constraints = pyo.ConstraintList()\n", + "ConstraintList(constraints).apply_pyomo_constraints(\n", + " model_pyo, problem_config.feat_list, model_pyo.problem_constraints\n", + ")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter Username\n", + "Academic license - for non-commercial use only - expires 2024-09-06\n", + "Read LP format model from file C:\\Users\\tobyb\\AppData\\Local\\Temp\\tmp0tfjrq6i.pyomo.lp\n", + "Reading time = 0.01 seconds\n", + "x1: 2775 rows, 1913 columns, 9133 nonzeros\n", + "Gurobi Optimizer version 10.0.2 build v10.0.2rc0 (win64)\n", + "\n", + "CPU model: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 4 physical cores, 8 logical processors, using up to 8 threads\n", + "\n", + "Optimize a model with 2775 rows, 1913 columns and 9133 nonzeros\n", + "Model fingerprint: 0x8879e895\n", + "Variable types: 1292 continuous, 621 integer (621 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e-06, 1e+06]\n", + " Objective range [1e+00, 2e+00]\n", + " Bounds range [1e+00, 5e+00]\n", + " RHS range [1e-04, 1e+01]\n", + "Presolve removed 273 rows and 260 columns\n", + "Presolve time: 0.05s\n", + "Presolved: 2502 rows, 1653 columns, 8084 nonzeros\n", + "Variable types: 1282 continuous, 371 integer (371 binary)\n", + "Found heuristic solution: objective 10.1607516\n", + "\n", + "Root relaxation: objective 2.501750e+00, 463 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 2.50175 0 11 10.16075 2.50175 75.4% - 0s\n", + "H 0 0 10.1529199 2.50175 75.4% - 0s\n", + "H 0 0 2.8865055 2.50175 13.3% - 0s\n", + " 0 0 2.60484 0 3 2.88651 2.60484 9.76% - 0s\n", + " 0 0 2.60484 0 6 2.88651 2.60484 9.76% - 0s\n", + " 0 0 2.60484 0 5 2.88651 2.60484 9.76% - 0s\n", + "H 0 0 2.8764756 2.60484 9.44% - 0s\n", + " 0 0 2.69557 0 11 2.87648 2.69557 6.29% - 0s\n", + "H 0 0 2.8510016 2.69557 5.45% - 0s\n", + "* 0 0 0 2.8510016 2.85100 0.00% - 0s\n", + "\n", + "Cutting planes:\n", + " Cover: 1\n", + " Clique: 5\n", + " RLT: 1\n", + " Relax-and-lift: 6\n", + "\n", + "Explored 1 nodes (786 simplex iterations) in 0.16 seconds (0.12 work units)\n", + "Thread count was 8 (of 8 available processors)\n", + "\n", + "Solution count 5: 2.851 2.87648 2.88651 ... 10.1608\n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 2.851001621749e+00, best bound 2.851001621749e+00, gap 0.0000%\n" + ] + } + ], + "source": [ + "# optimise the model\n", + "params_pyomo = {\"solver_name\": \"gurobi\"}\n", + "opt_pyo = PyomoOptimizer(problem_config, params=params_pyomo)\n", + "res_pyo = opt_pyo.solve(enting, model_core=model_pyo)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[0.0, 2.43082, 3.2917799999999997, 4.888964463647816, 3.6007]\n" + ] + } + ], + "source": [ + "print(res_pyo.opt_point)" + ] } ], "metadata": { diff --git a/entmoot/constraints.py b/entmoot/constraints.py index 8b8d807..d437749 100644 --- a/entmoot/constraints.py +++ b/entmoot/constraints.py @@ -3,6 +3,8 @@ import pyomo.environ as pyo +from entmoot.problem_config import FeatureType + if TYPE_CHECKING: from problem_config import FeatureType @@ -40,6 +42,39 @@ def as_pyomo_constraint( pass +class ConstraintList: + """Contains multiple constraints to be applied at once.""" + + def __init__(self, constraints: list[Constraint]): + self._constraints = constraints + + def add(self, constraint: Constraint): + self._constraints.append(constraint) + + def apply_pyomo_constraints( + self, + model: pyo.ConcreteModel, + feat_list: list[FeatureType], + pyo_constraint_list: pyo.ConstraintList, + ) -> None: + """Add constraints to a pyo.ConstraintList object. + + Requires creation of the pyo.ConstraintList outside of this class, + to the user to specify the constraints name.""" + + for constraint in self._constraints: + features = constraint._get_feature_vars(model, feat_list) + if isinstance(constraint, ExpressionConstraint): + expr = constraint._get_expr(features) + + elif isinstance(constraint, FunctionalConstraint): + # must convert rules to expr + rule = constraint._get_function(model, features) + expr = rule(model, 0) + + pyo_constraint_list.add(expr) + + class ExpressionConstraint(Constraint): """Constraints defined by pyomo.Expressions. @@ -70,7 +105,9 @@ def as_pyomo_constraint( return pyo.Constraint(rule=self._get_function(model, features)) @abstractmethod - def _get_function(self, features) -> ConstraintFunctionType: + def _get_function( + self, model: pyo.ConcreteModel, features: list["FeatureType"] + ) -> ConstraintFunctionType: pass From c98e2f959ed9492d124f06928e8a40cc4cf42ebe Mon Sep 17 00:00:00 2001 From: TobyBoyne Date: Fri, 20 Oct 2023 14:01:12 +0100 Subject: [PATCH 2/3] Add test for ConstraintList --- tests/test_constraints_pyomo.py | 56 ++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/tests/test_constraints_pyomo.py b/tests/test_constraints_pyomo.py index 2eaff5c..8a5b767 100644 --- a/tests/test_constraints_pyomo.py +++ b/tests/test_constraints_pyomo.py @@ -1,6 +1,10 @@ from entmoot.problem_config import ProblemConfig from entmoot.models.enting import Enting from entmoot.optimizers.pyomo_opt import PyomoOptimizer +from entmoot.models.model_params import EntingParams, UncParams +from entmoot.constraints import LinearInequalityConstraint, ConstraintList +import pyomo.environ as pyo + from entmoot.benchmarks import ( build_reals_only_problem, @@ -14,6 +18,9 @@ ) import pytest +PARAMS = EntingParams( + unc_params=UncParams(dist_metric="l1", acq_sense="exploration") +) def test_linear_equality_constraint(): problem_config = ProblemConfig(rnd_seed=73) @@ -25,8 +32,7 @@ def test_linear_equality_constraint(): rnd_sample = problem_config.get_rnd_sample_list(num_samples=20) testfunc_evals = eval_multi_obj_cat_testfunc(rnd_sample, n_obj=number_objectives) - params = {"unc_params": {"dist_metric": "l1", "acq_sense": "exploration"}} - enting = Enting(problem_config, params=params) + enting = Enting(problem_config, params=PARAMS) # fit tree ensemble enting.fit(rnd_sample, testfunc_evals) @@ -66,8 +72,7 @@ def test_nchoosek_constraint(min_count, max_count): rnd_sample = problem_config.get_rnd_sample_list(num_samples=50) testfunc_evals = eval_reals_only_testfunc(rnd_sample) - params = {"unc_params": {"dist_metric": "l1", "acq_sense": "penalty"}} - enting = Enting(problem_config, params=params) + enting = Enting(problem_config, params=PARAMS) # fit tree ensemble enting.fit(rnd_sample, testfunc_evals) @@ -88,3 +93,46 @@ def test_nchoosek_constraint(min_count, max_count): res_pyo = opt_pyo.solve(enting, model_core=model_pyo) assert min_count <= sum(x > 1e-6 for x in res_pyo.opt_point) <= max_count + + +def test_constraint_list(): + problem_config = ProblemConfig(rnd_seed=73) + build_reals_only_problem(problem_config) + rnd_sample = problem_config.get_rnd_sample_list(num_samples=50) + testfunc_evals = eval_reals_only_testfunc(rnd_sample) + + enting = Enting(problem_config, params=PARAMS) + # fit tree ensemble + enting.fit(rnd_sample, testfunc_evals) + + model_pyo = problem_config.get_pyomo_model_core() + + # define the constraints + constraints = [ + NChooseKConstraint( + feature_keys=["x1", "x2", "x3", "x4", "x5"], + min_count=1, + max_count=3, + none_also_valid=True + ), + LinearInequalityConstraint( + feature_keys=["x3", "x4", "x5"], + coefficients=[1, 1, 1], + rhs=10.0 + ) + ] + + # apply constraints to the model + model_pyo.problem_constraints = pyo.ConstraintList() + ConstraintList(constraints).apply_pyomo_constraints( + model_pyo, problem_config.feat_list, model_pyo.problem_constraints + ) + + # optimise the model + params_pyomo = {"solver_name": "gurobi"} + opt_pyo = PyomoOptimizer(problem_config, params=params_pyomo) + res_pyo = opt_pyo.solve(enting, model_core=model_pyo) + + print(res_pyo.opt_point) + assert 1 <= sum(x > 1e-6 for x in res_pyo.opt_point) <= 3 + assert sum(res_pyo.opt_point[2:]) < 10.0 \ No newline at end of file From 166a32d0655aa79e42554b611e91036b9d2052f0 Mon Sep 17 00:00:00 2001 From: TobyBoyne Date: Fri, 20 Oct 2023 14:04:58 +0100 Subject: [PATCH 3/3] Convert NChooseK to an ExpressionConstraint --- entmoot/constraints.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/entmoot/constraints.py b/entmoot/constraints.py index d437749..c4f990a 100644 --- a/entmoot/constraints.py +++ b/entmoot/constraints.py @@ -64,14 +64,10 @@ def apply_pyomo_constraints( for constraint in self._constraints: features = constraint._get_feature_vars(model, feat_list) - if isinstance(constraint, ExpressionConstraint): - expr = constraint._get_expr(features) - - elif isinstance(constraint, FunctionalConstraint): - # must convert rules to expr - rule = constraint._get_function(model, features) - expr = rule(model, 0) + if not isinstance(constraint, ExpressionConstraint): + raise TypeError("Only ExpressionConstraints are supported in a constraint list") + expr = constraint._get_expr(model, features) pyo_constraint_list.add(expr) @@ -85,10 +81,10 @@ def as_pyomo_constraint( self, model: pyo.ConcreteModel, feat_list: list["FeatureType"] ) -> pyo.Constraint: features = self._get_feature_vars(model, feat_list) - return pyo.Constraint(expr=self._get_expr(features)) + return pyo.Constraint(expr=self._get_expr(model, features)) @abstractmethod - def _get_expr(self, features) -> pyo.Expression: + def _get_expr(self, model, features) -> pyo.Expression: pass @@ -126,16 +122,16 @@ def _get_lhs(self, features: pyo.ConcreteModel) -> pyo.Expression: class LinearEqualityConstraint(LinearConstraint): - def _get_expr(self, features): + def _get_expr(self, model, features): return self._get_lhs(features) == self.rhs class LinearInequalityConstraint(LinearConstraint): - def _get_expr(self, features): + def _get_expr(self, model, features): return self._get_lhs(features) <= self.rhs -class NChooseKConstraint(FunctionalConstraint): +class NChooseKConstraint(ExpressionConstraint): """Constrain the number of active features to be bounded by min_count and max_count.""" tol: float = 1e-6 @@ -153,7 +149,7 @@ def __init__( self.none_also_valid = none_also_valid super().__init__(feature_keys) - def _get_function(self, model, features): + def _get_expr(self, model, features): # constrain the features using the binary variable y # where y indicates whether the feature is selected # y * tol <= x <= y * M @@ -168,7 +164,4 @@ def _get_function(self, model, features): model.ub_selected.add(expr=model.feat_selected[i] * self.M >= features[i]) model.lb_selected.add(expr=model.feat_selected[i] * self.tol <= features[i]) - def inner(model, i): - return sum(model.feat_selected.values()) <= self.max_count - - return inner + return sum(model.feat_selected.values()) <= self.max_count