From 5075a3c2f2dff86dfe844dcabde3a279041a52c8 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 3 Dec 2021 12:40:58 -0600 Subject: [PATCH 01/10] install-deps: Specify gurobi version --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9a6c54a..0fef914 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ docs: install-deps: $(PIP) install --upgrade pip - $(PIP) install --upgrade -i https://pypi.gurobi.com gurobipy + $(PIP) install --upgrade -i https://pypi.gurobi.com 'gurobipy>=9.1,<9.2' $(PIP) install --upgrade xpress $(PIP) install --upgrade -r requirements.txt From ba8f5bb2f48fd8a7a9f9981dfdcb87ceed075ede Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 25 Jan 2022 08:33:23 -0600 Subject: [PATCH 02/10] Upgrade to Gurobi 9.5 --- Makefile | 2 +- tests/components/test_dynamic_user_cuts.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 0fef914..f4dfcd8 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ docs: install-deps: $(PIP) install --upgrade pip - $(PIP) install --upgrade -i https://pypi.gurobi.com 'gurobipy>=9.1,<9.2' + $(PIP) install --upgrade -i https://pypi.gurobi.com 'gurobipy>=9.5,<9.6' $(PIP) install --upgrade xpress $(PIP) install --upgrade -r requirements.txt diff --git a/tests/components/test_dynamic_user_cuts.py b/tests/components/test_dynamic_user_cuts.py index 2bae1a6..57bbbd8 100644 --- a/tests/components/test_dynamic_user_cuts.py +++ b/tests/components/test_dynamic_user_cuts.py @@ -53,13 +53,17 @@ class GurobiStableSetProblem(Instance): @overrides def enforce_user_cut( self, - solver: InternalSolver, + solver: GurobiSolver, model: Any, cid: ConstraintName, ) -> Any: clique = [int(i) for i in cid.decode().split(",")] x = model.getVars() - model.addConstr(gp.quicksum([x[i] for i in clique]) <= 1) + constr = gp.quicksum([x[i] for i in clique]) <= 1 + if solver.cb_where: + model.cbCut(constr) + else: + model.addConstr(constr) @pytest.fixture From 2a76dd42ecf51d634c7deb05e90a85461cfd55f1 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 25 Jan 2022 11:39:03 -0600 Subject: [PATCH 03/10] Allow user to attach arbitrary data to violations --- miplearn/components/dynamic_common.py | 47 ++++++++++++------- miplearn/components/dynamic_lazy.py | 41 +++++++++-------- miplearn/components/dynamic_user_cuts.py | 42 ++++++++--------- miplearn/instance/base.py | 52 ++++++++++++---------- miplearn/instance/file.py | 16 +++---- miplearn/instance/picklegz.py | 14 +++--- miplearn/problems/tsp.py | 12 ++--- miplearn/solvers/gurobi.py | 2 +- miplearn/solvers/tests/__init__.py | 2 +- tests/components/test_dynamic_lazy.py | 31 ++++++++++--- tests/components/test_dynamic_user_cuts.py | 25 ++++++----- tests/problems/test_tsp.py | 13 ++++-- 12 files changed, 169 insertions(+), 128 deletions(-) diff --git a/miplearn/components/dynamic_common.py b/miplearn/components/dynamic_common.py index dbfd5e7..11f341a 100644 --- a/miplearn/components/dynamic_common.py +++ b/miplearn/components/dynamic_common.py @@ -1,7 +1,7 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - +import json import logging from typing import Dict, List, Tuple, Optional, Any, Set @@ -36,7 +36,7 @@ class DynamicConstraintsComponent(Component): self.classifier_prototype: Classifier = classifier self.classifiers: Dict[ConstraintCategory, Classifier] = {} self.thresholds: Dict[ConstraintCategory, Threshold] = {} - self.known_cids: List[ConstraintName] = [] + self.known_violations: Dict[ConstraintName, Any] = {} self.attr = attr def sample_xy_with_cids( @@ -48,18 +48,19 @@ class DynamicConstraintsComponent(Component): Dict[ConstraintCategory, List[List[bool]]], Dict[ConstraintCategory, List[ConstraintName]], ]: - if len(self.known_cids) == 0: + if len(self.known_violations) == 0: return {}, {}, {} assert instance is not None x: Dict[ConstraintCategory, List[List[float]]] = {} y: Dict[ConstraintCategory, List[List[bool]]] = {} cids: Dict[ConstraintCategory, List[ConstraintName]] = {} - known_cids = np.array(self.known_cids, dtype="S") + known_cids = np.array(sorted(list(self.known_violations.keys())), dtype="S") enforced_cids = None - enforced_cids_np = sample.get_array(self.attr) - if enforced_cids_np is not None: - enforced_cids = list(enforced_cids_np) + enforced_encoded = sample.get_scalar(self.attr) + if enforced_encoded is not None: + enforced = self.decode(enforced_encoded) + enforced_cids = list(enforced.keys()) # Get user-provided constraint features ( @@ -100,11 +101,10 @@ class DynamicConstraintsComponent(Component): @overrides def pre_fit(self, pre: List[Any]) -> None: assert pre is not None - known_cids: Set = set() - for cids in pre: - known_cids |= set(list(cids)) - self.known_cids.clear() - self.known_cids.extend(sorted(known_cids)) + self.known_violations.clear() + for violations in pre: + for (vname, vdata) in violations.items(): + self.known_violations[vname] = vdata def sample_predict( self, @@ -112,7 +112,7 @@ class DynamicConstraintsComponent(Component): sample: Sample, ) -> List[ConstraintName]: pred: List[ConstraintName] = [] - if len(self.known_cids) == 0: + if len(self.known_violations) == 0: logger.info("Classifiers not fitted. Skipping.") return pred x, _, cids = self.sample_xy_with_cids(instance, sample) @@ -131,7 +131,9 @@ class DynamicConstraintsComponent(Component): @overrides def pre_sample_xy(self, instance: Instance, sample: Sample) -> Any: - return sample.get_array(self.attr) + attr_encoded = sample.get_scalar(self.attr) + assert attr_encoded is not None + return self.decode(attr_encoded) @overrides def fit_xy( @@ -153,11 +155,13 @@ class DynamicConstraintsComponent(Component): instance: Instance, sample: Sample, ) -> Dict[str, float]: - actual = sample.get_array(self.attr) - assert actual is not None + attr_encoded = sample.get_scalar(self.attr) + assert attr_encoded is not None + actual_violations = DynamicConstraintsComponent.decode(attr_encoded) + actual = set(actual_violations.keys()) pred = set(self.sample_predict(instance, sample)) tp, tn, fp, fn = 0, 0, 0, 0 - for cid in self.known_cids: + for cid in self.known_violations.keys(): if cid in pred: if cid in actual: tp += 1 @@ -169,3 +173,12 @@ class DynamicConstraintsComponent(Component): else: tn += 1 return classifier_evaluation_dict(tp=tp, tn=tn, fp=fp, fn=fn) + + @staticmethod + def encode(violations: Dict[ConstraintName, Any]) -> str: + return json.dumps({k.decode(): v for (k, v) in violations.items()}) + + @staticmethod + def decode(violations_encoded: str) -> Dict[ConstraintName, Any]: + violations = json.loads(violations_encoded) + return {k.encode(): v for (k, v) in violations.items()} diff --git a/miplearn/components/dynamic_lazy.py b/miplearn/components/dynamic_lazy.py index 7756e64..e13360c 100644 --- a/miplearn/components/dynamic_lazy.py +++ b/miplearn/components/dynamic_lazy.py @@ -1,10 +1,8 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - import logging -import pdb -from typing import Dict, List, TYPE_CHECKING, Tuple, Any, Optional, Set +from typing import Dict, List, TYPE_CHECKING, Tuple, Any, Optional import numpy as np from overrides import overrides @@ -37,23 +35,23 @@ class DynamicLazyConstraintsComponent(Component): self.dynamic: DynamicConstraintsComponent = DynamicConstraintsComponent( classifier=classifier, threshold=threshold, - attr="mip_constr_lazy_enforced", + attr="mip_constr_lazy", ) self.classifiers = self.dynamic.classifiers self.thresholds = self.dynamic.thresholds - self.known_cids = self.dynamic.known_cids - self.lazy_enforced: Set[ConstraintName] = set() + self.known_violations = self.dynamic.known_violations + self.lazy_enforced: Dict[ConstraintName, Any] = {} @staticmethod def enforce( - cids: List[ConstraintName], + violations: Dict[ConstraintName, Any], instance: Instance, model: Any, solver: "LearningSolver", ) -> None: assert solver.internal_solver is not None - for cid in cids: - instance.enforce_lazy_constraint(solver.internal_solver, model, cid) + for (vname, vdata) in violations.items(): + instance.enforce_lazy_constraint(solver.internal_solver, model, vdata) @overrides def before_solve_mip( @@ -66,9 +64,10 @@ class DynamicLazyConstraintsComponent(Component): ) -> None: self.lazy_enforced.clear() logger.info("Predicting violated (dynamic) lazy constraints...") - cids = self.dynamic.sample_predict(instance, sample) - logger.info("Enforcing %d lazy constraints..." % len(cids)) - self.enforce(cids, instance, model, solver) + vnames = self.dynamic.sample_predict(instance, sample) + violations = {c: self.dynamic.known_violations[c] for c in vnames} + logger.info("Enforcing %d lazy constraints..." % len(vnames)) + self.enforce(violations, instance, model, solver) @overrides def after_solve_mip( @@ -79,10 +78,7 @@ class DynamicLazyConstraintsComponent(Component): stats: LearningSolveStats, sample: Sample, ) -> None: - sample.put_array( - "mip_constr_lazy_enforced", - np.array(list(self.lazy_enforced), dtype="S"), - ) + sample.put_scalar("mip_constr_lazy", self.dynamic.encode(self.lazy_enforced)) @overrides def iteration_cb( @@ -93,14 +89,17 @@ class DynamicLazyConstraintsComponent(Component): ) -> bool: assert solver.internal_solver is not None logger.debug("Finding violated lazy constraints...") - cids = instance.find_violated_lazy_constraints(solver.internal_solver, model) - if len(cids) == 0: + violations = instance.find_violated_lazy_constraints( + solver.internal_solver, model + ) + if len(violations) == 0: logger.debug("No violations found") return False else: - self.lazy_enforced |= set(cids) - logger.debug(" %d violations found" % len(cids)) - self.enforce(cids, instance, model, solver) + for v in violations: + self.lazy_enforced[v] = violations[v] + logger.debug(" %d violations found" % len(violations)) + self.enforce(violations, instance, model, solver) return True # Delegate ML methods to self.dynamic diff --git a/miplearn/components/dynamic_user_cuts.py b/miplearn/components/dynamic_user_cuts.py index b48d7e7..d939ff5 100644 --- a/miplearn/components/dynamic_user_cuts.py +++ b/miplearn/components/dynamic_user_cuts.py @@ -1,9 +1,8 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - import logging -from typing import Any, TYPE_CHECKING, Set, Tuple, Dict, List, Optional +from typing import Any, TYPE_CHECKING, Tuple, Dict, List import numpy as np from overrides import overrides @@ -32,9 +31,9 @@ class UserCutsComponent(Component): self.dynamic = DynamicConstraintsComponent( classifier=classifier, threshold=threshold, - attr="mip_user_cuts_enforced", + attr="mip_user_cuts", ) - self.enforced: Set[ConstraintName] = set() + self.enforced: Dict[ConstraintName, Any] = {} self.n_added_in_callback = 0 @overrides @@ -50,11 +49,12 @@ class UserCutsComponent(Component): self.enforced.clear() self.n_added_in_callback = 0 logger.info("Predicting violated user cuts...") - cids = self.dynamic.sample_predict(instance, sample) - logger.info("Enforcing %d user cuts ahead-of-time..." % len(cids)) - for cid in cids: - instance.enforce_user_cut(solver.internal_solver, model, cid) - stats["UserCuts: Added ahead-of-time"] = len(cids) + vnames = self.dynamic.sample_predict(instance, sample) + logger.info("Enforcing %d user cuts ahead-of-time..." % len(vnames)) + for vname in vnames: + vdata = self.dynamic.known_violations[vname] + instance.enforce_user_cut(solver.internal_solver, model, vdata) + stats["UserCuts: Added ahead-of-time"] = len(vnames) @overrides def user_cut_cb( @@ -65,18 +65,17 @@ class UserCutsComponent(Component): ) -> None: assert solver.internal_solver is not None logger.debug("Finding violated user cuts...") - cids = instance.find_violated_user_cuts(model) - logger.debug(f"Found {len(cids)} violated user cuts") + violations = instance.find_violated_user_cuts(model) + logger.debug(f"Found {len(violations)} violated user cuts") logger.debug("Building violated user cuts...") - for cid in cids: - if cid in self.enforced: + for (vname, vdata) in violations.items(): + if vname in self.enforced: continue - assert isinstance(cid, ConstraintName) - instance.enforce_user_cut(solver.internal_solver, model, cid) - self.enforced.add(cid) + instance.enforce_user_cut(solver.internal_solver, model, vdata) + self.enforced[vname] = vdata self.n_added_in_callback += 1 - if len(cids) > 0: - logger.debug(f"Added {len(cids)} violated user cuts") + if len(violations) > 0: + logger.debug(f"Added {len(violations)} violated user cuts") @overrides def after_solve_mip( @@ -87,10 +86,7 @@ class UserCutsComponent(Component): stats: LearningSolveStats, sample: Sample, ) -> None: - sample.put_array( - "mip_user_cuts_enforced", - np.array(list(self.enforced), dtype="S"), - ) + sample.put_scalar("mip_user_cuts", self.dynamic.encode(self.enforced)) stats["UserCuts: Added in callback"] = self.n_added_in_callback if self.n_added_in_callback > 0: logger.info(f"{self.n_added_in_callback} user cuts added in callback") @@ -133,5 +129,5 @@ class UserCutsComponent(Component): self, instance: "Instance", sample: Sample, - ) -> Dict[ConstraintCategory, Dict[str, float]]: + ) -> Dict[ConstraintCategory, Dict[ConstraintName, float]]: return self.dynamic.sample_evaluate(instance, sample) diff --git a/miplearn/instance/base.py b/miplearn/instance/base.py index 01f75e4..8ddcba1 100644 --- a/miplearn/instance/base.py +++ b/miplearn/instance/base.py @@ -9,7 +9,7 @@ from typing import Any, List, TYPE_CHECKING, Dict import numpy as np from miplearn.features.sample import Sample, MemorySample -from miplearn.types import ConstraintName, ConstraintCategory +from miplearn.types import ConstraintName logger = logging.getLogger(__name__) @@ -114,7 +114,7 @@ class Instance(ABC): self, solver: "InternalSolver", model: Any, - ) -> List[ConstraintName]: + ) -> Dict[ConstraintName, Any]: """ Returns lazy constraint violations found for the current solution. @@ -124,40 +124,46 @@ class Instance(ABC): resolve the problem. The process repeats until no further lazy constraint violations are found. - Each "violation" is simply a string which allows the instance to identify - unambiguously which lazy constraint should be generated. In the Traveling - Salesman Problem, for example, a subtour violation could be a string - containing the cities in the subtour. + Violations should be returned in a dictionary mapping the name of the violation + to some user-specified data that allows the instance to unambiguously generate + the lazy constraints at a later time. In the Traveling Salesman Problem, for + example, this function could return a dictionary identifying violated subtour + inequalities. More concretely, it could return: + { + "s1": [1, 2, 3], + "s2": [4, 5, 6, 7], + } + where "s1" and "s2" are the names of the subtours, and [1,2,3] and [4,5,6,7] + are the cities in each subtour. The names of the violations should be kept + stable across instances. In our example, "s1" should always correspond to + [1,2,3] across all instances. The user-provided data should be picklable. The current solution can be queried with `solver.get_solution()`. If the solver is configured to use lazy callbacks, this solution may be non-integer. For a concrete example, see TravelingSalesmanInstance. """ - return [] + return {} def enforce_lazy_constraint( self, solver: "InternalSolver", model: Any, - violation: ConstraintName, + violation_data: Any, ) -> None: """ Adds constraints to the model to ensure that the given violation is fixed. This method is typically called immediately after - find_violated_lazy_constraints. The violation object provided to this method - is exactly the same object returned earlier by - find_violated_lazy_constraints. After some training, LearningSolver may - decide to proactively build some lazy constraints at the beginning of the - optimization process, before a solution is even available. In this case, - enforce_lazy_constraints will be called without a corresponding call to - find_violated_lazy_constraints. - - Note that this method can be called either before the optimization starts or - from within a callback. To ensure that constraints are added correctly in - either case, it is recommended to use `solver.add_constraint`, instead of - modifying the `model` object directly. + `find_violated_lazy_constraints`. The argument `violation_data` is the + user-provided data, previously returned by `find_violated_lazy_constraints`. + In the Traveling Salesman Problem, for example, it could be a list of cities + in the subtour. + + After some training, LearningSolver may decide to proactively build some lazy + constraints at the beginning of the optimization process, before a solution + is even available. In this case, `enforce_lazy_constraints` will be called + without a corresponding call to `find_violated_lazy_constraints`. For a concrete example, see TravelingSalesmanInstance. """ @@ -166,14 +172,14 @@ class Instance(ABC): def has_user_cuts(self) -> bool: return False - def find_violated_user_cuts(self, model: Any) -> List[ConstraintName]: - return [] + def find_violated_user_cuts(self, model: Any) -> Dict[ConstraintName, Any]: + return {} def enforce_user_cut( self, solver: "InternalSolver", model: Any, - violation: ConstraintName, + violation_data: Any, ) -> Any: return None diff --git a/miplearn/instance/file.py b/miplearn/instance/file.py index 46e7609..c1d4a78 100644 --- a/miplearn/instance/file.py +++ b/miplearn/instance/file.py @@ -3,15 +3,15 @@ # Released under the modified BSD license. See COPYING.md for more details. import gc import os -from typing import Any, Optional, List, Dict, TYPE_CHECKING import pickle +from typing import Any, Optional, List, Dict, TYPE_CHECKING import numpy as np from overrides import overrides from miplearn.features.sample import Hdf5Sample, Sample from miplearn.instance.base import Instance -from miplearn.types import ConstraintName, ConstraintCategory +from miplearn.types import ConstraintName if TYPE_CHECKING: from miplearn.solvers.learning import InternalSolver @@ -71,7 +71,7 @@ class FileInstance(Instance): self, solver: "InternalSolver", model: Any, - ) -> List[ConstraintName]: + ) -> Dict[ConstraintName, Any]: assert self.instance is not None return self.instance.find_violated_lazy_constraints(solver, model) @@ -80,13 +80,13 @@ class FileInstance(Instance): self, solver: "InternalSolver", model: Any, - violation: ConstraintName, + violation_data: Any, ) -> None: assert self.instance is not None - self.instance.enforce_lazy_constraint(solver, model, violation) + self.instance.enforce_lazy_constraint(solver, model, violation_data) @overrides - def find_violated_user_cuts(self, model: Any) -> List[ConstraintName]: + def find_violated_user_cuts(self, model: Any) -> Dict[ConstraintName, Any]: assert self.instance is not None return self.instance.find_violated_user_cuts(model) @@ -95,10 +95,10 @@ class FileInstance(Instance): self, solver: "InternalSolver", model: Any, - violation: ConstraintName, + violation_data: Any, ) -> None: assert self.instance is not None - self.instance.enforce_user_cut(solver, model, violation) + self.instance.enforce_user_cut(solver, model, violation_data) # Input & Output # ------------------------------------------------------------------------- diff --git a/miplearn/instance/picklegz.py b/miplearn/instance/picklegz.py index 41cf9b2..bdceae7 100644 --- a/miplearn/instance/picklegz.py +++ b/miplearn/instance/picklegz.py @@ -13,7 +13,7 @@ from overrides import overrides from miplearn.features.sample import Sample from miplearn.instance.base import Instance -from miplearn.types import ConstraintName, ConstraintCategory +from miplearn.types import ConstraintName if TYPE_CHECKING: from miplearn.solvers.learning import InternalSolver @@ -83,7 +83,7 @@ class PickleGzInstance(Instance): self, solver: "InternalSolver", model: Any, - ) -> List[ConstraintName]: + ) -> Dict[ConstraintName, Any]: assert self.instance is not None return self.instance.find_violated_lazy_constraints(solver, model) @@ -92,13 +92,13 @@ class PickleGzInstance(Instance): self, solver: "InternalSolver", model: Any, - violation: ConstraintName, + violation_data: Any, ) -> None: assert self.instance is not None - self.instance.enforce_lazy_constraint(solver, model, violation) + self.instance.enforce_lazy_constraint(solver, model, violation_data) @overrides - def find_violated_user_cuts(self, model: Any) -> List[ConstraintName]: + def find_violated_user_cuts(self, model: Any) -> Dict[ConstraintName, Any]: assert self.instance is not None return self.instance.find_violated_user_cuts(model) @@ -107,10 +107,10 @@ class PickleGzInstance(Instance): self, solver: "InternalSolver", model: Any, - violation: ConstraintName, + violation_name: Any, ) -> None: assert self.instance is not None - self.instance.enforce_user_cut(solver, model, violation) + self.instance.enforce_user_cut(solver, model, violation_name) @overrides def load(self) -> None: diff --git a/miplearn/problems/tsp.py b/miplearn/problems/tsp.py index b277e3a..4261fea 100644 --- a/miplearn/problems/tsp.py +++ b/miplearn/problems/tsp.py @@ -1,7 +1,7 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -from typing import List, Tuple, FrozenSet, Any, Optional, Dict +from typing import List, Tuple, Any, Optional, Dict import networkx as nx import numpy as np @@ -86,14 +86,15 @@ class TravelingSalesmanInstance(Instance): self, solver: InternalSolver, model: Any, - ) -> List[ConstraintName]: + ) -> Dict[ConstraintName, List]: selected_edges = [e for e in self.edges if model.x[e].value > 0.5] graph = nx.Graph() graph.add_edges_from(selected_edges) - violations = [] + violations = {} for c in list(nx.connected_components(graph)): if len(c) < self.n_cities: - violations.append(",".join(map(str, c)).encode()) + cname = ("st[" + ",".join(map(str, c)) + "]").encode() + violations[cname] = list(c) return violations @overrides @@ -101,10 +102,9 @@ class TravelingSalesmanInstance(Instance): self, solver: InternalSolver, model: Any, - violation: ConstraintName, + component: List, ) -> None: assert isinstance(solver, BasePyomoSolver) - component = [int(v) for v in violation.decode().split(",")] cut_edges = [ e for e in self.edges diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 8311961..7e2132f 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -710,7 +710,7 @@ class GurobiTestInstanceKnapsack(PyomoTestInstanceKnapsack): self, solver: InternalSolver, model: Any, - violation: str, + violation_data: Any, ) -> None: x0 = model.getVarByName("x[0]") model.cbLazy(x0 <= 0) diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index 3bc74d3..34a8416 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -247,7 +247,7 @@ def run_lazy_cb_tests(solver: InternalSolver) -> None: assert relsol is not None assert relsol[b"x[0]"] is not None if relsol[b"x[0]"] > 0: - instance.enforce_lazy_constraint(cb_solver, cb_model, b"cut") + instance.enforce_lazy_constraint(cb_solver, cb_model, None) solver.set_instance(instance, model) solver.solve(lazy_cb=lazy_cb) diff --git a/tests/components/test_dynamic_lazy.py b/tests/components/test_dynamic_lazy.py index 4fbdc0b..5d1faa8 100644 --- a/tests/components/test_dynamic_lazy.py +++ b/tests/components/test_dynamic_lazy.py @@ -10,6 +10,7 @@ import pytest from miplearn.classifiers import Classifier from miplearn.classifiers.threshold import MinProbabilityThreshold from miplearn.components import classifier_evaluation_dict +from miplearn.components.dynamic_common import DynamicConstraintsComponent from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent from miplearn.features.sample import MemorySample from miplearn.instance.base import Instance @@ -24,13 +25,23 @@ def training_instances() -> List[Instance]: samples_0 = [ MemorySample( { - "mip_constr_lazy_enforced": np.array(["c1", "c2"], dtype="S"), + "mip_constr_lazy": DynamicConstraintsComponent.encode( + { + b"c1": 0, + b"c2": 0, + } + ), "static_instance_features": np.array([5.0]), }, ), MemorySample( { - "mip_constr_lazy_enforced": np.array(["c2", "c3"], dtype="S"), + "mip_constr_lazy": DynamicConstraintsComponent.encode( + { + b"c2": 0, + b"c3": 0, + } + ), "static_instance_features": np.array([5.0]), }, ), @@ -55,7 +66,12 @@ def training_instances() -> List[Instance]: samples_1 = [ MemorySample( { - "mip_constr_lazy_enforced": np.array(["c3", "c4"], dtype="S"), + "mip_constr_lazy": DynamicConstraintsComponent.encode( + { + b"c3": 0, + b"c4": 0, + } + ), "static_instance_features": np.array([8.0]), }, ) @@ -83,8 +99,8 @@ def test_sample_xy(training_instances: List[Instance]) -> None: comp = DynamicLazyConstraintsComponent() comp.pre_fit( [ - np.array(["c1", "c3", "c4"], dtype="S"), - np.array(["c1", "c2", "c4"], dtype="S"), + {b"c1": 0, b"c3": 0, b"c4": 0}, + {b"c1": 0, b"c2": 0, b"c4": 0}, ] ) x_expected = { @@ -105,7 +121,10 @@ def test_sample_xy(training_instances: List[Instance]) -> None: def test_sample_predict_evaluate(training_instances: List[Instance]) -> None: comp = DynamicLazyConstraintsComponent() - comp.known_cids.extend([b"c1", b"c2", b"c3", b"c4"]) + comp.known_violations[b"c1"] = 0 + comp.known_violations[b"c2"] = 0 + comp.known_violations[b"c3"] = 0 + comp.known_violations[b"c4"] = 0 comp.thresholds[b"type-a"] = MinProbabilityThreshold([0.5, 0.5]) comp.thresholds[b"type-b"] = MinProbabilityThreshold([0.5, 0.5]) comp.classifiers[b"type-a"] = Mock(spec=Classifier) diff --git a/tests/components/test_dynamic_user_cuts.py b/tests/components/test_dynamic_user_cuts.py index 57bbbd8..040d48e 100644 --- a/tests/components/test_dynamic_user_cuts.py +++ b/tests/components/test_dynamic_user_cuts.py @@ -1,9 +1,9 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - +import json import logging -from typing import Any, FrozenSet, List +from typing import Any, List, Dict import gurobipy as gp import networkx as nx @@ -12,12 +12,11 @@ from gurobipy import GRB from networkx import Graph from overrides import overrides -from miplearn.solvers.learning import InternalSolver from miplearn.components.dynamic_user_cuts import UserCutsComponent from miplearn.instance.base import Instance from miplearn.solvers.gurobi import GurobiSolver from miplearn.solvers.learning import LearningSolver -from miplearn.types import ConstraintName, ConstraintCategory +from miplearn.types import ConstraintName logger = logging.getLogger(__name__) @@ -41,13 +40,14 @@ class GurobiStableSetProblem(Instance): return True @overrides - def find_violated_user_cuts(self, model: Any) -> List[ConstraintName]: + def find_violated_user_cuts(self, model: Any) -> Dict[ConstraintName, Any]: assert isinstance(model, gp.Model) vals = model.cbGetNodeRel(model.getVars()) - violations = [] + violations = {} for clique in nx.find_cliques(self.graph): if sum(vals[i] for i in clique) > 1: - violations.append(",".join([str(i) for i in clique]).encode()) + vname = (",".join([str(i) for i in clique])).encode() + violations[vname] = list(clique) return violations @overrides @@ -55,9 +55,8 @@ class GurobiStableSetProblem(Instance): self, solver: GurobiSolver, model: Any, - cid: ConstraintName, + clique: List[int], ) -> Any: - clique = [int(i) for i in cid.decode().split(",")] x = model.getVars() constr = gp.quicksum([x[i] for i in clique]) <= 1 if solver.cb_where: @@ -86,9 +85,11 @@ def test_usage( ) -> None: stats_before = solver.solve(stab_instance) sample = stab_instance.get_samples()[0] - user_cuts_enforced = sample.get_array("mip_user_cuts_enforced") - assert user_cuts_enforced is not None - assert len(user_cuts_enforced) > 0 + user_cuts_encoded = sample.get_scalar("mip_user_cuts") + assert user_cuts_encoded is not None + user_cuts = json.loads(user_cuts_encoded) + assert user_cuts is not None + assert len(user_cuts) > 0 assert stats_before["UserCuts: Added ahead-of-time"] == 0 assert stats_before["UserCuts: Added in callback"] > 0 diff --git a/tests/problems/test_tsp.py b/tests/problems/test_tsp.py index 8572635..f0216ee 100644 --- a/tests/problems/test_tsp.py +++ b/tests/problems/test_tsp.py @@ -1,6 +1,7 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. +import json import numpy as np from numpy.linalg import norm @@ -66,9 +67,15 @@ def test_subtour() -> None: samples = instance.get_samples() assert len(samples) == 1 sample = samples[0] - lazy_enforced = sample.get_array("mip_constr_lazy_enforced") - assert lazy_enforced is not None - assert len(lazy_enforced) > 0 + + lazy_encoded = sample.get_scalar("mip_constr_lazy") + assert lazy_encoded is not None + lazy = json.loads(lazy_encoded) + assert lazy == { + "st[0,1,4]": [0, 1, 4], + "st[2,3,5]": [2, 3, 5], + } + assert_equals( sample.get_array("mip_var_values"), [ From 1811492557655f872e2c77ed84e6b011ab48fc84 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 25 Jan 2022 11:57:14 -0600 Subject: [PATCH 04/10] Fix failing Gurobi tests --- tests/components/test_dynamic_user_cuts.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/components/test_dynamic_user_cuts.py b/tests/components/test_dynamic_user_cuts.py index 040d48e..10e688d 100644 --- a/tests/components/test_dynamic_user_cuts.py +++ b/tests/components/test_dynamic_user_cuts.py @@ -5,6 +5,7 @@ import json import logging from typing import Any, List, Dict +import gurobipy import gurobipy as gp import networkx as nx import pytest @@ -42,7 +43,10 @@ class GurobiStableSetProblem(Instance): @overrides def find_violated_user_cuts(self, model: Any) -> Dict[ConstraintName, Any]: assert isinstance(model, gp.Model) - vals = model.cbGetNodeRel(model.getVars()) + try: + vals = model.cbGetNodeRel(model.getVars()) + except gurobipy.GurobiError: + return {} violations = {} for clique in nx.find_cliques(self.graph): if sum(vals[i] for i in clique) > 1: @@ -74,7 +78,7 @@ def stab_instance() -> Instance: @pytest.fixture def solver() -> LearningSolver: return LearningSolver( - solver=GurobiSolver(), + solver=GurobiSolver(params={"Threads": 1}), components=[UserCutsComponent()], ) From b0d63a0a2d86003f044ebaa25abda76c7492d758 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 22 Feb 2022 09:16:37 -0600 Subject: [PATCH 05/10] Make MaxWeightStableSetGenerator return data class --- miplearn/problems/stab.py | 34 ++++++++++------------------------ tests/problems/test_stab.py | 10 +++++----- tests/test_benchmark.py | 15 ++++++++++++--- 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/miplearn/problems/stab.py b/miplearn/problems/stab.py index a64fb3c..97e5559 100644 --- a/miplearn/problems/stab.py +++ b/miplearn/problems/stab.py @@ -1,7 +1,9 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -from typing import List, Dict + +from dataclasses import dataclass +from typing import List import networkx as nx import numpy as np @@ -14,26 +16,10 @@ from scipy.stats.distributions import rv_frozen from miplearn.instance.base import Instance -class ChallengeA: - def __init__( - self, - seed: int = 42, - n_training_instances: int = 500, - n_test_instances: int = 50, - ) -> None: - np.random.seed(seed) - self.generator = MaxWeightStableSetGenerator( - w=uniform(loc=100.0, scale=50.0), - n=randint(low=200, high=201), - p=uniform(loc=0.05, scale=0.0), - fix_graph=True, - ) - - np.random.seed(seed + 1) - self.training_instances = self.generator.generate(n_training_instances) - - np.random.seed(seed + 2) - self.test_instances = self.generator.generate(n_test_instances) +@dataclass +class MaxWeightStableSetData: + graph: Graph + weights: np.ndarray class MaxWeightStableSetInstance(Instance): @@ -132,14 +118,14 @@ class MaxWeightStableSetGenerator: if fix_graph: self.graph = self._generate_graph() - def generate(self, n_samples: int) -> List[MaxWeightStableSetInstance]: - def _sample() -> MaxWeightStableSetInstance: + def generate(self, n_samples: int) -> List[MaxWeightStableSetData]: + def _sample() -> MaxWeightStableSetData: if self.graph is not None: graph = self.graph else: graph = self._generate_graph() weights = self.w.rvs(graph.number_of_nodes()) - return MaxWeightStableSetInstance(graph, weights) + return MaxWeightStableSetData(graph, weights) return [_sample() for _ in range(n_samples)] diff --git a/tests/problems/test_stab.py b/tests/problems/test_stab.py index df40d33..e04a5e0 100644 --- a/tests/problems/test_stab.py +++ b/tests/problems/test_stab.py @@ -29,8 +29,8 @@ def test_stab_generator_fixed_graph() -> None: p=uniform(loc=0.05, scale=0.0), fix_graph=True, ) - instances = gen.generate(1_000) - weights = np.array([instance.weights for instance in instances]) + data = gen.generate(1_000) + weights = np.array([d.weights for d in data]) weights_avg_actual = np.round(np.average(weights, axis=0)) weights_avg_expected = [55.0] * 10 assert list(weights_avg_actual) == weights_avg_expected @@ -46,8 +46,8 @@ def test_stab_generator_random_graph() -> None: p=uniform(loc=0.5, scale=0.0), fix_graph=False, ) - instances = gen.generate(1_000) - n_nodes = [instance.graph.number_of_nodes() for instance in instances] - n_edges = [instance.graph.number_of_edges() for instance in instances] + data = gen.generate(1_000) + n_nodes = [d.graph.number_of_nodes() for d in data] + n_edges = [d.graph.number_of_edges() for d in data] assert np.round(np.mean(n_nodes)) == 35.0 assert np.round(np.mean(n_edges), -1) == 300.0 diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index ad72bf4..da1c096 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -7,7 +7,10 @@ import os.path from scipy.stats import randint from miplearn.benchmark import BenchmarkRunner -from miplearn.problems.stab import MaxWeightStableSetGenerator +from miplearn.problems.stab import ( + MaxWeightStableSetInstance, + MaxWeightStableSetGenerator, +) from miplearn.solvers.learning import LearningSolver @@ -15,8 +18,14 @@ def test_benchmark() -> None: for n_jobs in [1, 4]: # Generate training and test instances generator = MaxWeightStableSetGenerator(n=randint(low=25, high=26)) - train_instances = generator.generate(5) - test_instances = generator.generate(3) + train_instances = [ + MaxWeightStableSetInstance(data.graph, data.weights) + for data in generator.generate(5) + ] + test_instances = [ + MaxWeightStableSetInstance(data.graph, data.weights) + for data in generator.generate(3) + ] # Solve training instances training_solver = LearningSolver() From 03e5acb11a2560ba00707d53fc766c6f8e97b931 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 22 Feb 2022 09:20:17 -0600 Subject: [PATCH 06/10] Make MultiKnapsackGenerator return data class --- miplearn/problems/knapsack.py | 37 ++++++--------------------------- tests/problems/test_knapsack.py | 17 +++++++++------ 2 files changed, 17 insertions(+), 37 deletions(-) diff --git a/miplearn/problems/knapsack.py b/miplearn/problems/knapsack.py index 1dd06ef..3ca9096 100644 --- a/miplearn/problems/knapsack.py +++ b/miplearn/problems/knapsack.py @@ -1,7 +1,7 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - +from dataclasses import dataclass from typing import List, Dict, Optional import numpy as np @@ -13,36 +13,11 @@ from scipy.stats.distributions import rv_frozen from miplearn.instance.base import Instance -class ChallengeA: - """ - - 250 variables, 10 constraints, fixed weights - - w ~ U(0, 1000), jitter ~ U(0.95, 1.05) - - K = 500, u ~ U(0., 1.) - - alpha = 0.25 - """ - - def __init__( - self, - seed: int = 42, - n_training_instances: int = 500, - n_test_instances: int = 50, - ) -> None: - np.random.seed(seed) - self.gen = MultiKnapsackGenerator( - n=randint(low=250, high=251), - m=randint(low=10, high=11), - w=uniform(loc=0.0, scale=1000.0), - K=uniform(loc=500.0, scale=0.0), - u=uniform(loc=0.0, scale=1.0), - alpha=uniform(loc=0.25, scale=0.0), - fix_w=True, - w_jitter=uniform(loc=0.95, scale=0.1), - ) - np.random.seed(seed + 1) - self.training_instances = self.gen.generate(n_training_instances) - - np.random.seed(seed + 2) - self.test_instances = self.gen.generate(n_test_instances) +@dataclass +class MultiKnapsackData: + prices: np.ndarray + capacities: np.ndarray + weights: np.ndarray class MultiKnapsackInstance(Instance): diff --git a/tests/problems/test_knapsack.py b/tests/problems/test_knapsack.py index 06a1ee8..59561d0 100644 --- a/tests/problems/test_knapsack.py +++ b/tests/problems/test_knapsack.py @@ -6,7 +6,7 @@ import numpy as np from scipy.stats import uniform, randint from miplearn import LearningSolver -from miplearn.problems.knapsack import MultiKnapsackGenerator +from miplearn.problems.knapsack import MultiKnapsackGenerator, MultiKnapsackInstance def test_knapsack_generator() -> None: @@ -18,17 +18,22 @@ def test_knapsack_generator() -> None: u=uniform(loc=1.0, scale=1.0), alpha=uniform(loc=0.50, scale=0.0), ) - instances = gen.generate(100) - w_sum = sum(instance.weights for instance in instances) / len(instances) - b_sum = sum(instance.capacities for instance in instances) / len(instances) + data = gen.generate(100) + w_sum = sum(d.weights for d in data) / len(data) + b_sum = sum(d.capacities for d in data) / len(data) assert round(float(np.mean(w_sum)), -1) == 500.0 assert round(float(np.mean(b_sum)), -3) == 25000.0 def test_knapsack() -> None: - instance = MultiKnapsackGenerator( + data = MultiKnapsackGenerator( n=randint(low=5, high=6), m=randint(low=5, high=6), - ).generate(1)[0] + ).generate(1) + instance = MultiKnapsackInstance( + prices=data[0].prices, + capacities=data[0].capacities, + weights=data[0].weights, + ) solver = LearningSolver() solver.solve(instance) From 87bba1b38ed3bec6ccaa13ffb7af0994c1c84a40 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 22 Feb 2022 09:23:55 -0600 Subject: [PATCH 07/10] Make TravelingSalesmanGenerator return data class --- miplearn/problems/tsp.py | 33 ++++++++------------------------- tests/components/test_primal.py | 5 +++-- tests/problems/test_tsp.py | 10 +++++----- 3 files changed, 16 insertions(+), 32 deletions(-) diff --git a/miplearn/problems/tsp.py b/miplearn/problems/tsp.py index 4261fea..fc3ae75 100644 --- a/miplearn/problems/tsp.py +++ b/miplearn/problems/tsp.py @@ -1,6 +1,7 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. +from dataclasses import dataclass from typing import List, Tuple, Any, Optional, Dict import networkx as nx @@ -17,28 +18,10 @@ from miplearn.solvers.pyomo.base import BasePyomoSolver from miplearn.types import ConstraintName -class ChallengeA: - def __init__( - self, - seed: int = 42, - n_training_instances: int = 500, - n_test_instances: int = 50, - ) -> None: - np.random.seed(seed) - self.generator = TravelingSalesmanGenerator( - x=uniform(loc=0.0, scale=1000.0), - y=uniform(loc=0.0, scale=1000.0), - n=randint(low=350, high=351), - gamma=uniform(loc=0.95, scale=0.1), - fix_cities=True, - round=True, - ) - - np.random.seed(seed + 1) - self.training_instances = self.generator.generate(n_training_instances) - - np.random.seed(seed + 2) - self.test_instances = self.generator.generate(n_test_instances) +@dataclass +class TravelingSalesmanData: + n_cities: int + distances: np.ndarray class TravelingSalesmanInstance(Instance): @@ -180,8 +163,8 @@ class TravelingSalesmanGenerator: self.fixed_n = None self.fixed_cities = None - def generate(self, n_samples: int) -> List[TravelingSalesmanInstance]: - def _sample() -> TravelingSalesmanInstance: + def generate(self, n_samples: int) -> List[TravelingSalesmanData]: + def _sample() -> TravelingSalesmanData: if self.fixed_cities is not None: assert self.fixed_n is not None n, cities = self.fixed_n, self.fixed_cities @@ -191,7 +174,7 @@ class TravelingSalesmanGenerator: distances = np.tril(distances) + np.triu(distances.T, 1) if self.round: distances = distances.round() - return TravelingSalesmanInstance(n, distances) + return TravelingSalesmanData(n, distances) return [_sample() for _ in range(n_samples)] diff --git a/tests/components/test_primal.py b/tests/components/test_primal.py index 6acebee..83b1096 100644 --- a/tests/components/test_primal.py +++ b/tests/components/test_primal.py @@ -13,7 +13,7 @@ from miplearn.classifiers.threshold import Threshold from miplearn.components import classifier_evaluation_dict from miplearn.components.primal import PrimalSolutionComponent from miplearn.features.sample import Sample, MemorySample -from miplearn.problems.tsp import TravelingSalesmanGenerator +from miplearn.problems.tsp import TravelingSalesmanGenerator, TravelingSalesmanInstance from miplearn.solvers.learning import LearningSolver from miplearn.solvers.tests import assert_equals @@ -108,7 +108,8 @@ def test_usage() -> None: ] ) gen = TravelingSalesmanGenerator(n=randint(low=5, high=6)) - instance = gen.generate(1)[0] + data = gen.generate(1) + instance = TravelingSalesmanInstance(data[0].n_cities, data[0].distances) solver.solve(instance) solver.fit([instance]) stats = solver.solve(instance) diff --git a/tests/problems/test_tsp.py b/tests/problems/test_tsp.py index f0216ee..5c6fbc8 100644 --- a/tests/problems/test_tsp.py +++ b/tests/problems/test_tsp.py @@ -14,17 +14,17 @@ from miplearn.solvers.tests import assert_equals def test_generator() -> None: - instances = TravelingSalesmanGenerator( + data = TravelingSalesmanGenerator( x=uniform(loc=0.0, scale=1000.0), y=uniform(loc=0.0, scale=1000.0), n=randint(low=100, high=101), gamma=uniform(loc=0.95, scale=0.1), fix_cities=True, ).generate(100) - assert len(instances) == 100 - assert instances[0].n_cities == 100 - assert norm(instances[0].distances - instances[0].distances.T) < 1e-6 - d = [instance.distances[0, 1] for instance in instances] + assert len(data) == 100 + assert data[0].n_cities == 100 + assert norm(data[0].distances - data[0].distances.T) < 1e-6 + d = [d.distances[0, 1] for d in data] assert np.std(d) > 0 From c98ff4eab4d8bddfc55848f870d1cfa22522541f Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 22 Feb 2022 09:34:08 -0600 Subject: [PATCH 08/10] Implement save function --- miplearn/__init__.py | 1 + miplearn/instance/picklegz.py | 24 ++++++++++++++++++++++++ tests/instance/test_picklegz.py | 17 +++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/miplearn/__init__.py b/miplearn/__init__.py index 692281f..071ff3c 100644 --- a/miplearn/__init__.py +++ b/miplearn/__init__.py @@ -19,6 +19,7 @@ from .instance.picklegz import ( write_pickle_gz, read_pickle_gz, write_pickle_gz_multiple, + save, ) from .log import setup_logger from .solvers.gurobi import GurobiSolver diff --git a/miplearn/instance/picklegz.py b/miplearn/instance/picklegz.py index bdceae7..1a9db99 100644 --- a/miplearn/instance/picklegz.py +++ b/miplearn/instance/picklegz.py @@ -153,3 +153,27 @@ def read_pickle_gz(filename: str) -> Any: def write_pickle_gz_multiple(objs: List[Any], dirname: str) -> None: for (i, obj) in enumerate(objs): write_pickle_gz(obj, f"{dirname}/{i:05d}.pkl.gz") + + +def save(objs: List[Any], dirname: str) -> List[str]: + """ + Saves the provided objects to gzipped pickled files. Files are named sequentially + as `dirname/00000.pkl.gz`, `dirname/00001.pkl.gz`, etc. + + Parameters + ---------- + objs: List[any] + List of files to save + dirname: str + Output directory + + Returns + ------- + List containing the relative paths of the saved files. + """ + filenames = [] + for (i, obj) in enumerate(objs): + filename = f"{dirname}/{i:05d}.pkl.gz" + filenames.append(filename) + write_pickle_gz(obj, filename) + return filenames diff --git a/tests/instance/test_picklegz.py b/tests/instance/test_picklegz.py index ebdb017..e7b14e3 100644 --- a/tests/instance/test_picklegz.py +++ b/tests/instance/test_picklegz.py @@ -1,10 +1,16 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. + import tempfile +from typing import cast, IO from miplearn.instance.picklegz import write_pickle_gz, PickleGzInstance from miplearn.solvers.gurobi import GurobiSolver +from miplearn import save +from os.path import exists +import gzip +import pickle def test_usage() -> None: @@ -14,3 +20,14 @@ def test_usage() -> None: pickled = PickleGzInstance(file.name) pickled.load() assert pickled.to_model() is not None + + +def test_save() -> None: + objs = [1, "ABC", True] + with tempfile.TemporaryDirectory() as dirname: + filenames = save(objs, dirname) + assert len(filenames) == 3 + for (idx, f) in enumerate(filenames): + assert exists(f) + with gzip.GzipFile(f, "rb") as file: + assert pickle.load(cast(IO[bytes], file)) == objs[idx] From 522f3a7e180631d2cbf5ca2130b78991d93675b0 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 22 Feb 2022 15:21:56 -0600 Subject: [PATCH 09/10] Change LearningSolver.solve and fit --- miplearn/problems/stab.py | 14 ++ miplearn/solvers/learning.py | 156 ++++++++++++--------- tests/components/test_dynamic_user_cuts.py | 6 +- tests/components/test_objective.py | 6 +- tests/components/test_primal.py | 6 +- tests/instance/test_file.py | 2 +- tests/problems/test_knapsack.py | 2 +- tests/problems/test_stab.py | 2 +- tests/problems/test_tsp.py | 8 +- tests/solvers/test_learning_solver.py | 62 +++++--- 10 files changed, 157 insertions(+), 107 deletions(-) diff --git a/miplearn/problems/stab.py b/miplearn/problems/stab.py index 97e5559..caa74c2 100644 --- a/miplearn/problems/stab.py +++ b/miplearn/problems/stab.py @@ -131,3 +131,17 @@ class MaxWeightStableSetGenerator: def _generate_graph(self) -> Graph: return nx.generators.random_graphs.binomial_graph(self.n.rvs(), self.p.rvs()) + + +def build_stab_model(data: MaxWeightStableSetData) -> pe.ConcreteModel: + model = pe.ConcreteModel() + nodes = list(data.graph.nodes) + model.x = pe.Var(nodes, domain=pe.Binary) + model.OBJ = pe.Objective( + expr=sum(model.x[v] * data.weights[v] for v in nodes), + sense=pe.maximize, + ) + model.clique_eqs = pe.ConstraintList() + for clique in nx.find_cliques(data.graph): + model.clique_eqs.add(sum(model.x[v] for v in clique) <= 1) + return model diff --git a/miplearn/solvers/learning.py b/miplearn/solvers/learning.py index 753a228..1232a14 100644 --- a/miplearn/solvers/learning.py +++ b/miplearn/solvers/learning.py @@ -5,10 +5,12 @@ import logging import time import traceback -from typing import Optional, List, Any, cast, Dict, Tuple +from typing import Optional, List, Any, cast, Dict, Tuple, Callable, IO +from overrides import overrides from p_tqdm import p_map +from miplearn.features.sample import Hdf5Sample, Sample from miplearn.components.component import Component from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent from miplearn.components.dynamic_user_cuts import UserCutsComponent @@ -16,15 +18,44 @@ from miplearn.components.objective import ObjectiveValueComponent from miplearn.components.primal import PrimalSolutionComponent from miplearn.features.extractor import FeaturesExtractor from miplearn.instance.base import Instance -from miplearn.instance.picklegz import PickleGzInstance from miplearn.solvers import _RedirectOutput from miplearn.solvers.internal import InternalSolver from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver from miplearn.types import LearningSolveStats +import gzip +import pickle +from os.path import exists logger = logging.getLogger(__name__) +class InstanceWrapper(Instance): + def __init__(self, data_filename: Any, build_model: Callable): + super().__init__() + assert data_filename.endswith(".pkl.gz") + self.filename = data_filename + self.sample_filename = data_filename.replace(".pkl.gz", ".h5") + self.sample = Hdf5Sample( + self.sample_filename, + mode="r+" if exists(self.sample_filename) else "w", + ) + self.build_model = build_model + + @overrides + def to_model(self) -> Any: + with gzip.GzipFile(self.filename, "rb") as file: + data = pickle.load(cast(IO[bytes], file)) + return self.build_model(data) + + @overrides + def create_sample(self) -> Sample: + return self.sample + + @overrides + def get_samples(self) -> List[Sample]: + return [self.sample] + + class _GlobalVariables: def __init__(self) -> None: self.solver: Optional[LearningSolver] = None @@ -47,7 +78,7 @@ def _parallel_solve( assert solver is not None assert instances is not None try: - stats = solver.solve( + stats = solver._solve( instances[idx], discard_output=discard_outputs, ) @@ -86,11 +117,6 @@ class LearningSolver: option should be activated if the LP relaxation is not very expensive to solve and if it provides good hints for the integer solution. - simulate_perfect: bool - If true, each call to solve actually performs three actions: solve - the original problem, train the ML models on the data that was just - collected, and solve the problem again. This is useful for evaluating - the theoretical performance of perfect ML models. """ def __init__( @@ -100,7 +126,6 @@ class LearningSolver: solver: Optional[InternalSolver] = None, use_lazy_cb: bool = False, solve_lp: bool = True, - simulate_perfect: bool = False, extractor: Optional[FeaturesExtractor] = None, extract_lhs: bool = True, extract_sa: bool = True, @@ -117,7 +142,6 @@ class LearningSolver: self.internal_solver: Optional[InternalSolver] = None self.internal_solver_prototype: InternalSolver = solver self.mode: str = mode - self.simulate_perfect: bool = simulate_perfect self.solve_lp: bool = solve_lp self.tee = False self.use_lazy_cb: bool = use_lazy_cb @@ -139,6 +163,44 @@ class LearningSolver: discard_output: bool = False, tee: bool = False, ) -> LearningSolveStats: + """ + Solves the given instance. If trained machine-learning models are + available, they will be used to accelerate the solution process. + + The argument `instance` may be either an Instance object or a + filename pointing to a pickled Instance object. + + This method adds a new training sample to `instance.training_sample`. + If a filename is provided, then the file is modified in-place. That is, + the original file is overwritten. + + If `solver.solve_lp_first` is False, the properties lp_solution and + lp_value will be set to dummy values. + + Parameters + ---------- + instance: Instance + The instance to be solved. + model: Any + The corresponding Pyomo model. If not provided, it will be created. + discard_output: bool + If True, do not write the modified instances anywhere; simply discard + them. Useful during benchmarking. + tee: bool + If true, prints solver log to screen. + + Returns + ------- + LearningSolveStats + A dictionary of solver statistics containing at least the following + keys: "Lower bound", "Upper bound", "Wallclock time", "Nodes", + "Sense", "Log", "Warm start value" and "LP value". + + Additional components may generate additional keys. For example, + ObjectiveValueComponent adds the keys "Predicted LB" and + "Predicted UB". See the documentation of each component for more + details. + """ # Generate model # ------------------------------------------------------- @@ -299,65 +361,19 @@ class LearningSolver: def solve( self, - instance: Instance, - model: Any = None, - discard_output: bool = False, - tee: bool = False, - ) -> LearningSolveStats: - """ - Solves the given instance. If trained machine-learning models are - available, they will be used to accelerate the solution process. - - The argument `instance` may be either an Instance object or a - filename pointing to a pickled Instance object. - - This method adds a new training sample to `instance.training_sample`. - If a filename is provided, then the file is modified in-place. That is, - the original file is overwritten. - - If `solver.solve_lp_first` is False, the properties lp_solution and - lp_value will be set to dummy values. - - Parameters - ---------- - instance: Instance - The instance to be solved. - model: Any - The corresponding Pyomo model. If not provided, it will be created. - discard_output: bool - If True, do not write the modified instances anywhere; simply discard - them. Useful during benchmarking. - tee: bool - If true, prints solver log to screen. - - Returns - ------- - LearningSolveStats - A dictionary of solver statistics containing at least the following - keys: "Lower bound", "Upper bound", "Wallclock time", "Nodes", - "Sense", "Log", "Warm start value" and "LP value". + filenames: List[str], + build_model: Callable, + tee: bool = True, + ) -> List[LearningSolveStats]: + stats = [] + for f in filenames: + s = self._solve(InstanceWrapper(f, build_model), tee=tee) + stats.append(s) + return stats - Additional components may generate additional keys. For example, - ObjectiveValueComponent adds the keys "Predicted LB" and - "Predicted UB". See the documentation of each component for more - details. - """ - if self.simulate_perfect: - if not isinstance(instance, PickleGzInstance): - raise Exception("Not implemented") - self._solve( - instance=instance, - model=model, - tee=tee, - ) - self.fit([instance]) - instance.instance = None - return self._solve( - instance=instance, - model=model, - discard_output=discard_output, - tee=tee, - ) + def fit(self, filenames: List[str], build_model: Callable) -> None: + instances: List[Instance] = [InstanceWrapper(f, build_model) for f in filenames] + self._fit(instances) def parallel_solve( self, @@ -394,7 +410,7 @@ class LearningSolver: `[solver.solve(p) for p in instances]` """ if n_jobs == 1: - return [self.solve(p) for p in instances] + return [self._solve(p) for p in instances] else: self.internal_solver = None self._silence_miplearn_logger() @@ -415,7 +431,7 @@ class LearningSolver: self._restore_miplearn_logger() return stats - def fit( + def _fit( self, training_instances: List[Instance], n_jobs: int = 1, diff --git a/tests/components/test_dynamic_user_cuts.py b/tests/components/test_dynamic_user_cuts.py index 10e688d..f8b3a5f 100644 --- a/tests/components/test_dynamic_user_cuts.py +++ b/tests/components/test_dynamic_user_cuts.py @@ -87,7 +87,7 @@ def test_usage( stab_instance: Instance, solver: LearningSolver, ) -> None: - stats_before = solver.solve(stab_instance) + stats_before = solver._solve(stab_instance) sample = stab_instance.get_samples()[0] user_cuts_encoded = sample.get_scalar("mip_user_cuts") assert user_cuts_encoded is not None @@ -97,8 +97,8 @@ def test_usage( assert stats_before["UserCuts: Added ahead-of-time"] == 0 assert stats_before["UserCuts: Added in callback"] > 0 - solver.fit([stab_instance]) - stats_after = solver.solve(stab_instance) + solver._fit([stab_instance]) + stats_after = solver._solve(stab_instance) assert ( stats_after["UserCuts: Added ahead-of-time"] == stats_before["UserCuts: Added in callback"] diff --git a/tests/components/test_objective.py b/tests/components/test_objective.py index fc45083..f81eb8d 100644 --- a/tests/components/test_objective.py +++ b/tests/components/test_objective.py @@ -134,8 +134,8 @@ def test_sample_evaluate(sample: Sample) -> None: def test_usage() -> None: solver = LearningSolver(components=[ObjectiveValueComponent()]) instance = GurobiPyomoSolver().build_test_instance_knapsack() - solver.solve(instance) - solver.fit([instance]) - stats = solver.solve(instance) + solver._solve(instance) + solver._fit([instance]) + stats = solver._solve(instance) assert stats["mip_lower_bound"] == stats["Objective: Predicted lower bound"] assert stats["mip_upper_bound"] == stats["Objective: Predicted upper bound"] diff --git a/tests/components/test_primal.py b/tests/components/test_primal.py index 83b1096..aa6074a 100644 --- a/tests/components/test_primal.py +++ b/tests/components/test_primal.py @@ -110,9 +110,9 @@ def test_usage() -> None: gen = TravelingSalesmanGenerator(n=randint(low=5, high=6)) data = gen.generate(1) instance = TravelingSalesmanInstance(data[0].n_cities, data[0].distances) - solver.solve(instance) - solver.fit([instance]) - stats = solver.solve(instance) + solver._solve(instance) + solver._fit([instance]) + stats = solver._solve(instance) assert stats["Primal: Free"] == 0 assert stats["Primal: One"] + stats["Primal: Zero"] == 10 assert stats["mip_lower_bound"] == stats["mip_warm_start_value"] diff --git a/tests/instance/test_file.py b/tests/instance/test_file.py index bad2fc5..446cb2e 100644 --- a/tests/instance/test_file.py +++ b/tests/instance/test_file.py @@ -22,7 +22,7 @@ def test_usage() -> None: # Solve instance from disk solver = LearningSolver(solver=GurobiSolver()) - solver.solve(FileInstance(filename)) + solver._solve(FileInstance(filename)) # Assert HDF5 contains training data sample = FileInstance(filename).get_samples()[0] diff --git a/tests/problems/test_knapsack.py b/tests/problems/test_knapsack.py index 59561d0..760b58c 100644 --- a/tests/problems/test_knapsack.py +++ b/tests/problems/test_knapsack.py @@ -36,4 +36,4 @@ def test_knapsack() -> None: weights=data[0].weights, ) solver = LearningSolver() - solver.solve(instance) + solver._solve(instance) diff --git a/tests/problems/test_stab.py b/tests/problems/test_stab.py index e04a5e0..27a2e78 100644 --- a/tests/problems/test_stab.py +++ b/tests/problems/test_stab.py @@ -15,7 +15,7 @@ def test_stab() -> None: weights = np.array([1.0, 1.0, 1.0, 1.0, 1.0]) instance = MaxWeightStableSetInstance(graph, weights) solver = LearningSolver() - stats = solver.solve(instance) + stats = solver._solve(instance) assert stats["mip_lower_bound"] == 2.0 diff --git a/tests/problems/test_tsp.py b/tests/problems/test_tsp.py index 5c6fbc8..f3cc510 100644 --- a/tests/problems/test_tsp.py +++ b/tests/problems/test_tsp.py @@ -40,7 +40,7 @@ def test_instance() -> None: ) instance = TravelingSalesmanInstance(n_cities, distances) solver = LearningSolver() - solver.solve(instance) + solver._solve(instance) assert len(instance.get_samples()) == 1 sample = instance.get_samples()[0] assert_equals(sample.get_array("mip_var_values"), [1.0, 0.0, 1.0, 1.0, 0.0, 1.0]) @@ -63,7 +63,7 @@ def test_subtour() -> None: distances = squareform(pdist(cities)) instance = TravelingSalesmanInstance(n_cities, distances) solver = LearningSolver() - solver.solve(instance) + solver._solve(instance) samples = instance.get_samples() assert len(samples) == 1 sample = samples[0] @@ -96,5 +96,5 @@ def test_subtour() -> None: 1.0, ], ) - solver.fit([instance]) - solver.solve(instance) + solver._fit([instance]) + solver._solve(instance) diff --git a/tests/solvers/test_learning_solver.py b/tests/solvers/test_learning_solver.py index 97fcf47..02d08f6 100644 --- a/tests/solvers/test_learning_solver.py +++ b/tests/solvers/test_learning_solver.py @@ -5,19 +5,27 @@ import logging import os import tempfile +from os.path import exists from typing import List, cast import dill +from scipy.stats import randint +from miplearn.features.sample import Hdf5Sample from miplearn.instance.base import Instance -from miplearn.instance.picklegz import PickleGzInstance, write_pickle_gz, read_pickle_gz -from miplearn.solvers.gurobi import GurobiSolver +from miplearn.instance.picklegz import ( + PickleGzInstance, + write_pickle_gz, + read_pickle_gz, + save, +) +from miplearn.problems.stab import MaxWeightStableSetGenerator, build_stab_model from miplearn.solvers.internal import InternalSolver from miplearn.solvers.learning import LearningSolver +from miplearn.solvers.tests import assert_equals # noinspection PyUnresolvedReferences from tests.solvers.test_internal_solver import internal_solvers -from miplearn.solvers.tests import assert_equals logger = logging.getLogger(__name__) @@ -34,7 +42,7 @@ def test_learning_solver( mode=mode, ) - solver.solve(instance) + solver._solve(instance) assert len(instance.get_samples()) > 0 sample = instance.get_samples()[0] @@ -55,8 +63,8 @@ def test_learning_solver( assert lp_log is not None assert len(lp_log) > 100 - solver.fit([instance], n_jobs=4) - solver.solve(instance) + solver._fit([instance], n_jobs=4) + solver._solve(instance) # Assert solver is picklable with tempfile.TemporaryFile() as file: @@ -73,9 +81,9 @@ def test_solve_without_lp( solver=internal_solver, solve_lp=False, ) - solver.solve(instance) - solver.fit([instance]) - solver.solve(instance) + solver._solve(instance) + solver._fit([instance]) + solver._solve(instance) def test_parallel_solve( @@ -104,7 +112,7 @@ def test_solve_fit_from_disk( # Test: solve solver = LearningSolver(solver=internal_solver) - solver.solve(instances[0]) + solver._solve(instances[0]) instance_loaded = read_pickle_gz(cast(PickleGzInstance, instances[0]).filename) assert len(instance_loaded.get_samples()) > 0 @@ -119,17 +127,29 @@ def test_solve_fit_from_disk( os.remove(cast(PickleGzInstance, instance).filename) -def test_simulate_perfect() -> None: - internal_solver = GurobiSolver() - instance = internal_solver.build_test_instance_knapsack() - with tempfile.NamedTemporaryFile(suffix=".pkl", delete=False) as tmp: - write_pickle_gz(instance, tmp.name) - solver = LearningSolver( - solver=internal_solver, - simulate_perfect=True, - ) - stats = solver.solve(PickleGzInstance(tmp.name)) - assert stats["mip_lower_bound"] == stats["Objective: Predicted lower bound"] +def test_basic_usage() -> None: + with tempfile.TemporaryDirectory() as dirname: + # Generate instances + data = MaxWeightStableSetGenerator(n=randint(low=20, high=21)).generate(4) + train_files = save(data[0:3], f"{dirname}/train") + test_files = save(data[3:4], f"{dirname}/test") + + # Solve training instances + solver = LearningSolver() + stats = solver.solve(train_files, build_stab_model) + assert len(stats) == 3 + for f in train_files: + sample_filename = f.replace(".pkl.gz", ".h5") + assert exists(sample_filename) + sample = Hdf5Sample(sample_filename) + assert sample.get_scalar("mip_lower_bound") > 0 + + # Fit + solver.fit(train_files, build_stab_model) + + # Solve test instances + stats = solver.solve(test_files, build_stab_model) + assert "Objective: Predicted lower bound" in stats[0].keys() def test_gap() -> None: From 04dd3ad5d502ab73d6b3c406eb70cf4535d46fe5 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 25 Feb 2022 08:26:33 -0600 Subject: [PATCH 10/10] Implement load; update fit --- miplearn/__init__.py | 1 + miplearn/instance/picklegz.py | 8 +++++- miplearn/solvers/learning.py | 50 ++++++++++++++++++++++++---------- miplearn/solvers/pyomo/base.py | 14 ++++++++-- 4 files changed, 54 insertions(+), 19 deletions(-) diff --git a/miplearn/__init__.py b/miplearn/__init__.py index 071ff3c..60d6ca5 100644 --- a/miplearn/__init__.py +++ b/miplearn/__init__.py @@ -20,6 +20,7 @@ from .instance.picklegz import ( read_pickle_gz, write_pickle_gz_multiple, save, + load, ) from .log import setup_logger from .solvers.gurobi import GurobiSolver diff --git a/miplearn/instance/picklegz.py b/miplearn/instance/picklegz.py index 1a9db99..fc502ef 100644 --- a/miplearn/instance/picklegz.py +++ b/miplearn/instance/picklegz.py @@ -6,7 +6,7 @@ import gc import gzip import os import pickle -from typing import Optional, Any, List, cast, IO, TYPE_CHECKING, Dict +from typing import Optional, Any, List, cast, IO, TYPE_CHECKING, Dict, Callable import numpy as np from overrides import overrides @@ -177,3 +177,9 @@ def save(objs: List[Any], dirname: str) -> List[str]: filenames.append(filename) write_pickle_gz(obj, filename) return filenames + + +def load(filename: str, build_model: Callable) -> Any: + with gzip.GzipFile(filename, "rb") as file: + data = pickle.load(cast(IO[bytes], file)) + return build_model(data) diff --git a/miplearn/solvers/learning.py b/miplearn/solvers/learning.py index 1232a14..3a614d3 100644 --- a/miplearn/solvers/learning.py +++ b/miplearn/solvers/learning.py @@ -5,7 +5,7 @@ import logging import time import traceback -from typing import Optional, List, Any, cast, Dict, Tuple, Callable, IO +from typing import Optional, List, Any, cast, Dict, Tuple, Callable, IO, Union from overrides import overrides from p_tqdm import p_map @@ -24,13 +24,18 @@ from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver from miplearn.types import LearningSolveStats import gzip import pickle +import miplearn from os.path import exists logger = logging.getLogger(__name__) -class InstanceWrapper(Instance): - def __init__(self, data_filename: Any, build_model: Callable): +class FileInstanceWrapper(Instance): + def __init__( + self, + data_filename: Any, + build_model: Callable, + ): super().__init__() assert data_filename.endswith(".pkl.gz") self.filename = data_filename @@ -43,9 +48,7 @@ class InstanceWrapper(Instance): @overrides def to_model(self) -> Any: - with gzip.GzipFile(self.filename, "rb") as file: - data = pickle.load(cast(IO[bytes], file)) - return self.build_model(data) + return miplearn.load(self.filename, self.build_model) @overrides def create_sample(self) -> Sample: @@ -56,6 +59,17 @@ class InstanceWrapper(Instance): return [self.sample] +class MemoryInstanceWrapper(Instance): + def __init__(self, model): + super().__init__() + assert model is not None + self.model = model + + @overrides + def to_model(self) -> Any: + return self.model + + class _GlobalVariables: def __init__(self) -> None: self.solver: Optional[LearningSolver] = None @@ -361,18 +375,24 @@ class LearningSolver: def solve( self, - filenames: List[str], - build_model: Callable, - tee: bool = True, + arg: Union[Any, List[str]], + build_model: Callable = None, + tee: bool = False, ) -> List[LearningSolveStats]: - stats = [] - for f in filenames: - s = self._solve(InstanceWrapper(f, build_model), tee=tee) - stats.append(s) - return stats + if isinstance(arg, list): + assert build_model is not None + stats = [] + for i in arg: + s = self._solve(FileInstanceWrapper(i, build_model), tee=tee) + stats.append(s) + return stats + else: + return self._solve(MemoryInstanceWrapper(arg), tee=tee) def fit(self, filenames: List[str], build_model: Callable) -> None: - instances: List[Instance] = [InstanceWrapper(f, build_model) for f in filenames] + instances: List[Instance] = [ + FileInstanceWrapper(f, build_model) for f in filenames + ] self._fit(instances) def parallel_solve( diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index 5292eb0..bda7557 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -322,8 +322,14 @@ class BasePyomoSolver(InternalSolver): # Bounds lb, ub = v.bounds - upper_bounds.append(float(ub)) - lower_bounds.append(float(lb)) + if ub is not None: + upper_bounds.append(float(ub)) + else: + upper_bounds.append(float("inf")) + if lb is not None: + lower_bounds.append(float(lb)) + else: + lower_bounds.append(-float("inf")) # Objective coefficient if v.name in self._obj: @@ -391,7 +397,9 @@ class BasePyomoSolver(InternalSolver): ) -> None: if model is None: model = instance.to_model() - assert isinstance(model, pe.ConcreteModel) + assert isinstance( + model, pe.ConcreteModel + ), f"expected pe.ConcreteModel; found {model.__class__} instead" self.instance = instance self.model = model self.model.extra_constraints = ConstraintList()