From ef7a50e871279f7df9f206b707170854b3b4b75c Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 13 Apr 2021 16:08:30 -0500 Subject: [PATCH] Only include static features in after-load --- miplearn/components/dynamic_common.py | 6 +- miplearn/components/primal.py | 12 +-- miplearn/features.py | 25 +++-- miplearn/solvers/gurobi.py | 64 ++++++++---- miplearn/solvers/internal.py | 4 +- miplearn/solvers/learning.py | 10 +- miplearn/solvers/pyomo/base.py | 134 +++++++++++++++----------- miplearn/solvers/pyomo/gurobi.py | 31 ------ miplearn/solvers/tests/__init__.py | 56 ++--------- tests/components/test_dynamic_lazy.py | 12 +-- tests/components/test_primal.py | 4 +- 11 files changed, 173 insertions(+), 185 deletions(-) diff --git a/miplearn/components/dynamic_common.py b/miplearn/components/dynamic_common.py index 8d954f3..6c5c87b 100644 --- a/miplearn/components/dynamic_common.py +++ b/miplearn/components/dynamic_common.py @@ -62,9 +62,9 @@ class DynamicConstraintsComponent(Component): # Features features = [] - assert sample.after_lp is not None - assert sample.after_lp.instance is not None - features.extend(sample.after_lp.instance.to_list()) + assert sample.after_load is not None + assert sample.after_load.instance is not None + features.extend(sample.after_load.instance.to_list()) features.extend(instance.get_constraint_features(cid)) for ci in features: assert isinstance(ci, float), ( diff --git a/miplearn/components/primal.py b/miplearn/components/primal.py index 9d5f86d..00c676e 100644 --- a/miplearn/components/primal.py +++ b/miplearn/components/primal.py @@ -149,6 +149,7 @@ class PrimalSolutionComponent(Component): x: Dict = {} y: Dict = {} assert sample.after_load is not None + assert sample.after_load.instance is not None assert sample.after_load.variables is not None for (var_name, var) in sample.after_load.variables.items(): # Initialize categories @@ -160,14 +161,11 @@ class PrimalSolutionComponent(Component): y[category] = [] # Features - sf = sample.after_load + features = list(sample.after_load.instance.to_list()) + features.extend(sample.after_load.variables[var_name].to_list()) if sample.after_lp is not None: - sf = sample.after_lp - assert sf.instance is not None - features = list(sf.instance.to_list()) - assert sf.variables is not None - assert sf.variables[var_name] is not None - features.extend(sf.variables[var_name].to_list()) + assert sample.after_lp.variables is not None + features.extend(sample.after_lp.variables[var_name].to_list()) x[category].append(features) # Labels diff --git a/miplearn/features.py b/miplearn/features.py index 027c807..f0a6e0d 100644 --- a/miplearn/features.py +++ b/miplearn/features.py @@ -82,7 +82,7 @@ class Constraint: category: Optional[Hashable] = None dual_value: Optional[float] = None lazy: bool = False - lhs: Dict[str, float] = lambda: {} # type: ignore + lhs: Optional[Dict[str, float]] = None rhs: float = 0.0 sa_rhs_down: Optional[float] = None sa_rhs_up: Optional[float] = None @@ -136,14 +136,23 @@ class FeaturesExtractor: ) -> None: self.solver = internal_solver - def extract(self, instance: "Instance") -> Features: + def extract( + self, + instance: "Instance", + with_static: bool = True, + ) -> Features: features = Features() - features.variables = self.solver.get_variables() - features.constraints = self.solver.get_constraints() - self._extract_user_features_vars(instance, features) - self._extract_user_features_constrs(instance, features) - self._extract_user_features_instance(instance, features) - self._extract_alvarez_2017(features) + features.variables = self.solver.get_variables( + with_static=with_static, + ) + features.constraints = self.solver.get_constraints( + with_static=with_static, + ) + if with_static: + self._extract_user_features_vars(instance, features) + self._extract_user_features_constrs(instance, features) + self._extract_user_features_instance(instance, features) + self._extract_alvarez_2017(features) return features def _extract_user_features_vars( diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index f40e9be..d60ac1d 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -84,6 +84,7 @@ class GurobiSolver(InternalSolver): @overrides def add_constraint(self, constr: Constraint, name: str) -> None: assert self.model is not None + assert constr.lhs is not None lhs = self.gp.quicksum( self._varname_to_var[varname] * coeff for (varname, coeff) in constr.lhs.items() @@ -153,23 +154,36 @@ class GurobiSolver(InternalSolver): ] @overrides - def get_constraints(self) -> Dict[str, Constraint]: + def get_constraints(self, with_static: bool = True) -> Dict[str, Constraint]: model = self.model assert model is not None self._raise_if_callback() if self._dirty: model.update() self._dirty = False + gp_constrs = model.getConstrs() - var_names = model.getAttr("varName", model.getVars()) constr_names = model.getAttr("constrName", gp_constrs) - rhs = model.getAttr("rhs", gp_constrs) - sense = model.getAttr("sense", gp_constrs) + lhs: Optional[List[Dict]] = None + rhs = None + sense = None dual_value = None sa_rhs_up = None sa_rhs_down = None slack = None basis_status = None + + if with_static: + var_names = model.getAttr("varName", model.getVars()) + rhs = model.getAttr("rhs", gp_constrs) + sense = model.getAttr("sense", gp_constrs) + lhs = [] + for (i, gp_constr) in enumerate(gp_constrs): + expr = model.getRow(gp_constr) + lhsi = {} + for j in range(expr.size()): + lhsi[var_names[expr.getVar(j).index]] = expr.getCoeff(j) + lhs.append(lhsi) if self._has_lp_solution: dual_value = model.getAttr("pi", gp_constrs) sa_rhs_up = model.getAttr("saRhsUp", gp_constrs) @@ -177,16 +191,20 @@ class GurobiSolver(InternalSolver): basis_status = model.getAttr("cbasis", gp_constrs) if self._has_lp_solution or self._has_mip_solution: slack = model.getAttr("slack", gp_constrs) + constraints: Dict[str, Constraint] = {} for (i, gp_constr) in enumerate(gp_constrs): - expr = model.getRow(gp_constr) - lhs = {} - for j in range(expr.size()): - lhs[var_names[expr.getVar(j).index]] = expr.getCoeff(j) assert ( constr_names[i] not in constraints ), f"Duplicated constraint name detected: {constr_names[i]}" - constraint = Constraint(lhs=lhs, rhs=rhs[i], sense=sense[i]) + constraint = Constraint() + if with_static: + assert lhs is not None + assert rhs is not None + assert sense is not None + constraint.lhs = lhs[i] + constraint.rhs = rhs[i] + constraint.sense = sense[i] if dual_value is not None: assert sa_rhs_up is not None assert sa_rhs_down is not None @@ -247,13 +265,10 @@ class GurobiSolver(InternalSolver): ] @overrides - def get_variables(self) -> Dict[str, Variable]: + def get_variables(self, with_static: bool = True) -> Dict[str, Variable]: assert self.model is not None variables = {} gp_vars = self.model.getVars() - lb = self.model.getAttr("lb", gp_vars) - ub = self.model.getAttr("ub", gp_vars) - obj_coeff = self.model.getAttr("obj", gp_vars) names = self.model.getAttr("varName", gp_vars) values = None rc = None @@ -264,6 +279,13 @@ class GurobiSolver(InternalSolver): sa_lb_up = None sa_lb_down = None vbasis = None + ub = None + lb = None + obj_coeff = None + if with_static: + lb = self.model.getAttr("lb", gp_vars) + ub = self.model.getAttr("ub", gp_vars) + obj_coeff = self.model.getAttr("obj", gp_vars) if self.model.solCount > 0: values = self.model.getAttr("x", gp_vars) if self._has_lp_solution: @@ -281,12 +303,15 @@ class GurobiSolver(InternalSolver): assert ( names[i] not in variables ), f"Duplicated variable name detected: {names[i]}" - var = Variable( - lower_bound=lb[i], - upper_bound=ub[i], - obj_coeff=obj_coeff[i], - type=self._original_vtype[gp_var], - ) + var = Variable() + if with_static: + assert lb is not None + assert ub is not None + assert obj_coeff is not None + var.lower_bound = lb[i] + var.upper_bound = ub[i] + var.obj_coeff = obj_coeff[i] + var.type = self._original_vtype[gp_var] if values is not None: var.value = values[i] if rc is not None: @@ -319,6 +344,7 @@ class GurobiSolver(InternalSolver): @overrides def is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool: + assert constr.lhs is not None lhs = 0.0 for (varname, coeff) in constr.lhs.items(): var = self._varname_to_var[varname] diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index f1204b7..06ea885 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -170,7 +170,7 @@ class InternalSolver(ABC, EnforceOverrides): raise NotImplementedError() @abstractmethod - def get_constraints(self) -> Dict[str, Constraint]: + def get_constraints(self, with_static: bool = True) -> Dict[str, Constraint]: pass @abstractmethod @@ -237,7 +237,7 @@ class InternalSolver(ABC, EnforceOverrides): return False @abstractmethod - def get_variables(self) -> Dict[str, Variable]: + def get_variables(self, with_static: bool = True) -> Dict[str, Variable]: pass @abstractmethod diff --git a/miplearn/solvers/learning.py b/miplearn/solvers/learning.py index 6e6a218..70f18d4 100644 --- a/miplearn/solvers/learning.py +++ b/miplearn/solvers/learning.py @@ -187,7 +187,10 @@ class LearningSolver: # Extract features (after-lp) # ------------------------------------------------------- logger.info("Extracting features (after-lp)...") - features = FeaturesExtractor(self.internal_solver).extract(instance) + features = FeaturesExtractor(self.internal_solver).extract( + instance, + with_static=False, + ) features.extra = {} features.lp_solve = lp_stats sample.after_lp = features @@ -249,7 +252,10 @@ class LearningSolver: # Extract features (after-mip) # ------------------------------------------------------- logger.info("Extracting features (after-mip)...") - features = FeaturesExtractor(self.internal_solver).extract(instance) + features = FeaturesExtractor(self.internal_solver).extract( + instance, + with_static=False, + ) features.mip_solve = mip_stats features.extra = {} sample.after_mip = features diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index fc5104b..68c54f0 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -77,6 +77,7 @@ class BasePyomoSolver(InternalSolver): ) -> None: assert self.model is not None if isinstance(constr, Constraint): + assert constr.lhs is not None lhs = 0.0 for (varname, coeff) in constr.lhs.items(): var = self._varname_to_var[varname] @@ -127,7 +128,7 @@ class BasePyomoSolver(InternalSolver): self._pyomo_solver.update_var(var) @overrides - def get_constraints(self) -> Dict[str, Constraint]: + def get_constraints(self, with_static: bool = True) -> Dict[str, Constraint]: assert self.model is not None constraints = {} @@ -136,11 +137,17 @@ class BasePyomoSolver(InternalSolver): for idx in constr: name = f"{constr.name}[{idx}]" assert name not in constraints - constraints[name] = self._parse_pyomo_constraint(constr[idx]) + constraints[name] = self._parse_pyomo_constraint( + constr[idx], + with_static=with_static, + ) else: name = constr.name assert name not in constraints - constraints[name] = self._parse_pyomo_constraint(constr) + constraints[name] = self._parse_pyomo_constraint( + constr, + with_static=with_static, + ) return constraints @@ -169,7 +176,7 @@ class BasePyomoSolver(InternalSolver): return solution @overrides - def get_variables(self) -> Dict[str, Variable]: + def get_variables(self, with_static: bool = True) -> Dict[str, Variable]: assert self.model is not None variables = {} for var in self.model.component_objects(pyomo.core.Var): @@ -177,7 +184,10 @@ class BasePyomoSolver(InternalSolver): varname = f"{var}[{idx}]" if idx is None: varname = str(var) - variables[varname] = self._parse_pyomo_variable(var[idx]) + variables[varname] = self._parse_pyomo_variable( + var[idx], + with_static=with_static, + ) return variables @overrides @@ -201,6 +211,7 @@ class BasePyomoSolver(InternalSolver): @overrides def is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool: lhs = 0.0 + assert constr.lhs is not None for (varname, coeff) in constr.lhs.items(): var = self._varname_to_var[varname] lhs += var.value * coeff @@ -378,71 +389,78 @@ class BasePyomoSolver(InternalSolver): def _get_warm_start_regexp(self) -> Optional[str]: return None - def _parse_pyomo_variable(self, var: pyomo.core.Var) -> Variable: + def _parse_pyomo_variable( + self, + pyomo_var: pyomo.core.Var, + with_static: bool = True, + ) -> Variable: assert self.model is not None - - # Variable type - vtype: Optional[str] = None - if var.domain == pyomo.core.Binary: - vtype = "B" - elif var.domain in [ - pyomo.core.Reals, - pyomo.core.NonNegativeReals, - pyomo.core.NonPositiveReals, - pyomo.core.NegativeReals, - pyomo.core.PositiveReals, - ]: - vtype = "C" - if vtype is None: - raise Exception(f"unknown variable domain: {var.domain}") - - # Bounds - lb, ub = var.bounds + variable = Variable() + + if with_static: + # Variable type + vtype: Optional[str] = None + if pyomo_var.domain == pyomo.core.Binary: + vtype = "B" + elif pyomo_var.domain in [ + pyomo.core.Reals, + pyomo.core.NonNegativeReals, + pyomo.core.NonPositiveReals, + pyomo.core.NegativeReals, + pyomo.core.PositiveReals, + ]: + vtype = "C" + if vtype is None: + raise Exception(f"unknown variable domain: {pyomo_var.domain}") + variable.type = vtype + + # Bounds + lb, ub = pyomo_var.bounds + variable.upper_bound = float(ub) + variable.lower_bound = float(lb) + + # Objective coefficient + obj_coeff = 0.0 + if pyomo_var.name in self._obj: + obj_coeff = self._obj[pyomo_var.name] + variable.obj_coeff = obj_coeff # Reduced costs - rc = None - if var in self.model.rc: - rc = self.model.rc[var] - - # Objective coefficient - obj_coeff = 0.0 - if var.name in self._obj: - obj_coeff = self._obj[var.name] - - return Variable( - value=var.value, - type=vtype, - lower_bound=float(lb), - upper_bound=float(ub), - obj_coeff=obj_coeff, - reduced_cost=rc, - ) + if pyomo_var in self.model.rc: + variable.reduced_cost = self.model.rc[pyomo_var] + + variable.value = pyomo_var.value + return variable def _parse_pyomo_constraint( self, pyomo_constr: pyomo.core.Constraint, + with_static: bool = True, ) -> Constraint: assert self.model is not None constr = Constraint() - # Extract RHS and sense - has_ub = pyomo_constr.has_ub() - has_lb = pyomo_constr.has_lb() - assert ( - (not has_lb) or (not has_ub) or pyomo_constr.upper() == pyomo_constr.lower() - ), "range constraints not supported" - if not has_ub: - constr.sense = ">" - constr.rhs = pyomo_constr.lower() - elif not has_lb: - constr.sense = "<" - constr.rhs = pyomo_constr.upper() - else: - constr.sense = "=" - constr.rhs = pyomo_constr.upper() + if with_static: + # Extract RHS and sense + has_ub = pyomo_constr.has_ub() + has_lb = pyomo_constr.has_lb() + assert ( + (not has_lb) + or (not has_ub) + or pyomo_constr.upper() == pyomo_constr.lower() + ), "range constraints not supported" + if not has_ub: + constr.sense = ">" + constr.rhs = pyomo_constr.lower() + elif not has_lb: + constr.sense = "<" + constr.rhs = pyomo_constr.upper() + else: + constr.sense = "=" + constr.rhs = pyomo_constr.upper() - # Extract LHS - constr.lhs = self._parse_pyomo_expr(pyomo_constr.body) + # Extract LHS + constr.lhs = self._parse_pyomo_expr(pyomo_constr.body) # Extract solution attributes if self._has_lp_solution: diff --git a/miplearn/solvers/pyomo/gurobi.py b/miplearn/solvers/pyomo/gurobi.py index 0a4152b..3aeaff0 100644 --- a/miplearn/solvers/pyomo/gurobi.py +++ b/miplearn/solvers/pyomo/gurobi.py @@ -55,37 +55,6 @@ class GurobiPyomoSolver(BasePyomoSolver): gvar = self._pyomo_solver._pyomo_var_to_solver_var_map[var] gvar.setAttr(GRB.Attr.BranchPriority, int(round(priority))) - @overrides - def get_variables(self) -> Dict[str, Variable]: - variables = super().get_variables() - if self._has_lp_solution: - for (varname, var) in variables.items(): - pvar = self._varname_to_var[varname] - gvar = self._pyomo_solver._pyomo_var_to_solver_var_map[pvar] - GurobiSolver._parse_gurobi_var_lp(gvar, var) - - return variables - - @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", - ] - @overrides def _extract_node_count(self, log: str) -> int: return max(1, int(self._pyomo_solver._solver_model.getAttr("NodeCount"))) diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index 3e806b6..a5d721c 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -279,63 +279,25 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: assert isinstance(mip_stats.mip_wallclock_time, float) assert mip_stats.mip_wallclock_time > 0 - # Fetch variables (after-load) + # Fetch variables (after-mip) assert_equals( - _round_variables(solver.get_variables()), + _round_variables(solver.get_variables(with_static=False)), _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, - ), - "z": Variable( - lower_bound=0.0, - obj_coeff=0.0, - type="C", - upper_bound=67.0, - value=61.0, - ), + "x[0]": Variable(value=1.0), + "x[1]": Variable(value=0.0), + "x[2]": Variable(value=1.0), + "x[3]": Variable(value=1.0), + "z": Variable(value=61.0), }, ), ) # Fetch constraints (after-mip) assert_equals( - _round_constraints(solver.get_constraints()), - { - "eq_capacity": Constraint( - 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, - sense="=", - slack=0.0, - ) - }, + _round_constraints(solver.get_constraints(with_static=False)), + {"eq_capacity": Constraint(slack=0.0)}, ) # Build a new constraint diff --git a/tests/components/test_dynamic_lazy.py b/tests/components/test_dynamic_lazy.py index 162c769..d3a9a9a 100644 --- a/tests/components/test_dynamic_lazy.py +++ b/tests/components/test_dynamic_lazy.py @@ -28,18 +28,18 @@ def training_instances() -> List[Instance]: instances = [cast(Instance, Mock(spec=Instance)) for _ in range(2)] instances[0].samples = [ Sample( - after_lp=Features(instance=InstanceFeatures()), + after_load=Features(instance=InstanceFeatures()), after_mip=Features(extra={"lazy_enforced": {"c1", "c2"}}), ), Sample( - after_lp=Features(instance=InstanceFeatures()), + after_load=Features(instance=InstanceFeatures()), after_mip=Features(extra={"lazy_enforced": {"c2", "c3"}}), ), ] - instances[0].samples[0].after_lp.instance.to_list = Mock( # type: ignore + instances[0].samples[0].after_load.instance.to_list = Mock( # type: ignore return_value=[5.0] ) - instances[0].samples[1].after_lp.instance.to_list = Mock( # type: ignore + instances[0].samples[1].after_load.instance.to_list = Mock( # type: ignore return_value=[5.0] ) instances[0].get_constraint_category = Mock( # type: ignore @@ -60,11 +60,11 @@ def training_instances() -> List[Instance]: ) instances[1].samples = [ Sample( - after_lp=Features(instance=InstanceFeatures()), + after_load=Features(instance=InstanceFeatures()), after_mip=Features(extra={"lazy_enforced": {"c3", "c4"}}), ) ] - instances[1].samples[0].after_lp.instance.to_list = Mock( # type: ignore + instances[1].samples[0].after_load.instance.to_list = Mock( # type: ignore return_value=[8.0] ) instances[1].get_constraint_category = Mock( # type: ignore diff --git a/tests/components/test_primal.py b/tests/components/test_primal.py index 8254686..46d4a52 100644 --- a/tests/components/test_primal.py +++ b/tests/components/test_primal.py @@ -27,6 +27,7 @@ from miplearn.solvers.tests import assert_equals def sample() -> Sample: sample = Sample( after_load=Features( + instance=InstanceFeatures(), variables={ "x[0]": Variable(category="default"), "x[1]": Variable(category=None), @@ -35,7 +36,6 @@ def sample() -> Sample: }, ), after_lp=Features( - instance=InstanceFeatures(), variables={ "x[0]": Variable(), "x[1]": Variable(), @@ -52,7 +52,7 @@ def sample() -> Sample: } ), ) - sample.after_lp.instance.to_list = Mock(return_value=[5.0]) # type: ignore + sample.after_load.instance.to_list = Mock(return_value=[5.0]) # type: ignore sample.after_lp.variables["x[0]"].to_list = Mock( # type: ignore return_value=[0.0, 0.0] )