diff --git a/miplearn/components/static_lazy.py b/miplearn/components/static_lazy.py index 5da2634..c27f0ee 100644 --- a/miplearn/components/static_lazy.py +++ b/miplearn/components/static_lazy.py @@ -12,7 +12,8 @@ from miplearn.classifiers import Classifier from miplearn.classifiers.counting import CountingClassifier from miplearn.classifiers.threshold import MinProbabilityThreshold, Threshold from miplearn.components.component import Component -from miplearn.features import Sample, ConstraintFeatures +from miplearn.features import Sample +from miplearn.solvers.internal import Constraints from miplearn.instance.base import Instance from miplearn.types import LearningSolveStats @@ -45,7 +46,7 @@ class StaticLazyConstraintsComponent(Component): self.threshold_prototype: Threshold = threshold self.classifiers: Dict[Hashable, Classifier] = {} self.thresholds: Dict[Hashable, Threshold] = {} - self.pool: ConstraintFeatures = ConstraintFeatures() + self.pool: Constraints = Constraints() self.violation_tolerance: float = violation_tolerance self.enforced_cids: Set[Hashable] = set() self.n_restored: int = 0 @@ -82,7 +83,7 @@ class StaticLazyConstraintsComponent(Component): logger.info("Instance does not have static lazy constraints. Skipping.") self.enforced_cids = set(self.sample_predict(sample)) logger.info("Moving lazy constraints to the pool...") - constraints = ConstraintFeatures.from_sample(sample) + constraints = Constraints.from_sample(sample) assert constraints.lazy is not None assert constraints.names is not None selected = [ diff --git a/miplearn/features.py b/miplearn/features.py index 1927d9c..158c73c 100644 --- a/miplearn/features.py +++ b/miplearn/features.py @@ -4,9 +4,8 @@ import collections import numbers -from dataclasses import dataclass from math import log, isfinite -from typing import TYPE_CHECKING, Dict, Optional, List, Hashable, Tuple, Any +from typing import TYPE_CHECKING, Dict, Optional, List, Hashable, Any import numpy as np @@ -15,76 +14,6 @@ if TYPE_CHECKING: from miplearn.instance.base import Instance -@dataclass -class VariableFeatures: - names: Optional[List[str]] = None - basis_status: Optional[List[str]] = None - lower_bounds: Optional[List[float]] = None - obj_coeffs: Optional[List[float]] = None - reduced_costs: Optional[List[float]] = None - sa_lb_down: Optional[List[float]] = None - sa_lb_up: Optional[List[float]] = None - sa_obj_down: Optional[List[float]] = None - sa_obj_up: Optional[List[float]] = None - sa_ub_down: Optional[List[float]] = None - sa_ub_up: Optional[List[float]] = None - types: Optional[List[str]] = None - upper_bounds: Optional[List[float]] = None - values: Optional[List[float]] = None - - -@dataclass -class ConstraintFeatures: - basis_status: Optional[List[str]] = None - dual_values: Optional[List[float]] = None - lazy: Optional[List[bool]] = None - lhs: Optional[List[List[Tuple[str, float]]]] = None - names: Optional[List[str]] = None - rhs: Optional[List[float]] = None - sa_rhs_down: Optional[List[float]] = None - sa_rhs_up: Optional[List[float]] = None - senses: Optional[List[str]] = None - slacks: Optional[List[float]] = None - - @staticmethod - def from_sample(sample: "Sample") -> "ConstraintFeatures": - return ConstraintFeatures( - basis_status=sample.get("lp_constr_basis_status"), - dual_values=sample.get("lp_constr_dual_values"), - lazy=sample.get("constr_lazy"), - lhs=sample.get("constr_lhs"), - names=sample.get("constr_names"), - rhs=sample.get("constr_rhs"), - sa_rhs_down=sample.get("lp_constr_sa_rhs_down"), - sa_rhs_up=sample.get("lp_constr_sa_rhs_up"), - senses=sample.get("constr_senses"), - slacks=sample.get("lp_constr_slacks"), - ) - - def __getitem__(self, selected: List[bool]) -> "ConstraintFeatures": - return ConstraintFeatures( - basis_status=self._filter(self.basis_status, selected), - dual_values=self._filter(self.dual_values, selected), - names=self._filter(self.names, selected), - lazy=self._filter(self.lazy, selected), - lhs=self._filter(self.lhs, selected), - rhs=self._filter(self.rhs, selected), - sa_rhs_down=self._filter(self.sa_rhs_down, selected), - sa_rhs_up=self._filter(self.sa_rhs_up, selected), - senses=self._filter(self.senses, selected), - slacks=self._filter(self.slacks, selected), - ) - - def _filter( - self, - obj: Optional[List], - selected: List[bool], - ) -> Optional[List]: - if obj is None: - return None - return [obj[i] for (i, selected_i) in enumerate(selected) if selected_i] - - class Sample: def __init__( self, diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index edfa182..52968cc 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -10,7 +10,6 @@ from typing import List, Any, Dict, Optional, Hashable, Tuple, TYPE_CHECKING from overrides import overrides -from miplearn.features import VariableFeatures, ConstraintFeatures from miplearn.instance.base import Instance from miplearn.solvers import _RedirectOutput from miplearn.solvers.internal import ( @@ -19,6 +18,8 @@ from miplearn.solvers.internal import ( IterationCallback, LazyCallback, MIPSolveStats, + Variables, + Constraints, ) from miplearn.solvers.pyomo.base import PyomoTestInstanceKnapsack from miplearn.types import ( @@ -91,7 +92,7 @@ class GurobiSolver(InternalSolver): ] @overrides - def add_constraints(self, cf: ConstraintFeatures) -> None: + def add_constraints(self, cf: Constraints) -> None: assert cf.names is not None assert cf.senses is not None assert cf.lhs is not None @@ -120,7 +121,7 @@ class GurobiSolver(InternalSolver): @overrides def are_constraints_satisfied( self, - cf: ConstraintFeatures, + cf: Constraints, tol: float = 1e-5, ) -> List[bool]: assert cf.names is not None @@ -196,7 +197,7 @@ class GurobiSolver(InternalSolver): with_static: bool = True, with_sa: bool = True, with_lhs: bool = True, - ) -> ConstraintFeatures: + ) -> Constraints: model = self.model assert model is not None assert model.numVars == len(self._gp_vars) @@ -241,7 +242,7 @@ class GurobiSolver(InternalSolver): if self._has_lp_solution or self._has_mip_solution: slacks = model.getAttr("slack", gp_constrs) - return ConstraintFeatures( + return Constraints( basis_status=basis_status, dual_values=dual_value, lhs=lhs, @@ -300,7 +301,7 @@ class GurobiSolver(InternalSolver): self, with_static: bool = True, with_sa: bool = True, - ) -> VariableFeatures: + ) -> Variables: model = self.model assert model is not None @@ -347,7 +348,7 @@ class GurobiSolver(InternalSolver): if model.solCount > 0: values = model.getAttr("x", self._gp_vars) - return VariableFeatures( + return Variables( names=self._var_names, upper_bounds=upper_bounds, lower_bounds=lower_bounds, diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index f689e44..1cdcfad 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -5,9 +5,8 @@ import logging from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, List, Optional, List +from typing import Any, Optional, List, Tuple, TYPE_CHECKING -from miplearn.features import VariableFeatures, ConstraintFeatures from miplearn.instance.base import Instance from miplearn.types import ( IterationCallback, @@ -18,6 +17,9 @@ from miplearn.types import ( logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from miplearn.features import Sample + @dataclass class LPSolveStats: @@ -44,20 +46,90 @@ class MIPSolveStats: mip_warm_start_value: Optional[float] = None +@dataclass +class Variables: + names: Optional[List[str]] = None + basis_status: Optional[List[str]] = None + lower_bounds: Optional[List[float]] = None + obj_coeffs: Optional[List[float]] = None + reduced_costs: Optional[List[float]] = None + sa_lb_down: Optional[List[float]] = None + sa_lb_up: Optional[List[float]] = None + sa_obj_down: Optional[List[float]] = None + sa_obj_up: Optional[List[float]] = None + sa_ub_down: Optional[List[float]] = None + sa_ub_up: Optional[List[float]] = None + types: Optional[List[str]] = None + upper_bounds: Optional[List[float]] = None + values: Optional[List[float]] = None + + +@dataclass +class Constraints: + basis_status: Optional[List[str]] = None + dual_values: Optional[List[float]] = None + lazy: Optional[List[bool]] = None + lhs: Optional[List[List[Tuple[str, float]]]] = None + names: Optional[List[str]] = None + rhs: Optional[List[float]] = None + sa_rhs_down: Optional[List[float]] = None + sa_rhs_up: Optional[List[float]] = None + senses: Optional[List[str]] = None + slacks: Optional[List[float]] = None + + @staticmethod + def from_sample(sample: "Sample") -> "Constraints": + return Constraints( + basis_status=sample.get("lp_constr_basis_status"), + dual_values=sample.get("lp_constr_dual_values"), + lazy=sample.get("constr_lazy"), + lhs=sample.get("constr_lhs"), + names=sample.get("constr_names"), + rhs=sample.get("constr_rhs"), + sa_rhs_down=sample.get("lp_constr_sa_rhs_down"), + sa_rhs_up=sample.get("lp_constr_sa_rhs_up"), + senses=sample.get("constr_senses"), + slacks=sample.get("lp_constr_slacks"), + ) + + def __getitem__(self, selected: List[bool]) -> "Constraints": + return Constraints( + basis_status=self._filter(self.basis_status, selected), + dual_values=self._filter(self.dual_values, selected), + names=self._filter(self.names, selected), + lazy=self._filter(self.lazy, selected), + lhs=self._filter(self.lhs, selected), + rhs=self._filter(self.rhs, selected), + sa_rhs_down=self._filter(self.sa_rhs_down, selected), + sa_rhs_up=self._filter(self.sa_rhs_up, selected), + senses=self._filter(self.senses, selected), + slacks=self._filter(self.slacks, selected), + ) + + def _filter( + self, + obj: Optional[List], + selected: List[bool], + ) -> Optional[List]: + if obj is None: + return None + return [obj[i] for (i, selected_i) in enumerate(selected) if selected_i] + + class InternalSolver(ABC): """ Abstract class representing the MIP solver used internally by LearningSolver. """ @abstractmethod - def add_constraints(self, cf: ConstraintFeatures) -> None: + def add_constraints(self, cf: Constraints) -> None: """Adds the given constraints to the model.""" pass @abstractmethod def are_constraints_satisfied( self, - cf: ConstraintFeatures, + cf: Constraints, tol: float = 1e-5, ) -> List[bool]: """ @@ -133,7 +205,7 @@ class InternalSolver(ABC): with_static: bool = True, with_sa: bool = True, with_lhs: bool = True, - ) -> ConstraintFeatures: + ) -> Constraints: pass @abstractmethod @@ -149,7 +221,7 @@ class InternalSolver(ABC): self, with_static: bool = True, with_sa: bool = True, - ) -> VariableFeatures: + ) -> Variables: """ Returns a description of the decision variables in the problem. diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index 5889976..46b044c 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -19,7 +19,6 @@ from pyomo.core.expr.numeric_expr import SumExpression, MonomialTermExpression from pyomo.opt import TerminationCondition from pyomo.opt.base.solvers import SolverFactory -from miplearn.features import VariableFeatures, ConstraintFeatures from miplearn.instance.base import Instance from miplearn.solvers import _RedirectOutput, _none_if_empty from miplearn.solvers.internal import ( @@ -28,6 +27,8 @@ from miplearn.solvers.internal import ( IterationCallback, LazyCallback, MIPSolveStats, + Variables, + Constraints, ) from miplearn.types import ( SolverParams, @@ -79,7 +80,7 @@ class BasePyomoSolver(InternalSolver): self._has_mip_solution = False @overrides - def add_constraints(self, cf: ConstraintFeatures) -> None: + def add_constraints(self, cf: Constraints) -> None: assert cf.names is not None assert cf.senses is not None assert cf.lhs is not None @@ -111,7 +112,7 @@ class BasePyomoSolver(InternalSolver): @overrides def are_constraints_satisfied( self, - cf: ConstraintFeatures, + cf: Constraints, tol: float = 1e-5, ) -> List[bool]: assert cf.names is not None @@ -159,7 +160,7 @@ class BasePyomoSolver(InternalSolver): with_static: bool = True, with_sa: bool = True, with_lhs: bool = True, - ) -> ConstraintFeatures: + ) -> Constraints: model = self.model assert model is not None @@ -233,7 +234,7 @@ class BasePyomoSolver(InternalSolver): names.append(constr.name) _parse_constraint(constr) - return ConstraintFeatures( + return Constraints( names=_none_if_empty(names), rhs=_none_if_empty(rhs), senses=_none_if_empty(senses), @@ -271,7 +272,7 @@ class BasePyomoSolver(InternalSolver): self, with_static: bool = True, with_sa: bool = True, - ) -> VariableFeatures: + ) -> Variables: assert self.model is not None names: List[str] = [] @@ -326,7 +327,7 @@ class BasePyomoSolver(InternalSolver): if self._has_lp_solution or self._has_mip_solution: values.append(v.value) - return VariableFeatures( + return Variables( names=_none_if_empty(names), types=_none_if_empty(types), upper_bounds=_none_if_empty(upper_bounds), diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index 75c79b4..4626b4d 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -6,8 +6,7 @@ from typing import Any, List import numpy as np -from miplearn.features import VariableFeatures, ConstraintFeatures -from miplearn.solvers.internal import InternalSolver +from miplearn.solvers.internal import InternalSolver, Variables, Constraints inf = float("inf") @@ -40,7 +39,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: # Fetch variables (after-load) assert_equals( solver.get_variables(), - VariableFeatures( + Variables( names=["x[0]", "x[1]", "x[2]", "x[3]", "z"], lower_bounds=[0.0, 0.0, 0.0, 0.0, 0.0], upper_bounds=[1.0, 1.0, 1.0, 1.0, 67.0], @@ -52,7 +51,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: # Fetch constraints (after-load) assert_equals( solver.get_constraints(), - ConstraintFeatures( + Constraints( names=["eq_capacity"], rhs=[0.0], lhs=[ @@ -83,7 +82,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: solver.get_variables(with_static=False), _filter_attrs( solver.get_variable_attrs(), - VariableFeatures( + Variables( names=["x[0]", "x[1]", "x[2]", "x[3]", "z"], basis_status=["U", "B", "U", "L", "U"], reduced_costs=[193.615385, 0.0, 187.230769, -23.692308, 13.538462], @@ -103,7 +102,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: solver.get_constraints(with_static=False), _filter_attrs( solver.get_constraint_attrs(), - ConstraintFeatures( + Constraints( basis_status=["N"], dual_values=[13.538462], names=["eq_capacity"], @@ -136,7 +135,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: solver.get_variables(with_static=False), _filter_attrs( solver.get_variable_attrs(), - VariableFeatures( + Variables( names=["x[0]", "x[1]", "x[2]", "x[3]", "z"], values=[1.0, 0.0, 1.0, 1.0, 61.0], ), @@ -148,7 +147,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: solver.get_constraints(with_static=False), _filter_attrs( solver.get_constraint_attrs(), - ConstraintFeatures( + Constraints( names=["eq_capacity"], slacks=[0.0], ), @@ -156,7 +155,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: ) # Build new constraint and verify that it is violated - cf = ConstraintFeatures( + cf = Constraints( names=["cut"], lhs=[[("x[0]", 1.0)]], rhs=[0.0], @@ -170,7 +169,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: solver.get_constraints(with_static=True), _filter_attrs( solver.get_constraint_attrs(), - ConstraintFeatures( + Constraints( names=["eq_capacity", "cut"], rhs=[0.0, 0.0], lhs=[ diff --git a/tests/components/test_static_lazy.py b/tests/components/test_static_lazy.py index c43e0bf..727df8d 100644 --- a/tests/components/test_static_lazy.py +++ b/tests/components/test_static_lazy.py @@ -11,9 +11,9 @@ from numpy.testing import assert_array_equal from miplearn.classifiers import Classifier from miplearn.classifiers.threshold import Threshold, MinProbabilityThreshold from miplearn.components.static_lazy import StaticLazyConstraintsComponent -from miplearn.features import Sample, ConstraintFeatures +from miplearn.features import Sample from miplearn.instance.base import Instance -from miplearn.solvers.internal import InternalSolver +from miplearn.solvers.internal import InternalSolver, Constraints from miplearn.solvers.learning import LearningSolver from miplearn.types import ( LearningSolveStats, @@ -118,7 +118,7 @@ def test_usage_with_solver(instance: Instance) -> None: # Should ask internal solver to verify if constraints in the pool are # satisfied and add the ones that are not - c = ConstraintFeatures.from_sample(sample)[[False, False, True, False, False]] + c = Constraints.from_sample(sample)[[False, False, True, False, False]] internal.are_constraints_satisfied.assert_called_once_with(c, tol=1.0) internal.are_constraints_satisfied.reset_mock() internal.add_constraints.assert_called_once_with(c) diff --git a/tests/test_features.py b/tests/test_features.py index 48947b4..2aa563c 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -6,10 +6,9 @@ import numpy as np from miplearn.features import ( FeaturesExtractor, - VariableFeatures, - ConstraintFeatures, Sample, ) +from miplearn.solvers.internal import Variables, Constraints from miplearn.solvers.gurobi import GurobiSolver from miplearn.solvers.tests import assert_equals @@ -129,7 +128,7 @@ def test_knapsack() -> None: def test_constraint_getindex() -> None: - cf = ConstraintFeatures( + cf = Constraints( names=["c1", "c2", "c3"], rhs=[1.0, 2.0, 3.0], senses=["=", "<", ">"], @@ -150,7 +149,7 @@ def test_constraint_getindex() -> None: ) assert_equals( cf[[True, False, True]], - ConstraintFeatures( + Constraints( names=["c1", "c3"], rhs=[1.0, 3.0], senses=["=", ">"], @@ -177,8 +176,8 @@ def test_assert_equals() -> None: np.array([[1.0, 2.0], [3.0, 4.0]]), ) assert_equals( - VariableFeatures(values=np.array([1.0, 2.0])), # type: ignore - VariableFeatures(values=np.array([1.0, 2.0])), # type: ignore + Variables(values=np.array([1.0, 2.0])), # type: ignore + Variables(values=np.array([1.0, 2.0])), # type: ignore ) assert_equals(np.array([True, True]), [True, True]) assert_equals((1.0,), (1.0,))