From 733c8299e0ec5830734c717295168274fae36e11 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 10 Apr 2021 18:29:38 -0500 Subject: [PATCH] Add more variable features --- miplearn/__init__.py | 2 +- miplearn/features.py | 23 +++- miplearn/solvers/gurobi.py | 71 +++++++++- miplearn/solvers/internal.py | 14 +- miplearn/solvers/pyomo/base.py | 35 +++++ miplearn/solvers/tests/__init__.py | 203 +++++++++++++++++++++++++---- tests/components/test_primal.py | 34 ++--- tests/test_features.py | 10 +- 8 files changed, 331 insertions(+), 61 deletions(-) diff --git a/miplearn/__init__.py b/miplearn/__init__.py index 5765c5f..3b966ed 100644 --- a/miplearn/__init__.py +++ b/miplearn/__init__.py @@ -16,7 +16,7 @@ from .components.static_lazy import StaticLazyConstraintsComponent from .features import ( Features, TrainingSample, - VariableFeatures, + Variable, InstanceFeatures, ) from .instance.base import Instance diff --git a/miplearn/features.py b/miplearn/features.py index 1bbe957..342aa0e 100644 --- a/miplearn/features.py +++ b/miplearn/features.py @@ -36,9 +36,22 @@ class InstanceFeatures: @dataclass -class VariableFeatures: +class Variable: + basis_status: Optional[str] = None category: Optional[Hashable] = None + lower_bound: Optional[float] = None + obj_coeff: Optional[float] = None + reduced_cost: Optional[float] = None + sa_lb_down: Optional[float] = None + sa_lb_up: Optional[float] = None + sa_obj_down: Optional[float] = None + sa_obj_up: Optional[float] = None + sa_ub_down: Optional[float] = None + sa_ub_up: Optional[float] = None + type: Optional[str] = None + upper_bound: Optional[float] = None user_features: Optional[List[float]] = None + value: Optional[float] = None @dataclass @@ -59,7 +72,7 @@ class Constraint: @dataclass class Features: instance: Optional[InstanceFeatures] = None - variables: Optional[Dict[str, VariableFeatures]] = None + variables: Optional[Dict[str, Variable]] = None constraints: Optional[Dict[str, Constraint]] = None @@ -78,8 +91,8 @@ class FeaturesExtractor: def _extract_variables( self, instance: "Instance", - ) -> Dict[VariableName, VariableFeatures]: - result: Dict[VariableName, VariableFeatures] = {} + ) -> Dict[VariableName, Variable]: + result: Dict[VariableName, Variable] = {} for var_name in self.solver.get_variable_names(): user_features: Optional[List[float]] = None category: Category = instance.get_variable_category(var_name) @@ -102,7 +115,7 @@ class FeaturesExtractor: f"Found {type(v).__name__} instead " f"for var={var_name}." ) - result[var_name] = VariableFeatures( + result[var_name] = Variable( category=category, user_features=user_features, ) diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 500f26b..0b4c969 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -10,7 +10,7 @@ from typing import List, Any, Dict, Optional, Hashable from overrides import overrides -from miplearn.features import Constraint +from miplearn.features import Constraint, Variable from miplearn.instance.base import Instance from miplearn.solvers import _RedirectOutput from miplearn.solvers.internal import ( @@ -415,6 +415,49 @@ class GurobiSolver(InternalSolver): capacity=67.0, ) + @overrides + def get_variables(self) -> Dict[str, Variable]: + assert self.model is not None + variables = {} + for gp_var in self.model.getVars(): + name = gp_var.varName + assert len(name) > 0, f"empty variable name detected" + assert name not in variables, f"duplicated variable name detected: {name}" + var = self._parse_gurobi_var(gp_var) + variables[name] = var + return variables + + def _parse_gurobi_var(self, gp_var: Any) -> Variable: + assert self.model is not None + var = Variable() + var.lower_bound = gp_var.lb + var.upper_bound = gp_var.ub + var.obj_coeff = gp_var.obj + var.type = gp_var.vtype + + if self._has_lp_solution: + var.reduced_cost = gp_var.rc + var.sa_obj_up = gp_var.saobjUp + var.sa_obj_down = gp_var.saobjLow + var.sa_ub_up = gp_var.saubUp + var.sa_ub_down = gp_var.saubLow + var.sa_lb_up = gp_var.salbUp + var.sa_lb_down = gp_var.salbLow + vbasis = gp_var.vbasis + if vbasis == 0: + var.basis_status = "B" + elif vbasis == -1: + var.basis_status = "L" + elif vbasis == -2: + var.basis_status = "U" + elif vbasis == -3: + var.basis_status = "S" + else: + raise Exception(f"unknown vbasis: {vbasis}") + if self._has_lp_solution or self._has_mip_solution: + var.value = gp_var.x + return var + @overrides def get_constraints(self) -> Dict[str, Constraint]: assert self.model is not None @@ -443,11 +486,11 @@ class GurobiSolver(InternalSolver): if self._has_lp_solution: constr.dual_value = gp_constr.pi constr.sa_rhs_up = gp_constr.sarhsup - constr.sa_rhs_low = gp_constr.sarhslow + constr.sa_rhs_down = gp_constr.sarhslow if gp_constr.cbasis == 0: - constr.basis_status = "b" + constr.basis_status = "B" elif gp_constr.cbasis == -1: - constr.basis_status = "n" + constr.basis_status = "N" else: raise Exception(f"unknown cbasis: {gp_constr.cbasis}") if self._has_lp_solution or self._has_mip_solution: @@ -474,6 +517,26 @@ class GurobiSolver(InternalSolver): "user_features", ] + @overrides + def get_variable_attrs(self) -> List[str]: + return [ + "basis_status", + "category", + "lower_bound", + "obj_coeff", + "reduced_cost", + "sa_lb_down", + "sa_lb_up", + "sa_obj_down", + "sa_obj_up", + "sa_ub_down", + "sa_ub_up", + "type", + "upper_bound", + "user_features", + "value", + ] + class GurobiTestInstanceInfeasible(Instance): @overrides diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index c4589b2..69e9960 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional from overrides import EnforceOverrides -from miplearn.features import Constraint +from miplearn.features import Constraint, Variable from miplearn.instance.base import Instance from miplearn.types import ( LPSolveStats, @@ -247,9 +247,21 @@ class InternalSolver(ABC, EnforceOverrides): """ return False + @abstractmethod + def get_variables(self) -> Dict[str, Variable]: + pass + @abstractmethod def get_constraint_attrs(self) -> List[str]: """ Returns a list of constraint attributes supported by this solver. """ + + pass + + @abstractmethod + def get_variable_attrs(self) -> List[str]: + """ + Returns a list of variable attributes supported by this solver. + """ pass diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index e472f67..a85b6e0 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -19,6 +19,7 @@ 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 Variable from miplearn.instance.base import Instance from miplearn.solvers import _RedirectOutput from miplearn.solvers.internal import ( @@ -363,6 +364,19 @@ class BasePyomoSolver(InternalSolver): capacity=67.0, ) + @overrides + def get_variables(self) -> Dict[str, Variable]: + assert self.model is not None + variables = {} + for var in self.model.component_objects(pyomo.core.Var): + for idx in var: + varname = f"{var}[{idx}]" + variables[varname] = self._parse_pyomo_variable(var[idx]) + return variables + + def _parse_pyomo_variable(self, var: pyomo.core.Var) -> Variable: + return Variable() + @overrides def get_constraints(self) -> Dict[str, Constraint]: assert self.model is not None @@ -385,6 +399,7 @@ class BasePyomoSolver(InternalSolver): self, pyomo_constr: pyomo.core.Constraint, ) -> Constraint: + assert self.model is not None constr = Constraint() # Extract RHS and sense @@ -448,6 +463,26 @@ class BasePyomoSolver(InternalSolver): "user_features", ] + @overrides + def get_variable_attrs(self) -> List[str]: + return [ + # "basis_status", + # "category", + # "lower_bound", + # "obj_coeff", + # "reduced_cost", + # "sa_lb_down", + # "sa_lb_up", + # "sa_obj_down", + # "sa_obj_up", + # "sa_ub_down", + # "sa_ub_up", + # "type", + # "upper_bound", + # "user_features", + # "value", + ] + class PyomoTestInstanceInfeasible(Instance): @overrides diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index 083de11..70025bd 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -4,9 +4,10 @@ from typing import Any, Dict -from miplearn.features import Constraint +from miplearn.features import Constraint, Variable from miplearn.solvers.internal import InternalSolver +inf = float("inf") # NOTE: # This file is in the main source folder, so that it can be called from Julia. @@ -20,10 +21,30 @@ def _round_constraints(constraints: Dict[str, Constraint]) -> Dict[str, Constrai return constraints +def _round_variables(vars: Dict[str, Variable]) -> Dict[str, Variable]: + for (cname, c) in vars.items(): + for attr in [ + "upper_bound", + "lower_bound", + "obj_coeff", + "value", + "reduced_cost", + "sa_obj_up", + "sa_obj_down", + "sa_ub_up", + "sa_ub_down", + "sa_lb_up", + "sa_lb_down", + ]: + if getattr(c, attr) is not None: + setattr(c, attr, round(getattr(c, attr), 6)) + return vars + + def _remove_unsupported_constr_attrs( solver: InternalSolver, constraints: Dict[str, Constraint], -): +) -> Dict[str, Constraint]: for (cname, c) in constraints.items(): to_remove = [] for k in c.__dict__.keys(): @@ -34,6 +55,20 @@ def _remove_unsupported_constr_attrs( return constraints +def _remove_unsupported_var_attrs( + solver: InternalSolver, + variables: Dict[str, Variable], +) -> Dict[str, Variable]: + for (cname, c) in variables.items(): + to_remove = [] + for k in c.__dict__.keys(): + if k not in solver.get_variable_attrs(): + to_remove.append(k) + for k in to_remove: + setattr(c, k, None) + return variables + + def run_internal_solver_tests(solver: InternalSolver) -> None: run_basic_usage_tests(solver.clone()) run_warm_start_tests(solver.clone()) @@ -51,8 +86,36 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: # Fetch variables (after-load) assert_equals( - solver.get_variable_names(), - ["x[0]", "x[1]", "x[2]", "x[3]"], + _round_variables(solver.get_variables()), + _remove_unsupported_var_attrs( + solver, + { + "x[0]": Variable( + lower_bound=0.0, + obj_coeff=505.0, + type="B", + upper_bound=1.0, + ), + "x[1]": Variable( + lower_bound=0.0, + obj_coeff=352.0, + type="B", + upper_bound=1.0, + ), + "x[2]": Variable( + lower_bound=0.0, + obj_coeff=458.0, + type="B", + upper_bound=1.0, + ), + "x[3]": Variable( + lower_bound=0.0, + obj_coeff=220.0, + type="B", + upper_bound=1.0, + ), + }, + ), ) # Fetch constraints (after-load) @@ -75,17 +138,75 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: assert_equals(round(lp_stats["LP value"], 3), 1287.923) assert len(lp_stats["LP log"]) > 100 - # Fetch variables (after-lp) - solution = solver.get_solution() - assert solution is not None - assert solution["x[0]"] is not None - assert solution["x[1]"] is not None - assert solution["x[2]"] is not None - assert solution["x[3]"] is not None - assert_equals(round(solution["x[0]"], 3), 1.000) - assert_equals(round(solution["x[1]"], 3), 0.923) - assert_equals(round(solution["x[2]"], 3), 1.000) - assert_equals(round(solution["x[3]"], 3), 0.000) + # Fetch variables (after-load) + assert_equals( + _round_variables(solver.get_variables()), + _remove_unsupported_var_attrs( + solver, + { + "x[0]": Variable( + basis_status="U", + lower_bound=0.0, + obj_coeff=505.0, + reduced_cost=193.615385, + sa_lb_down=-inf, + sa_lb_up=1.0, + sa_obj_down=311.384615, + sa_obj_up=inf, + sa_ub_down=0.913043, + sa_ub_up=2.043478, + type="C", + upper_bound=1.0, + value=1.0, + ), + "x[1]": Variable( + basis_status="B", + lower_bound=0.0, + obj_coeff=352.0, + reduced_cost=0.0, + sa_lb_down=-inf, + sa_lb_up=0.923077, + sa_obj_down=317.777778, + sa_obj_up=570.869565, + sa_ub_down=0.923077, + sa_ub_up=inf, + type="C", + upper_bound=1.0, + value=0.923077, + ), + "x[2]": Variable( + basis_status="U", + lower_bound=0.0, + obj_coeff=458.0, + reduced_cost=187.230769, + sa_lb_down=-inf, + sa_lb_up=1.0, + sa_obj_down=270.769231, + sa_obj_up=inf, + sa_ub_down=0.9, + sa_ub_up=2.2, + type="C", + upper_bound=1.0, + value=1.0, + ), + "x[3]": Variable( + basis_status="L", + lower_bound=0.0, + obj_coeff=220.0, + reduced_cost=-23.692308, + sa_lb_down=-0.111111, + sa_lb_up=1.0, + sa_obj_down=-inf, + sa_obj_up=243.692308, + sa_ub_down=0.0, + sa_ub_up=inf, + type="C", + upper_bound=1.0, + value=0.0, + ), + }, + ), + ) # Fetch constraints (after-lp) assert_equals( @@ -100,9 +221,9 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: sense="<", slack=0.0, dual_value=13.538462, - sa_rhs_down=None, + sa_rhs_down=43.0, sa_rhs_up=69.0, - basis_status="n", + basis_status="N", ) }, ), @@ -122,17 +243,43 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: assert_equals(mip_stats["Sense"], "max") assert isinstance(mip_stats["Wallclock time"], float) - # Fetch variables (after-mip) - solution = solver.get_solution() - assert solution is not None - assert solution["x[0]"] is not None - assert solution["x[1]"] is not None - assert solution["x[2]"] is not None - assert solution["x[3]"] is not None - assert_equals(solution["x[0]"], 1.0) - assert_equals(solution["x[1]"], 0.0) - assert_equals(solution["x[2]"], 1.0) - assert_equals(solution["x[3]"], 1.0) + # Fetch variables (after-load) + assert_equals( + _round_variables(solver.get_variables()), + _remove_unsupported_var_attrs( + solver, + { + "x[0]": Variable( + lower_bound=0.0, + obj_coeff=505.0, + type="B", + upper_bound=1.0, + value=1.0, + ), + "x[1]": Variable( + lower_bound=0.0, + obj_coeff=352.0, + type="B", + upper_bound=1.0, + value=0.0, + ), + "x[2]": Variable( + lower_bound=0.0, + obj_coeff=458.0, + type="B", + upper_bound=1.0, + value=1.0, + ), + "x[3]": Variable( + lower_bound=0.0, + obj_coeff=220.0, + type="B", + upper_bound=1.0, + value=1.0, + ), + }, + ), + ) # Fetch constraints (after-mip) assert_equals( diff --git a/tests/components/test_primal.py b/tests/components/test_primal.py index 9cd8a04..061d28f 100644 --- a/tests/components/test_primal.py +++ b/tests/components/test_primal.py @@ -12,7 +12,7 @@ from miplearn.classifiers import Classifier from miplearn.classifiers.threshold import Threshold from miplearn.components import classifier_evaluation_dict from miplearn.components.primal import PrimalSolutionComponent -from miplearn.features import TrainingSample, VariableFeatures, Features +from miplearn.features import TrainingSample, Variable, Features from miplearn.instance.base import Instance from miplearn.problems.tsp import TravelingSalesmanGenerator from miplearn.solvers.learning import LearningSolver @@ -21,18 +21,18 @@ from miplearn.solvers.learning import LearningSolver def test_xy() -> None: features = Features( variables={ - "x[0]": VariableFeatures( + "x[0]": Variable( category="default", user_features=[0.0, 0.0], ), - "x[1]": VariableFeatures( + "x[1]": Variable( category=None, ), - "x[2]": VariableFeatures( + "x[2]": Variable( category="default", user_features=[1.0, 0.0], ), - "x[3]": VariableFeatures( + "x[3]": Variable( category="default", user_features=[1.0, 1.0], ), @@ -78,18 +78,18 @@ def test_xy() -> None: def test_xy_without_lp_solution() -> None: features = Features( variables={ - "x[0]": VariableFeatures( + "x[0]": Variable( category="default", user_features=[0.0, 0.0], ), - "x[1]": VariableFeatures( + "x[1]": Variable( category=None, ), - "x[2]": VariableFeatures( + "x[2]": Variable( category="default", user_features=[1.0, 0.0], ), - "x[3]": VariableFeatures( + "x[3]": Variable( category="default", user_features=[1.0, 1.0], ), @@ -141,15 +141,15 @@ def test_predict() -> None: thr.predict = Mock(return_value=[0.75, 0.75]) features = Features( variables={ - "x[0]": VariableFeatures( + "x[0]": Variable( category="default", user_features=[0.0, 0.0], ), - "x[1]": VariableFeatures( + "x[1]": Variable( category="default", user_features=[0.0, 2.0], ), - "x[2]": VariableFeatures( + "x[2]": Variable( category="default", user_features=[2.0, 0.0], ), @@ -235,11 +235,11 @@ def test_evaluate() -> None: } features: Features = Features( variables={ - "x[0]": VariableFeatures(), - "x[1]": VariableFeatures(), - "x[2]": VariableFeatures(), - "x[3]": VariableFeatures(), - "x[4]": VariableFeatures(), + "x[0]": Variable(), + "x[1]": Variable(), + "x[2]": Variable(), + "x[3]": Variable(), + "x[4]": Variable(), } ) instance = Mock(spec=Instance) diff --git a/tests/test_features.py b/tests/test_features.py index 9e7b08b..e36d423 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -5,7 +5,7 @@ from miplearn.features import ( FeaturesExtractor, InstanceFeatures, - VariableFeatures, + Variable, Constraint, ) from miplearn.solvers.gurobi import GurobiSolver @@ -19,19 +19,19 @@ def test_knapsack() -> None: solver.set_instance(instance, model) FeaturesExtractor(solver).extract(instance) assert instance.features.variables == { - "x[0]": VariableFeatures( + "x[0]": Variable( category="default", user_features=[23.0, 505.0], ), - "x[1]": VariableFeatures( + "x[1]": Variable( category="default", user_features=[26.0, 352.0], ), - "x[2]": VariableFeatures( + "x[2]": Variable( category="default", user_features=[20.0, 458.0], ), - "x[3]": VariableFeatures( + "x[3]": Variable( category="default", user_features=[18.0, 220.0], ),