diff --git a/miplearn/components/static_lazy.py b/miplearn/components/static_lazy.py index 26c3bf6..141bbcb 100644 --- a/miplearn/components/static_lazy.py +++ b/miplearn/components/static_lazy.py @@ -88,10 +88,10 @@ class StaticLazyConstraintsComponent(Component): assert constraints is not None assert constraints.lazy is not None assert constraints.names is not None - selected = tuple( + selected = [ (constraints.lazy[i] and constraints.names[i] not in self.enforced_cids) for i in range(len(constraints.lazy)) - ) + ] n_removed = sum(selected) n_kept = sum(constraints.lazy) - n_removed self.pool = constraints[selected] @@ -174,7 +174,7 @@ class StaticLazyConstraintsComponent(Component): self.pool, tol=self.violation_tolerance, ) - is_violated = tuple(not i for i in is_satisfied) + is_violated = [not i for i in is_satisfied] violated_constraints = self.pool[is_violated] satisfied_constraints = self.pool[is_satisfied] self.pool = satisfied_constraints diff --git a/miplearn/features.py b/miplearn/features.py index 72746a7..853513d 100644 --- a/miplearn/features.py +++ b/miplearn/features.py @@ -78,18 +78,18 @@ class VariableFeatures: @dataclass class ConstraintFeatures: - basis_status: Optional[Tuple[str, ...]] = None - categories: Optional[Tuple[Optional[Hashable], ...]] = None - dual_values: Optional[Tuple[float, ...]] = None - names: Optional[Tuple[str, ...]] = None - lazy: Optional[Tuple[bool, ...]] = None - lhs: Optional[Tuple[Tuple[Tuple[str, float], ...], ...]] = None - rhs: Optional[Tuple[float, ...]] = None - sa_rhs_down: Optional[Tuple[float, ...]] = None - sa_rhs_up: Optional[Tuple[float, ...]] = None - senses: Optional[Tuple[str, ...]] = None - slacks: Optional[Tuple[float, ...]] = None - user_features: Optional[Tuple[Optional[Tuple[float, ...]], ...]] = None + basis_status: Optional[List[str]] = None + categories: Optional[List[Optional[Hashable]]] = None + dual_values: Optional[List[float]] = None + names: Optional[List[str]] = None + lazy: Optional[List[bool]] = None + lhs: Optional[List[List[Tuple[str, float]]]] = 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 + user_features: Optional[List[Optional[List[float]]]] = None def to_list(self, index: int) -> List[float]: features: List[float] = [] @@ -107,7 +107,7 @@ class ConstraintFeatures: _clip(features) return features - def __getitem__(self, selected: Tuple[bool, ...]) -> "ConstraintFeatures": + def __getitem__(self, selected: List[bool]) -> "ConstraintFeatures": return ConstraintFeatures( basis_status=self._filter(self.basis_status, selected), categories=self._filter(self.categories, selected), @@ -125,12 +125,12 @@ class ConstraintFeatures: def _filter( self, - obj: Optional[Tuple], - selected: Tuple[bool, ...], - ) -> Optional[Tuple]: + obj: Optional[List], + selected: List[bool], + ) -> Optional[List]: if obj is None: return None - return tuple(obj[i] for (i, selected_i) in enumerate(selected) if selected_i) + return [obj[i] for (i, selected_i) in enumerate(selected) if selected_i] @dataclass @@ -217,7 +217,7 @@ class FeaturesExtractor: if user_features_i is None: user_features.append(None) else: - user_features.append(user_features_i) + user_features.append(list(user_features_i)) features.variables.categories = categories features.variables.user_features = user_features @@ -229,7 +229,7 @@ class FeaturesExtractor: assert features.constraints is not None assert features.constraints.names is not None has_static_lazy = instance.has_static_lazy_constraints() - user_features: List[Optional[Tuple[float, ...]]] = [] + user_features: List[Optional[List[float]]] = [] categories: List[Optional[Hashable]] = [] lazy: List[bool] = [] for (cidx, cname) in enumerate(features.constraints.names): @@ -253,7 +253,7 @@ class FeaturesExtractor: f"Constraint features must be a list of numbers. " f"Found {type(f).__name__} instead for cname={cname}." ) - user_features.append(tuple(cf)) + user_features.append(list(cf)) else: user_features.append(None) categories.append(None) @@ -261,9 +261,9 @@ class FeaturesExtractor: lazy.append(instance.is_constraint_lazy(cname)) else: lazy.append(False) - features.constraints.user_features = tuple(user_features) - features.constraints.lazy = tuple(lazy) - features.constraints.categories = tuple(categories) + features.constraints.user_features = user_features + features.constraints.lazy = lazy + features.constraints.categories = categories def _extract_user_features_instance( self, diff --git a/miplearn/solvers/__init__.py b/miplearn/solvers/__init__.py index eb535da..e172895 100644 --- a/miplearn/solvers/__init__.py +++ b/miplearn/solvers/__init__.py @@ -36,3 +36,10 @@ class _RedirectOutput: ) -> None: sys.stdout = self._original_stdout sys.stderr = self._original_stderr + + +def _none_if_empty(obj: Any) -> Any: + if len(obj) == 0: + return None + else: + return obj diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 00a8dbd..616cff3 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -122,7 +122,7 @@ class GurobiSolver(InternalSolver): self, cf: ConstraintFeatures, tol: float = 1e-5, - ) -> Tuple[bool, ...]: + ) -> List[bool]: assert cf.names is not None assert cf.senses is not None assert cf.lhs is not None @@ -141,7 +141,7 @@ class GurobiSolver(InternalSolver): result.append(lhs >= cf.rhs[i] - tol) else: result.append(abs(cf.rhs[i] - lhs) <= tol) - return tuple(result) + return result @overrides def build_test_instance_infeasible(self) -> Instance: @@ -209,37 +209,37 @@ class GurobiSolver(InternalSolver): raise Exception(f"unknown cbasis: {v}") gp_constrs = model.getConstrs() - constr_names = tuple(model.getAttr("constrName", gp_constrs)) - rhs, lhs, senses, slacks, basis_status = None, None, None, None, None + constr_names = model.getAttr("constrName", gp_constrs) + lhs: Optional[List] = None + rhs, senses, slacks, basis_status = None, None, None, None dual_value, basis_status, sa_rhs_up, sa_rhs_down = None, None, None, None if with_static: - rhs = tuple(model.getAttr("rhs", gp_constrs)) - senses = tuple(model.getAttr("sense", gp_constrs)) + rhs = model.getAttr("rhs", gp_constrs) + senses = model.getAttr("sense", gp_constrs) if with_lhs: - lhs_l: List = [None for _ in gp_constrs] + lhs = [None for _ in gp_constrs] for (i, gp_constr) in enumerate(gp_constrs): expr = model.getRow(gp_constr) - lhs_l[i] = tuple( + lhs[i] = [ (self._var_names[expr.getVar(j).index], expr.getCoeff(j)) for j in range(expr.size()) - ) - lhs = tuple(lhs_l) + ] if self._has_lp_solution: - dual_value = tuple(model.getAttr("pi", gp_constrs)) - basis_status = tuple( + dual_value = model.getAttr("pi", gp_constrs) + basis_status = list( map( _parse_gurobi_cbasis, model.getAttr("cbasis", gp_constrs), ) ) if with_sa: - sa_rhs_up = tuple(model.getAttr("saRhsUp", gp_constrs)) - sa_rhs_down = tuple(model.getAttr("saRhsLow", gp_constrs)) + sa_rhs_up = model.getAttr("saRhsUp", gp_constrs) + sa_rhs_down = model.getAttr("saRhsLow", gp_constrs) if self._has_lp_solution or self._has_mip_solution: - slacks = tuple(model.getAttr("slack", gp_constrs)) + slacks = model.getAttr("slack", gp_constrs) return ConstraintFeatures( basis_status=basis_status, @@ -370,7 +370,7 @@ class GurobiSolver(InternalSolver): return self.model.status in [self.gp.GRB.INFEASIBLE, self.gp.GRB.INF_OR_UNBD] @overrides - def remove_constraints(self, names: Tuple[str, ...]) -> None: + def remove_constraints(self, names: List[str]) -> None: assert self.model is not None constrs = [self.model.getConstrByName(n) for n in names] self.model.remove(constrs) diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index da183b3..f689e44 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -5,7 +5,7 @@ import logging from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, List, Optional, Tuple +from typing import Any, List, Optional, List from miplearn.features import VariableFeatures, ConstraintFeatures from miplearn.instance.base import Instance @@ -59,7 +59,7 @@ class InternalSolver(ABC): self, cf: ConstraintFeatures, tol: float = 1e-5, - ) -> Tuple[bool, ...]: + ) -> List[bool]: """ Checks whether the current solution satisfies the given constraints. """ @@ -176,7 +176,7 @@ class InternalSolver(ABC): pass @abstractmethod - def remove_constraints(self, names: Tuple[str, ...]) -> None: + def remove_constraints(self, names: List[str]) -> None: """ Removes the given constraints from the model. """ diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index d26443a..c24d286 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -21,7 +21,7 @@ from pyomo.opt.base.solvers import SolverFactory from miplearn.features import VariableFeatures, ConstraintFeatures from miplearn.instance.base import Instance -from miplearn.solvers import _RedirectOutput +from miplearn.solvers import _RedirectOutput, _none_if_empty from miplearn.solvers.internal import ( InternalSolver, LPSolveStats, @@ -113,7 +113,7 @@ class BasePyomoSolver(InternalSolver): self, cf: ConstraintFeatures, tol: float = 1e-5, - ) -> Tuple[bool, ...]: + ) -> List[bool]: assert cf.names is not None assert cf.lhs is not None assert cf.rhs is not None @@ -130,7 +130,7 @@ class BasePyomoSolver(InternalSolver): result.append(lhs >= cf.rhs[i] - tol) else: result.append(abs(cf.rhs[i] - lhs) < tol) - return tuple(result) + return result @overrides def build_test_instance_infeasible(self) -> Instance: @@ -165,7 +165,7 @@ class BasePyomoSolver(InternalSolver): names: List[str] = [] rhs: List[float] = [] - lhs: List[Tuple[Tuple[str, float], ...]] = [] + lhs: List[List[Tuple[str, float]]] = [] senses: List[str] = [] dual_values: List[float] = [] slacks: List[float] = [] @@ -214,7 +214,7 @@ class BasePyomoSolver(InternalSolver): raise Exception( f"Unknown expression type: {expr.__class__.__name__}" ) - lhs.append(tuple(lhsc)) + lhs.append(lhsc) # Extract dual values if self._has_lp_solution: @@ -233,24 +233,13 @@ class BasePyomoSolver(InternalSolver): names.append(constr.name) _parse_constraint(constr) - rhs_t, lhs_t, senses_t = None, None, None - slacks_t, dual_values_t = None, None - if with_static: - rhs_t = tuple(rhs) - lhs_t = tuple(lhs) - senses_t = tuple(senses) - if self._has_lp_solution: - dual_values_t = tuple(dual_values) - if self._has_lp_solution or self._has_mip_solution: - slacks_t = tuple(slacks) - return ConstraintFeatures( - names=tuple(names), - rhs=rhs_t, - senses=senses_t, - lhs=lhs_t, - slacks=slacks_t, - dual_values=dual_values_t, + names=_none_if_empty(names), + rhs=_none_if_empty(rhs), + senses=_none_if_empty(senses), + lhs=_none_if_empty(lhs), + slacks=_none_if_empty(slacks), + dual_values=_none_if_empty(dual_values), ) @overrides @@ -337,12 +326,6 @@ class BasePyomoSolver(InternalSolver): if self._has_lp_solution or self._has_mip_solution: values.append(v.value) - def _none_if_empty(obj: Any) -> Any: - if len(obj) == 0: - return None - else: - return obj - return VariableFeatures( names=_none_if_empty(names), types=_none_if_empty(types), @@ -379,7 +362,7 @@ class BasePyomoSolver(InternalSolver): return self._termination_condition == TerminationCondition.infeasible @overrides - def remove_constraints(self, names: Tuple[str, ...]) -> None: + def remove_constraints(self, names: List[str]) -> None: assert self.model is not None for name in names: constr = self._cname_to_constr[name] diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index fb18508..b9540c4 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -69,18 +69,18 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: assert_equals( solver.get_constraints(), ConstraintFeatures( - names=("eq_capacity",), - rhs=(0.0,), - lhs=( - ( + names=["eq_capacity"], + rhs=[0.0], + lhs=[ + [ ("x[0]", 23.0), ("x[1]", 26.0), ("x[2]", 20.0), ("x[3]", 18.0), ("z", -1.0), - ), - ), - senses=("=",), + ], + ], + senses=["="], ), ) @@ -120,12 +120,12 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: _filter_attrs( solver.get_constraint_attrs(), ConstraintFeatures( - basis_status=("N",), - dual_values=(13.538462,), - names=("eq_capacity",), - sa_rhs_down=(-24.0,), - sa_rhs_up=(2.0,), - slacks=(0.0,), + basis_status=["N"], + dual_values=[13.538462], + names=["eq_capacity"], + sa_rhs_down=[-24.0], + sa_rhs_up=[2.0], + slacks=[0.0], ), ), ) @@ -165,20 +165,20 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: _filter_attrs( solver.get_constraint_attrs(), ConstraintFeatures( - names=("eq_capacity",), - slacks=(0.0,), + names=["eq_capacity"], + slacks=[0.0], ), ), ) # Build new constraint and verify that it is violated cf = ConstraintFeatures( - names=("cut",), - lhs=((("x[0]", 1.0),),), - rhs=(0.0,), - senses=("<",), + names=["cut"], + lhs=[[("x[0]", 1.0)]], + rhs=[0.0], + senses=["<"], ) - assert_equals(solver.are_constraints_satisfied(cf), (False,)) + assert_equals(solver.are_constraints_satisfied(cf), [False]) # Add constraint and verify it affects solution solver.add_constraints(cf) @@ -187,28 +187,30 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: _filter_attrs( solver.get_constraint_attrs(), ConstraintFeatures( - names=("eq_capacity", "cut"), - rhs=(0.0, 0.0), - lhs=( - ( + names=["eq_capacity", "cut"], + rhs=[0.0, 0.0], + lhs=[ + [ ("x[0]", 23.0), ("x[1]", 26.0), ("x[2]", 20.0), ("x[3]", 18.0), ("z", -1.0), - ), - (("x[0]", 1.0),), - ), - senses=("=", "<"), + ], + [ + ("x[0]", 1.0), + ], + ], + senses=["=", "<"], ), ), ) stats = solver.solve() assert_equals(stats.mip_lower_bound, 1030.0) - assert_equals(solver.are_constraints_satisfied(cf), (True,)) + assert_equals(solver.are_constraints_satisfied(cf), [True]) # Remove the new constraint - solver.remove_constraints(("cut",)) + solver.remove_constraints(["cut"]) # New constraint should no longer affect solution stats = solver.solve() diff --git a/tests/components/test_static_lazy.py b/tests/components/test_static_lazy.py index 3231bd8..be839de 100644 --- a/tests/components/test_static_lazy.py +++ b/tests/components/test_static_lazy.py @@ -33,20 +33,20 @@ def sample() -> Sample: lazy_constraint_count=4, ), constraints=ConstraintFeatures( - names=("c1", "c2", "c3", "c4", "c5"), - categories=( + names=["c1", "c2", "c3", "c4", "c5"], + categories=[ "type-a", "type-a", "type-a", "type-b", "type-b", - ), - lazy=(True, True, True, True, False), + ], + lazy=[True, True, True, True, False], ), ), after_lp=Features( instance=InstanceFeatures(), - constraints=ConstraintFeatures(names=("c1", "c2", "c3", "c4", "c5")), + constraints=ConstraintFeatures(names=["c1", "c2", "c3", "c4", "c5"]), ), after_mip=Features( extra={ @@ -132,7 +132,7 @@ def test_usage_with_solver(instance: Instance) -> None: # Should ask internal solver to remove some constraints assert internal.remove_constraints.call_count == 1 - internal.remove_constraints.assert_has_calls([call(("c3",))]) + internal.remove_constraints.assert_has_calls([call(["c3"])]) # LearningSolver calls after_iteration (first time) should_repeat = component.iteration_cb(solver, instance, None) @@ -141,7 +141,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 assert sample.after_load.constraints is not None - c = sample.after_load.constraints[False, False, True, False, False] + c = sample.after_load.constraints[[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 5d6739c..5d2cf2d 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -65,26 +65,26 @@ def test_knapsack() -> None: assert_equals( _round(features.constraints), ConstraintFeatures( - basis_status=("N",), - categories=("eq_capacity",), - dual_values=(13.538462,), - names=("eq_capacity",), - lazy=(False,), - lhs=( - ( + basis_status=["N"], + categories=["eq_capacity"], + dual_values=[13.538462], + names=["eq_capacity"], + lazy=[False], + lhs=[ + [ ("x[0]", 23.0), ("x[1]", 26.0), ("x[2]", 20.0), ("x[3]", 18.0), ("z", -1.0), - ), - ), - rhs=(0.0,), - sa_rhs_down=(-24.0,), - sa_rhs_up=(2.0,), - senses=("=",), - slacks=(0.0,), - user_features=((0.0,),), + ], + ], + rhs=[0.0], + sa_rhs_down=[-24.0], + sa_rhs_up=[2.0], + senses=["="], + slacks=[0.0], + user_features=[[0.0]], ), ) assert_equals( @@ -98,39 +98,39 @@ def test_knapsack() -> None: def test_constraint_getindex() -> None: cf = ConstraintFeatures( - names=("c1", "c2", "c3"), - rhs=(1.0, 2.0, 3.0), - senses=("=", "<", ">"), - lhs=( - ( + names=["c1", "c2", "c3"], + rhs=[1.0, 2.0, 3.0], + senses=["=", "<", ">"], + lhs=[ + [ ("x1", 1.0), ("x2", 1.0), - ), - ( + ], + [ ("x2", 2.0), ("x3", 2.0), - ), - ( + ], + [ ("x3", 3.0), ("x4", 3.0), - ), - ), + ], + ], ) assert_equals( - cf[True, False, True], + cf[[True, False, True]], ConstraintFeatures( - names=("c1", "c3"), - rhs=(1.0, 3.0), - senses=("=", ">"), - lhs=( - ( + names=["c1", "c3"], + rhs=[1.0, 3.0], + senses=["=", ">"], + lhs=[ + [ ("x1", 1.0), ("x2", 1.0), - ), - ( + ], + [ ("x3", 3.0), ("x4", 3.0), - ), - ), + ], + ], ), )