From 1397937f03b672a26d1ca93c1e4d102ff54584a5 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 2 Mar 2021 17:21:05 -0600 Subject: [PATCH] Add first model feature (constraint RHS) --- miplearn/features.py | 26 ++++++++++++++++ miplearn/instance.py | 5 +-- miplearn/problems/knapsack.py | 16 ++++++++-- miplearn/solvers/gurobi.py | 4 +++ miplearn/solvers/internal.py | 7 +++++ miplearn/solvers/learning.py | 7 ++++- miplearn/solvers/pyomo/base.py | 13 +++++++- miplearn/types.py | 8 +++++ tests/__init__.py | 23 -------------- tests/components/test_lazy_dynamic.py | 2 +- tests/components/test_objective.py | 3 +- tests/components/test_primal.py | 1 - tests/fixtures/knapsack.py | 45 +++++++++++++++++++++++++++ tests/solvers/__init__.py | 6 ++-- tests/solvers/test_internal_solver.py | 12 +++---- tests/solvers/test_learning_solver.py | 13 +++++--- tests/test_features.py | 23 ++++++++++++++ 17 files changed, 167 insertions(+), 47 deletions(-) create mode 100644 miplearn/features.py create mode 100644 tests/fixtures/knapsack.py create mode 100644 tests/test_features.py diff --git a/miplearn/features.py b/miplearn/features.py new file mode 100644 index 0000000..c18a2c2 --- /dev/null +++ b/miplearn/features.py @@ -0,0 +1,26 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from typing import TYPE_CHECKING + +from miplearn.types import ModelFeatures + +if TYPE_CHECKING: + from miplearn import InternalSolver + + +class ModelFeaturesExtractor: + def __init__( + self, + internal_solver: "InternalSolver", + ) -> None: + self.internal_solver = internal_solver + + def extract(self) -> ModelFeatures: + rhs = {} + for cid in self.internal_solver.get_constraint_ids(): + rhs[cid] = self.internal_solver.get_constraint_rhs(cid) + return { + "ConstraintRHS": rhs, + } diff --git a/miplearn/instance.py b/miplearn/instance.py index 36ca7b4..c30c2f1 100644 --- a/miplearn/instance.py +++ b/miplearn/instance.py @@ -9,7 +9,7 @@ from typing import Any, List, Optional, Hashable import numpy as np -from miplearn.types import TrainingSample, VarIndex +from miplearn.types import TrainingSample, VarIndex, ModelFeatures class Instance(ABC): @@ -24,8 +24,9 @@ class Instance(ABC): features, which can be provided as inputs to machine learning models. """ - def __init__(self): + def __init__(self) -> None: self.training_data: List[TrainingSample] = [] + self.model_features: ModelFeatures = {} @abstractmethod def to_model(self) -> Any: diff --git a/miplearn/problems/knapsack.py b/miplearn/problems/knapsack.py index 02e3282..e251322 100644 --- a/miplearn/problems/knapsack.py +++ b/miplearn/problems/knapsack.py @@ -1,6 +1,7 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. +from typing import List import numpy as np import pyomo.environ as pe @@ -24,7 +25,6 @@ class ChallengeA: n_training_instances=500, n_test_instances=50, ): - np.random.seed(seed) self.gen = MultiKnapsackGenerator( n=randint(low=250, high=251), @@ -241,7 +241,12 @@ class KnapsackInstance(Instance): Simpler (one-dimensional) Knapsack Problem, used for testing. """ - def __init__(self, weights, prices, capacity): + def __init__( + self, + weights: List[float], + prices: List[float], + capacity: float, + ) -> None: super().__init__() self.weights = weights self.prices = prices @@ -282,7 +287,12 @@ class GurobiKnapsackInstance(KnapsackInstance): instead of Pyomo, used for testing. """ - def __init__(self, weights, prices, capacity): + def __init__( + self, + weights: List[float], + prices: List[float], + capacity: float, + ) -> None: super().__init__(weights, prices, capacity) def to_model(self): diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index b6ab663..3a735d3 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -335,6 +335,10 @@ class GurobiSolver(InternalSolver): self.model.update() return [c.ConstrName for c in self.model.getConstrs()] + def get_constraint_rhs(self, cid: str) -> float: + assert self.model is not None + return self.model.getConstrByName(cid).rhs + def extract_constraint(self, cid): self._raise_if_callback() constr = self.model.getConstrByName(cid) diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index 89543aa..728f1be 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -155,6 +155,13 @@ class InternalSolver(ABC): """ pass + @abstractmethod + def get_constraint_rhs(self, cid: str) -> float: + """ + Returns the right-hand side of a given constraint. + """ + pass + @abstractmethod def add_constraint(self, cobj: Constraint) -> None: """ diff --git a/miplearn/solvers/learning.py b/miplearn/solvers/learning.py index 754bbd5..bf9830d 100644 --- a/miplearn/solvers/learning.py +++ b/miplearn/solvers/learning.py @@ -16,11 +16,12 @@ from miplearn.components.cuts import UserCutsComponent from miplearn.components.lazy_dynamic import DynamicLazyConstraintsComponent from miplearn.components.objective import ObjectiveValueComponent from miplearn.components.primal import PrimalSolutionComponent +from miplearn.features import ModelFeaturesExtractor from miplearn.instance import Instance from miplearn.solvers import _RedirectOutput from miplearn.solvers.internal import InternalSolver from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver -from miplearn.types import MIPSolveStats, TrainingSample, LearningSolveStats +from miplearn.types import TrainingSample, LearningSolveStats logger = logging.getLogger(__name__) @@ -164,6 +165,10 @@ class LearningSolver: assert isinstance(self.internal_solver, InternalSolver) self.internal_solver.set_instance(instance, model) + # Extract model features + extractor = ModelFeaturesExtractor(self.internal_solver) + instance.model_features = extractor.extract() + # Solve linear relaxation if self.solve_lp_first: logger.info("Solving LP relaxation...") diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index ebed5c0..de800be 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -212,7 +212,11 @@ class BasePyomoSolver(InternalSolver): assert self.model is not None self._cname_to_constr = {} for constr in self.model.component_objects(Constraint): - self._cname_to_constr[constr.name] = constr + if isinstance(constr, pe.ConstraintList): + for idx in constr: + self._cname_to_constr[f"{constr.name}[{idx}]"] = constr[idx] + else: + self._cname_to_constr[constr.name] = constr def fix(self, solution): count_total, count_fixed = 0, 0 @@ -302,6 +306,13 @@ class BasePyomoSolver(InternalSolver): else: return "=" + def get_constraint_rhs(self, cid: str) -> float: + cobj = self._cname_to_constr[cid] + if cobj.has_ub: + return cobj.upper() + else: + return cobj.lower() + def set_constraint_sense(self, cid: str, sense: str) -> None: raise Exception("Not implemented") diff --git a/miplearn/types.py b/miplearn/types.py index b89e649..8d58163 100644 --- a/miplearn/types.py +++ b/miplearn/types.py @@ -71,6 +71,14 @@ LearningSolveStats = TypedDict( total=False, ) +ModelFeatures = TypedDict( + "ModelFeatures", + { + "ConstraintRHS": Dict[str, float], + }, + total=False, +) + IterationCallback = Callable[[], bool] LazyCallback = Callable[[Any, Any], None] diff --git a/tests/__init__.py b/tests/__init__.py index 62e7b5a..13c148b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,26 +1,3 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - -from miplearn.problems.knapsack import KnapsackInstance -from miplearn.solvers.learning import LearningSolver - - -def get_test_pyomo_instances(): - instances = [ - KnapsackInstance( - weights=[23.0, 26.0, 20.0, 18.0], - prices=[505.0, 352.0, 458.0, 220.0], - capacity=67.0, - ), - KnapsackInstance( - weights=[25.0, 30.0, 22.0, 18.0], - prices=[500.0, 365.0, 420.0, 150.0], - capacity=70.0, - ), - ] - models = [instance.to_model() for instance in instances] - solver = LearningSolver() - for i in range(len(instances)): - solver.solve(instances[i], models[i]) - return instances, models diff --git a/tests/components/test_lazy_dynamic.py b/tests/components/test_lazy_dynamic.py index b2caf1d..426aae7 100644 --- a/tests/components/test_lazy_dynamic.py +++ b/tests/components/test_lazy_dynamic.py @@ -12,7 +12,7 @@ from miplearn.classifiers import Classifier from miplearn.components.lazy_dynamic import DynamicLazyConstraintsComponent from miplearn.solvers.internal import InternalSolver from miplearn.solvers.learning import LearningSolver -from .. import get_test_pyomo_instances +from tests.fixtures.knapsack import get_test_pyomo_instances E = 0.1 diff --git a/tests/components/test_objective.py b/tests/components/test_objective.py index 4e25494..0093df0 100644 --- a/tests/components/test_objective.py +++ b/tests/components/test_objective.py @@ -1,6 +1,7 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. + from typing import cast from unittest.mock import Mock @@ -10,7 +11,7 @@ from numpy.testing import assert_array_equal from miplearn.instance import Instance from miplearn.classifiers import Regressor from miplearn.components.objective import ObjectiveValueComponent -from .. import get_test_pyomo_instances +from tests.fixtures.knapsack import get_test_pyomo_instances def test_x_y_predict() -> None: diff --git a/tests/components/test_primal.py b/tests/components/test_primal.py index cdc882d..f48926e 100644 --- a/tests/components/test_primal.py +++ b/tests/components/test_primal.py @@ -11,7 +11,6 @@ from miplearn import Classifier from miplearn.classifiers.threshold import Threshold, MinPrecisionThreshold from miplearn.components.primal import PrimalSolutionComponent from miplearn.instance import Instance -from tests import get_test_pyomo_instances def test_x_y_fit() -> None: diff --git a/tests/fixtures/knapsack.py b/tests/fixtures/knapsack.py new file mode 100644 index 0000000..14e25ce --- /dev/null +++ b/tests/fixtures/knapsack.py @@ -0,0 +1,45 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from miplearn import BasePyomoSolver, GurobiSolver, InternalSolver, Instance +from miplearn.problems.knapsack import KnapsackInstance, GurobiKnapsackInstance +from miplearn.solvers.learning import LearningSolver +from tests.solvers import _is_subclass_or_instance + + +def get_test_pyomo_instances(): + instances = [ + KnapsackInstance( + weights=[23.0, 26.0, 20.0, 18.0], + prices=[505.0, 352.0, 458.0, 220.0], + capacity=67.0, + ), + KnapsackInstance( + weights=[25.0, 30.0, 22.0, 18.0], + prices=[500.0, 365.0, 420.0, 150.0], + capacity=70.0, + ), + ] + models = [instance.to_model() for instance in instances] + solver = LearningSolver() + for i in range(len(instances)): + solver.solve(instances[i], models[i]) + return instances, models + + +def get_knapsack_instance(solver: InternalSolver) -> Instance: + if _is_subclass_or_instance(solver, BasePyomoSolver): + return KnapsackInstance( + weights=[23.0, 26.0, 20.0, 18.0], + prices=[505.0, 352.0, 458.0, 220.0], + capacity=67.0, + ) + elif _is_subclass_or_instance(solver, GurobiSolver): + return GurobiKnapsackInstance( + weights=[23.0, 26.0, 20.0, 18.0], + prices=[505.0, 352.0, 458.0, 220.0], + capacity=67.0, + ) + else: + assert False diff --git a/tests/solvers/__init__.py b/tests/solvers/__init__.py index b1c4f0b..0b02fe4 100644 --- a/tests/solvers/__init__.py +++ b/tests/solvers/__init__.py @@ -3,7 +3,7 @@ # Released under the modified BSD license. See COPYING.md for more details. from inspect import isclass -from typing import List, Callable +from typing import List, Callable, Any from miplearn.problems.knapsack import KnapsackInstance, GurobiKnapsackInstance from miplearn.solvers.gurobi import GurobiSolver @@ -13,7 +13,7 @@ from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver from miplearn.solvers.pyomo.xpress import XpressPyomoSolver -def _is_subclass_or_instance(obj, parent_class): +def _is_subclass_or_instance(obj: Any, parent_class: Any) -> bool: return isinstance(obj, parent_class) or ( isclass(obj) and issubclass(obj, parent_class) ) @@ -35,5 +35,5 @@ def _get_knapsack_instance(solver): assert False -def _get_internal_solvers() -> List[Callable[[], InternalSolver]]: +def get_internal_solvers() -> List[Callable[[], InternalSolver]]: return [GurobiPyomoSolver, GurobiSolver, XpressPyomoSolver] diff --git a/tests/solvers/test_internal_solver.py b/tests/solvers/test_internal_solver.py index b34942d..7171d53 100644 --- a/tests/solvers/test_internal_solver.py +++ b/tests/solvers/test_internal_solver.py @@ -13,7 +13,7 @@ from miplearn.solvers.gurobi import GurobiSolver from miplearn.solvers.pyomo.base import BasePyomoSolver from . import ( _get_knapsack_instance, - _get_internal_solvers, + get_internal_solvers, ) from ..fixtures.infeasible import get_infeasible_instance @@ -32,7 +32,7 @@ def test_redirect_output(): def test_internal_solver_warm_starts(): - for solver_class in _get_internal_solvers(): + for solver_class in get_internal_solvers(): logger.info("Solver: %s" % solver_class) instance = _get_knapsack_instance(solver_class) model = instance.to_model() @@ -83,7 +83,7 @@ def test_internal_solver_warm_starts(): def test_internal_solver(): - for solver_class in _get_internal_solvers(): + for solver_class in get_internal_solvers(): logger.info("Solver: %s" % solver_class) instance = _get_knapsack_instance(solver_class) @@ -175,7 +175,7 @@ def test_internal_solver(): def test_relax(): - for solver_class in _get_internal_solvers(): + for solver_class in get_internal_solvers(): instance = _get_knapsack_instance(solver_class) solver = solver_class() solver.set_instance(instance) @@ -185,7 +185,7 @@ def test_relax(): def test_infeasible_instance(): - for solver_class in _get_internal_solvers(): + for solver_class in get_internal_solvers(): instance = get_infeasible_instance(solver_class) solver = solver_class() solver.set_instance(instance) @@ -203,7 +203,7 @@ def test_infeasible_instance(): def test_iteration_cb(): - for solver_class in _get_internal_solvers(): + for solver_class in get_internal_solvers(): logger.info("Solver: %s" % solver_class) instance = _get_knapsack_instance(solver_class) solver = solver_class() diff --git a/tests/solvers/test_learning_solver.py b/tests/solvers/test_learning_solver.py index 5c736f1..4c2f932 100644 --- a/tests/solvers/test_learning_solver.py +++ b/tests/solvers/test_learning_solver.py @@ -10,14 +10,14 @@ import os from miplearn.solvers.gurobi import GurobiSolver from miplearn.solvers.learning import LearningSolver -from . import _get_knapsack_instance, _get_internal_solvers +from . import _get_knapsack_instance, get_internal_solvers logger = logging.getLogger(__name__) def test_learning_solver(): for mode in ["exact", "heuristic"]: - for internal_solver in _get_internal_solvers(): + for internal_solver in get_internal_solvers(): logger.info("Solver: %s" % internal_solver) instance = _get_knapsack_instance(internal_solver) solver = LearningSolver( @@ -26,6 +26,9 @@ def test_learning_solver(): ) solver.solve(instance) + + assert hasattr(instance, "model_features") + data = instance.training_data[0] assert data["Solution"]["x"][0] == 1.0 assert data["Solution"]["x"][1] == 0.0 @@ -49,7 +52,7 @@ def test_learning_solver(): def test_solve_without_lp(): - for internal_solver in _get_internal_solvers(): + for internal_solver in get_internal_solvers(): logger.info("Solver: %s" % internal_solver) instance = _get_knapsack_instance(internal_solver) solver = LearningSolver( @@ -62,7 +65,7 @@ def test_solve_without_lp(): def test_parallel_solve(): - for internal_solver in _get_internal_solvers(): + for internal_solver in get_internal_solvers(): instances = [_get_knapsack_instance(internal_solver) for _ in range(10)] solver = LearningSolver(solver=internal_solver) results = solver.parallel_solve(instances, n_jobs=3) @@ -73,7 +76,7 @@ def test_parallel_solve(): def test_solve_fit_from_disk(): - for internal_solver in _get_internal_solvers(): + for internal_solver in get_internal_solvers(): # Create instances and pickle them filenames = [] for k in range(3): diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..22c4b85 --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,23 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from miplearn.features import ModelFeaturesExtractor +from tests.fixtures.knapsack import get_knapsack_instance +from tests.solvers import get_internal_solvers + + +def test_knapsack() -> None: + for solver_factory in get_internal_solvers(): + # Initialize model, instance and internal solver + solver = solver_factory() + instance = get_knapsack_instance(solver) + model = instance.to_model() + solver.set_instance(instance, model) + + # Extract all model features + extractor = ModelFeaturesExtractor(solver) + features = extractor.extract() + + # Test constraint features + print(solver, features) + assert features["ConstraintRHS"]["eq_capacity"] == 67.0