Skip to content

Commit

Permalink
Merge pull request #27 from TobyBoyne/feature/constraintlist
Browse files Browse the repository at this point in the history
Add ConstraintList
  • Loading branch information
spiralulam authored Oct 23, 2023
2 parents 1079571 + 166a32d commit 64927b8
Show file tree
Hide file tree
Showing 3 changed files with 242 additions and 26 deletions.
160 changes: 149 additions & 11 deletions docs/notebooks/constraint_classes.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
52 changes: 41 additions & 11 deletions entmoot/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import pyomo.environ as pyo

from entmoot.problem_config import FeatureType

if TYPE_CHECKING:
from problem_config import FeatureType

Expand Down Expand Up @@ -40,6 +42,35 @@ 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 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)


class ExpressionConstraint(Constraint):
"""Constraints defined by pyomo.Expressions.
Expand All @@ -50,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


Expand All @@ -70,7 +101,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


Expand All @@ -89,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
Expand All @@ -116,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
Expand All @@ -131,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
56 changes: 52 additions & 4 deletions tests/test_constraints_pyomo.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -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

0 comments on commit 64927b8

Please sign in to comment.